Jump to navigation
Tetris - iameven.com
As a continued effort to focus on games for my personal projects, I made Tetris.
I've read in some tutorials previously to start with git as the first thing, and commit often. I do see the point of doing this, I just usually forget.
But I didn't forget this time (and hopefully going forward). This gives me a somewhat good history of the project from beginning to end, and helps a lot when writing this summary. git log --reverse --oneline.
As I mentioned in my previous post, about Snake, I think it's smart to focus on just some elements. Game design may be one of the harder parts. I want to make sure I have an understanding of things like controls, animation and rendering first. Tetris still have a couple of interesting challenges, I think:
- How to store and rotate the pieces.
- Collision detection against walls, floor and other pieces.
- Verify and remove a full line.
A "simple" game like Tetris makes for fast progress that I find rewarding to continue working on. In the end, I chose Tetris because I've read it's a good place to start when wanting to make games.
After initializing the project in npm and git I created a class called Tetra (I renamed it Tetrimino after reading the wikipedia page on tetris). I decided to have all the pieces listed in the constructor, setting the selected type when creating a piece. The shapes were first stored as 2x4 arrays, with a boolean dictating the blocks to fill. I also assigned a color to each piece to separate them on rendering.
[0,1,1] [1,0] [1,1,0] => [1,1] [0,1]
Before doing the rendering I had an idea about how to rotate the array. I created a new array for every column and fed the previous rows (starting at the bottom) to the new arrays. One of the challenges, store and rotate, turned out to be fairly easy.
Before continuing I copied some configuration files from the snake project. ESLint, webpack and adding a canvas to the HTML. Then I wrote the render function and after a few tweaks I could verify that rotation worked. As part of this I created an instance of the piece in a main game file called tetris.js that also holds the main game loop.
After just half an hour I had something to look at on screen, with limited interactivity. By adding my instance of the piece directly to the window property I could test the functionality from the developer console in the browser.
Before giving up for the night I simplified my shape array to only be as large as they needed to be. The long 'I' shape had an empty row beneath it, and except for that and the 'O' block shape all the other shapes are three units wide. This helped the rotation look a bit better.
One of the things that helped me debug movement on snake was adding a grid. In snake I used it as a visual helper only, but here I realized I could use this to detect collision as well. Later I found it to be just as easy to render the piece in the grid as the pieces change shape when a line clears. To start I only added it as a visual helper.
I took the easy route of copying from snake again. The Keypress library from dmauro is easy to work with, but for some reason not on npm so I had to add it to a /lib folder and check it into git with my other code. I also copied the delta time calculation, though I later scrapped that in favor of using setTimeout instead.
After adding keyboard I also wanted to move the piece. Left and right is as easy as adding or subtracting from the current x position. I sent the grid properties to the Tetrimino piece at this point to contain left and right movement within the game board. Moving down is the same as adding to the current y position. I knew that the collision detection shouldn't live in this class, so I didn't bother limiting down movement on the piece.
At this point I had read the Wikipedia page and easy spin was mentioned. That is, as long as movement is happening the piece is stuck in space which allows for infinite spin. There are, according to said Wikipedia page some vocal critics against this as it makes the game too easy, but I think it's been in most of the versions I've played, so I added it.
The following week I went on a company kick-off to Lillehammer which sort of killed some of the momentum from the first weekend. When I continued the next weekend I just cleaned up a bit, by moving piece generation to the Tetrimino class and store settings I sent around in an object. I also made the piece spawn in the center of the board.
The next day I was back into it again and added an event emitter to the Tetrimino. I did this to send current position to the grid where I could do collision detection. However, I left this in a half finished state and didn't work on it for almost two weeks. That can be enough to kill a project.
Coming back to half finished code (not checked in to git) after two weeks is hard. I seriously considered scrapping what I had and start over. Everything. In my head right then it wouldn't be that much work and I could probably optimize some code. I decided to only scrap my unfinished code and start from my last checkpoint. I'm glad I did.
As I had sent the grid properties to the Tetrimino class and did the rendering there I depended on pixel position. The size of a unit is 30 pixels so x and y position were increments of 30 (60, 90, 120 etc…). I decided to work with grid coordinates to simplify. Instead of adding or subtracting a unit size I now use 1, or ++/--. Now I didn't need to divide by unit size to get the coordinates back.
The other thing I did in the Tetrimino was add a _store(), a place to hold the "official" position of the piece. That lets me broadcast a new position to test against collision, and if it's blocked I use the store values, if not I save the new values.
Having the Tetrimino broadcast new piece, movement and rotation everything else was depending on the grid. At first I added the board boundaries and on collision stop the piece moving by holding on to the stored values. That let me remove those checks from Tetrimino.move().
I needed to fill the occupied grid coordinates with something to indicate the space being used. That can be a boolean but can also be the piece color info. The only problem with only using color was the piece crashing into it self, because collision detection ran before setting the new positions. I chose to solve this by also adding a boolean which I flipped to true when the piece locked. Meaning that the moving piece isn't occupying any space, but since there's only ever one moving piece it shouldn't matter.
When collision worked I used the color field to fill the unit, or stroke (draw outline for grid) if not. I made the grid very low contrast to the background. Now I could remove rendering from the Tetrimino class. I removed stroke from the piece units which made for a more interesting look I think.
When the Tetrimino generates a new piece it emits "piece" along with its properties. The main file listens to that event and adds the piece properties to the grid. When a new piece is added the space the previous piece occupied is locked, the hit boolean is set to true for those coordinates. Another function, _checkLines(), goes through every line of the grid, from top to bottom, and sees if all the columns in a row is occupied. When it finds a full row it loops back from that position and copies the line above it to the current line, and fills in a new blank line on the top.
When there is a line cleared Grid emits "score" and sends the number of lines cleared. That is between 1 and 4, 4 being a Tetris. In the Wikipedia article they mention that clearing more lines at once should give more points. There are also some versions of Tetris with combos, meaning consecutive line removals adds even more points. To keep things simple I multiply the line number with itself which results in either 1, 4, 9 or 16 points.
The last thing I did was clean up a bit. The main game file, tetris.js was quite messy. I put everything into functions so starting a new game calls on the same init() function I use to start the game on load.
I added a game over state, and a sidebar to display the points. It would also be nice to show the upcoming piece in the sidebar. However, I do feel like I've reached my goal here, and I'm anxious to start on the next thing. So I'll just leave this, at least for now.
Do play Tetris, or check out my code.
The git log at the time of this writing. The messages seem somewhat cryptic, looking back at it. I guess I could work on adding clearer commit messages.
$ git log --reverse --oneline 9b012f1 init 339d7be added webpack and animationFrame polyfill 3cad059 main game rough layout and start of the piece class 40ccc6c copied webpack config and linting from snake f99f008 how I think rotate should work b9f7862 copied html canvas from snake 0ac06a5 render tetra, fix small bug in rotate, test it in main c8993a6 renamed pieces to Tetrimino, simplified the shape arrays bc05b80 update y location of Tetrimino, delte time calculation copied from snake b8cde86 added a Grid, only for rendering now, could and maybe should be used for storing where the Tetriminos are at 38b8dc4 imported keypress and controls from snake, mapped some keys and can now start 'a new game' (reset the piece) and rotate 1d2bba4 Tetrimino.move(direction) f83d8f6 left and right containment, need to move the piece or deny rotation when rotation happens on edge 67fe117 easy spin or infinite spin added 0d0e880 common options object stored in window for now, removed cases of magic numbers, changed grid color defea16 Tetrimino spawns in the center horizontally and above grid vertically fe157c1 Tetrimino reports position, grid checks collision, some clean up in tetris.js to support several pieces which I'll change later fe96fd6 moved Tetrimino generation to its class and event emitter to broadcast said event e34303f sort of broke rotation, it's still broken, just not as broken ea68eb5 Tetrimino now tracks position based on grid coordinates instead of board size cc964e7 Moved animation-frame and eventemitter to dependencies to see my actual code dependencies b563400 sorted methods alphabetically, added a storage planing for a sort of undo move if it is illegal. Emit position on storage 14e938f Use grid to test board boundries and report back to Tetrimino by resetting it b64b8cd Use timeout instead of delta time to update Tetrimino 3711973 Keep track of piece positions in Grid e6e6cbc Moved Tetrimino rendering to grid 702ad1b Press enter to start new game ef720b9 Remove full lines a0465db Add some makeup to the pig 29dd0b1 Hold move buttons for maximum speed 747efc1 Dark background on board 1fc1bfe 'build' script added b44b2be Events added to Grid, emit score on clearLines. Send update signal on move down making for a slightly more snappy experience c69099d created an init function to reset the game 871543a Game over state added a160f8e Added some instructions and link to 'homepage' 0b15b9a Added a sidebar to display points, would also be nice to display next piece there ac4b90c Properly align game board in center of page f233046 Fixed eslint multiline complaint