🎮 Play version 1 here! 🎮
Just for fun, here's a simple Tetris game built "from scratch" with vanilla JavaScript and the web browsers' built-in Canvas API. This project was originally part of my #100DaysOfCode experiment to explore some new programming topics every day.
đź’¬ Suggestions / advice always appreciated! You can make an issue or a pull request to share your ideas and coding wisdom. :)
đź““ Sections below:
Version 1 is complete, and it's on hold for the moment. But when I'm ready for more Tetris, here's my backlog of to-dos:
- More refactoring! (Always more refactoring!)
- Bundle the JS files and transpile for older browsers (learn about Webpack etc for this, probably!)
- Add replay button (so users don't need to refresh the page to play again)
- Add basic wall kicks (see bottom of this page on Tetris rotation systems for reference)
- Maybe: flesh out the scoring system
- Maybe: sound effects
- Maybe: add pause/resume feature
Obviously, the sky's the limit as far as other feature ideas go! Game modes, a multiplayer version, various experimental variations on the game logic, mashups between Tetris and other games... But I need to stop somewhere!
How was it building Tetris? Below are all my raw notes that I wrote in my #100DaysOfCode journal, in case anyone's curious.
Note: I'm planning to upload a timelapse video showing the process, since I recorded my screen while making this game!
Today I think I feel like taking a break from messing around with trees and recursion. Today I feel like doing something completely different. At least for a little while. I'm pretty sleepy too, but I know I can make time for a little programming practice today. So I was thinking, maybe I can make a game! Tetris has been on my mind for a while now since I was helping one student work towards building it herself. I'm not sure just how much work it would take to finish it, so that might be fun!
Tetris features / problems to solve:
- Animating blocks to move down the screen
- Making them stop at the bottom
- Drawing another block after the previous block has fallen
- Collision detection
- Moving blocks left and right on key press
- Game over when blocks reach the top of the screen
- Making the blocks into each Tetris shape
- Randomizing which block is next
- Rotating the blocks on key press
- Deleting when a row is completed
- Tracking the score
Finished:
- Animating a block to move down the screen
- A tetromino object constructor, with methods for movement
- A method for checking if the tetromino has reached the bottom
- Create a new block when the previous block lands
I'd love to finish Tetris today! Since I worked both days this weekend, I planned for today to be my Sunday. (Update: I only spent an hour or two coding today though.)
Finished:
-
Set up a 2D array, tracking which squares in the game grid are occupied.
-
Refactored
isFalling
tohasRoomBelow
, which checks thegameGrid
for collisions (instead of checking based on pixel calculations). -
Refactored the tetrmino's
moveDown
method to switch blocks on/off inside thegameGrid
. -
Learned how to generate an array with the
Array
constructor,fill
, andmap
. Found a couple interesting quirks:- The
Array
constructor will generate an array of the given size, but the elements are "empty" -- they have no value, not evennull
orundefined
! So weird! And that means you can't iterate over them withmap
. So the solution is to firstfill
them with a value likenull
, and then usemap
. - Lesson learned: don't use a nested
fill
-- since arrays are objects, the generated array will be filled with pointers that all point to the same array, rather than separate arrays with the same elements.
- The
Finished:
-
Finally moved this notes file to my computer so I can work on it locally and get more practice using Vim.
-
Also moved my Tetris project code to a local Git repo and started (finally!) writing my code in Vim.
-
Refactored to rename tetrominos'
x
andy
properties torow
andcol
. -
Can now move blocks left and right on key press. (Also fixed it up so key presses draw the next frame, not clicks.)
-
Added a bit of logic to prevent tetrominoes from being moved off screen.
-
Bug fix: the game grid now updates accordingly when blocks are moved left or right. (For now, the game grid is updated twice in every tick of the game loop. Will need to clean this up later.)
-
Added game over condition which displays a message on the screen
-
Random pastel colors for each tetromino
Finished:
-
Completed rows now get deleted and remaining rows are shifted down. (The simple logic here won't hold up with more complex tetromino shapes, though. But it's a start!)
-
Created nested arrays of 0s and 1s to represent each tetromino shape, then wrote a function that converts those into a flat array of Square objects with coordinates (starting at 0,0 for the top left square).
Finished:
-
(Re)learned the very basics of using import/export modules (ES6 syntax) and after quite a few annoying bugs and successfully avoiding a rabbit hole (my global npm packages don't seem to be working), I got this working example: my ES6 module import/export example code on Gist.
-
Separated code into modules: main file (renamed to
tetris.js
), tetromino constructor, and a module for helper functions. -
Switched to using p5js in "instance mode", which was the only way I could figure out how to use p5js with my own code organized as modules. (My best guess: the difference between global scope and module scope is what's messing this up!)
-
To solve scope issues (the Tetromino module can no longer access "global" vars in
tetris.js
), for now I'm just passing the variables forgameGrid
,blockSize
, and the p5js instance object all as arguments for the Tetromino constructor. There must be a better way, but this works for now.
Resources and notes on ES6 modules:
- I like the info in this article on using ES6 modules as a "high water mark" for browsers that use modern JS!
- I'm still confused about how "default" exports/imports work, but I'll save that for another day.
Misc stuff to look at later:
- Learn more about module scope vs block scope and best practices for how to structure module relationships!
- The p5js website has a beginner-friendly intro to test-driven development, which I've been wanting to finally start learning.
Finished:
- Wrote down a bunch of notes as comments in my code, brainstorming how to refactor into separate components
- Successfully updated my code with a Square module and changes to the Tetromino model, integrating the code I wrote yesterday to generate an array of Square objects from an array of 1s and 0s representing each tetromino shape.
- Did a quick test with a new p5js draw function to draw a single tetromino -- it works! (I commented out the old drawing code for now, since I need to reorganize everything and rewrite practically every method.)
- Created a new branch for the previous version named
single-square-tetris
, so I can easily jump back to my first "finished" version.
No coding today. After teaching all afternoon, and being pretty sleep-deprived again, I just didn't feel like it. I'm thinking that as far as habits go, taking a day off once a week is totally fine by me. Or the weekends. Or just treating it like going to the gym: 3 or 4 days a week works great.
Finished:
- Refactored Tetris file to break it up into two modules: one with a Tetris constructor, and a separate file for the drawing interface. So I moved all the p5js code there.
- Now the Tetris constructor has parameters for number of rows and columns, which are defined as constants in the drawing interface file
- The drawing loop calls
gameLoopTick()
which, for now, does nothing except return an array of squares to be drawn. - Gutted the Tetromino module, removing all the movement and collision-detection code and removing all references to p5js or the game grid.
Finished:
-
Now each new tetromino has a random shape
-
Wrote up some notes to try to visualize the code that I'll be writing for this new version, but I was probably just over-thinking it. I wanted to start writing the code to move a tetromino down the screen, but then I ran out of time. And I ran into a bunch of annoying bugs, and realized my local server just wasn't loading the updated files. I could've sworn that Command + the refresh button on the touch bar worked last time, but it didn't this time! (Only using the refresh button in the browser window works for a hard refresh.)
Finished:
-
Successfully moving a tetromino down the screen on each frame, yay!
-
Added
hasRoomForNextMove
method to Tetris module with downard collision detection. And it works! Phew! -
Refactoring: now storing
fallenSquares
as an array, separate from the current tatromino object (containing its own squares), instead of keeping track of a mergessquares
object. Overly complicated! Instead, now the Tetris module passes a merged array of all squares to the interface module. -
Refactoring: now the Tetromino's
move
method returns the entire tetromino object, not just its squares array. Makes more sense. -
Wrote a new
updateGameGrid
method for the Tetris module, which switches grid spaces on and off based on old and new coordinates of the current tetromino. -
Refactored
print
method to use a copy of thegameGrid
(otherwise the console would always show the newest version of the array, or I'd have to pass in a copy of it every time I call the print method, which is annoying... so I'm starting to see the usefulness of not mutating arrays, for sure!)
Finished:
-
Added left/right collision checking to
hasRoomForNextMove
, which was broken at first. (I forgot that I can't check for both sideways and downward collisions at the same time (I don't think); if there isn't room to the side, the tetromino also doesn't move down. That's no good!) -
Interface now sets the
nextMove
variable and passes it to the Tetris module'sgameLoopTick
method, which passes it along tohasRoomForNextMove
and the Tetromino module'smove
method. -
Fixed left/right movement! Current tetromino now moves left or right only, without also moving down. And now it moves down on its own every X frames. (Later might change this to every X milliseconds based on timestamp. Not sure which is better.)
Finished:
-
Yesterday I got it to create a new tetromino when the previous one lands, but now I also got the canvas to draw all the previous squares in addition to the new squares. And I fixed a little bug I had created, where they weren't moving all the way down the screen even though they had room. (Forgot to switch out one argument, oops! Tiny fix!)
-
The game ends now, yay! I added an
isOffScreen
method to the Tetris module. At first I had a couple little bugs, but I fixed them up! Now each new tetromino appears fully on the screen, and the game ends when any of its squares are in the top row. (Probably still needs some tweaking, though.) -
New bug discovered: my collision detection is off in a few important cases! For the "S" shape, only its bottom-most square checks for collisions, so it actually sinks into the squares below in certain situations, because one of its squares ignores collisions! My logic was oversimplified. Oops.
Finished:
-
Completely rewrote the collision detection logic in
hasRoomForNextMove
, which feels super complicated but at least it works now! I'm checking every square against every other square in the tetromino, then removing every square that has a neighbor (below, to the left, or to the right), and finally checking the adjacentgameGrid
locations for each of the remainingedgeSquares
. -
Along the way to rewriting that method, I created all sorts of new little bugs and made some silly mistakes related to array indexes and trying to modify an array in place -- bad, bad, very bad!
-
I discovered a new bug (or re-discovered it?): the game-over condition has false positives, if moving the tetromino left or right while it's still at the top of the screen. Gotta rewrite that one from scratch too, probably!
#TIL:
- I found a very cool little algorithm for filtering an array in place from this StackOverflow thread, where you keep track of two indexes to overwrite the elements to remove with elements to keep from further down the array, and then reset the array's length manually at the end to chop off the last half. I didn't even know you could do that! This thread on "index adjustment" and why it's clunky to change an array in place was interesting.
No coding. (Aside from spending all of 2 minutes starting to write notes in here, and then I stopped to play games the rest of the night because I felt pretty burnt out after my class.)
No coding. I just didn't feel like it. Taking a nap and finally eating, and then rock climbing, and then going out for food... and then I was too tired to even think about working on anything at night. I guess I have it in my head already that it's OK to take a break on weekends, especially since I taught a class both days this weekend (again), and it's pretty exhausting.
Finished:
-
Rewrote game-over condition: as soon as a new tetromino is created, check if it overlaps any squares on the game grid. Works like a charm!
-
Reimplemented clearing rows and shifting down squares, hurray!
-
Bug or feature? Sometimes a square will be left floating in mid-air, if there was a bigger gap below it. I forgot if the original game did this, and it looks like it does! But I still have this nagging feeling that my implementation is missing something... Oh well, good enough for now.
-
A little refactoring: now reusing
updateGameGrid
after clearing rows and updating the array of squares, instead of creating another method to accomplish the same thing. (NowupdateGameGrid
is called after moving the current tetromino and after clearing any rows.) -
Implemented rotation, woohoo!!! It was easier than I thought -- at least the first step. The last row becomes the first column, and the first row becomes the last column, for each clockwise rotation. Just a couple of nested for loops, incrementing one counter and decrementing the other. What a beautifully simple pattern! Very satisfying to figure out. But...
-
Bug to solve: I don't know why the tetromino moves down the screen when it's rotated. It looks like the coordinates of its squares don't match the game grid anymore. Not sure why. I'll also probably have to rewrite the collision detection yet again, because it seems downright impossible to use the current implementation to check for collisions caused by rotations. Eep!
Finished:
-
Worked it out on paper: I can indeed rotate the tetrominoes using only their coordinates, no need for rotating the shape and then updating their coordinates in two separate steps. Time to try implementing it in code now! I'm excited by just how simple this might be!
-
Implemented new rotation algorithm and added
centerSquare
property, marking the center in theshapes
templates with the number 2. New algorithm: calculate each square's offset from center square, switch row/column offset and negate one of them, apply new offset to generate new coordinates. So much simpler, no need to rotate the shape template itself! -
Rewrote collision detection to be so much simpler! Now instead of checking the edges of a tetromino, I just changed the
move
method into agetNewTetromino
method that returns a new tetromino object with the coordinates that result from the proposed move. Then I check if any of those squares overlap or go outside the game grid; if not, I set the current tetromino to that new version! -
Fixed a tricky bug: I forgot that
[...myArray]
only makes a shallow copy of an array, and I'm using nested arrays! So now, instead of making a copy of the game grid and switching off the tetromino's previous coordinates (which also switched them off in the actual grid, oops!), now it's checking for coordinates that overlap but excluding any from the previous coordinates.
Since I solved what I thought were the hard problems of Tetris, I'm basically done now! But I'm also nowhere close to done, because there are lots of little details I haven't implemented. I recognize this feeling from every project I've ever done -- the moment when I need to decide: how much is enough?
Today I discovered there's a lot of nuance to the Tetris rotation system, and it varies from game to game. The Tetris Guideline is the current specification, developed in 2001 to standardize all official Tetris games.
Now that I know this exists, my inner perfectionist wants to keep going until my version matches the official specs. But my original goal here was just to find out how hard it would be to build Tetris, and how long it would take me, and to learn whatever I could from the challenge. Turns out, as with most projects, it took me way longer than I thought it would!
OK, time to decide.
Final-ish list of features for my good-enough Tetris:
- Fix the rotation bug for the O shape -- it doesn't rotate at all!
- Track number of lines cleared (I don't care about the intricacies of the official scoring system)
- Use all 7 Tetris shapes
- Assign a fixed color to each Tetris shape
- Show a queue of upcoming shapes
Some misc notes and rabbit holes for another day:
-
This StrategyWiki overview of Tetris rotation systems is fascinating!
-
So now I know that wall kicks are a thing. I might want to try implementing the most simple variation, so the tetromino can be rotated when against the side walls, if there's room.
-
Apparently it's tetrominos, no "e"! That's according to The Tetris Company, supposedly. But Wikipedia and other sites write it as "tetrominoes", so I'll stick with that.
-
Officially, there actually is a non-visible portion of the grid ("playfield")! Good to know!
-
Each piece has a specific spawn rule: horizontal, with J/L and T pointing up, and in particular columns!
Finished:
- For the "O" shape, set its
centerSquare
property toundefined
and updated therotate
method to end early and return an uneditied copy of the tetromino in that case, so now the "O" shape literally does not rotate.
Finished:
-
Added score (just the number of lines cleared)
-
Refactoring: now
gameLoopTick
returns an object containing the squares to draw, whether the game is over, and the score. Now the interface module handles displaying "game over" and displays the score too. -
Added all 7 shapes, each with an assigned color. Refactored: Tetris module stores the shape templates and handles randomly generating shapes, passing them to the Tetromino constructor (instead of the Tetromino handling that).
-
Added queue of next tetrominoes! Now the canvas draws the next tetrominoes off to the side, and the playfield is now a subset of the canvas. The Tetris module now stores the queue of next tetrominoes, taking the next one and pushing another to the queue each time a new tetromino is dropped. (I'm not doing that "7 bag" algorithm that the official game uses, so the randomness of the shapes could use some tweaking, but whatever. This is good-enough Tetris.)
-
Added animation! Now the game animates itself! Fixed a couple little bugs: now the piece doesn't move down on every single game loop tick (and for now, game loop ticks are the same as animation frames). Another interesting bug was that at first, the pieces kept repeating their move on every frame until pressing a different button. The fix was simple: use the p5js
keyIsDown
method inside the draw loop to update (and reset) on every frame.
Questions and threads to follow later:
- What are the pros and cons of defining local variables within an object constructor, compared to setting them as properties of the object? (Using
let blah
versusthis.blah
) - Look into use cases for creating custom events
Finished:
-
Implemented counter-clockwise rotation, following the Tetris Guideline to map it to the "Z" or "Ctrl" keys.
-
Tetrominoes now spawn horizontally, and in the left/center columns, according to the official guideline.
-
Re-implemented the "soft drop" move: when the user presses the down arrow, the tetromino moves down immediately (potentially on every game loop tick if the key is held)
-
Added "hard drop" move: when user presses Space, immediately move the tetromino as far down as it has room for. My code's a bit messy in my attempt to keep it DRY... something to take another look at later! At first I had a little bug where the tetromino could still be moved after the hard drop, but I fixed it with one tiny change to the logic.
-
To prevent the page from scrolling when pressing the arrow keys, I just used
preventDefault()
with the "keydown" DOM event. For whatever reason, it didn't work with the p5jskeyPressed
method. Right now I'm using thewhich
property to get the key codes for the game controls and only disabling those.
No coding, totally fine with taking the weekend off.
No coding. Nice lazy Sunday.
Finished:
-
Fixed key-repeat issue. After a bit of a wild goose chase, I finally realized that the official Tetris game has a delay befor repeating a move! The solution ended up being pretty simple: register the initial key press, but then ignore it until reaching the threshhold (some number of milliseconds), and then repeat the move as long as the key is held down.
-
The soft-drop repeats immediately though, no delay. (That's what it seems to do in the official game.)
-
I spent a lot of time experimenting with it to try to get the right feel for the controls. I'm reasonably happy with it now!
Finished:
-
Implemented left/right plus soft-drop combined move, so now if you hold down both keys it'll move quickly down and to the side simultaneously. Feels much more intuitive this way!
-
Fixed a tiny edge case: previously, holding left and then also holding down right arrow key would cause the tetromino to still repeat moving to the left, because that was the order of the conditional statements. I'm sure it would rarely happen, but it definitely shouldn't work that way! One small change to fix it: track which key was the most recent one pressed, and only repeat left or right if it's held down and the most recently pressed key.
-
Published version 1 on GitHub here: https://github.com/LearningNerd/yay-tetris/! I included these notes in the README file for the project, along with the below list of next to-dos for whenever I decide to revisit the project.
Hello, again! After a nice long break spent on other projects and learning more cool stuff, I'm back to do some refactoring! This Tetris code really needs some love and attention.
Today I removed all p5js code, to review my understanding of the canvas API, to practice refactoring, and to submit this project for my application to the Recurse Center (which requires a "from scratch" code sample).
Finished:
-
Read all about cross-browser compatibility issues with keyboard events and looked through the p5js source code to see what details I'll now need to handle myself
-
Created a file for constants: names of keyboard events, names of game moves, and a mapping for normalized key names.
-
Looked at source code for p5js
millis()
method and found it's literally just a renaming ofwindow.performance.now()
, lol -
Reused some old code I had written to implement an animation/game loop "from scratch" for the classic Snake game: https://codepen.io/LearnTeachCode/pen/PWVZJZ?editors=0010
-
Relearned how to run a local web server with Python:
python -m SimpleHTTPServer
-
Solved my bug regarding key presses -- I just need to manually track which keys are being held down!
-
Learned about the "focus" and "blur" events and how they don't bubble up -- but "focusin" and "focusout" do bubble. And you can listen for these events on the
window
object, but not on thedocument
object! See: https://www.quirksmode.org/dom/events/blurfocus.html -
Learned about keyboard "ghosting" and "jamming" -- because of how some keyboards are wired, it won't even register a fourth key if you hold down some combinations of 3 keys! See: https://en.wikipedia.org/wiki/Rollover_(key)
- Although that still doesn't totally explain why my keyboard won't register three simultaneous arrow key presses... I'm assuming it's just a wiring issue. I want to test this on other keyboards now just for fun!
Notes on cross-browser keyboard event annoyingness:
- Modern browsers implement the "key" property, but IE/Edge use different values for some keys
- keyCode "is supported in effectively all browsers (since IE6+, Firefox 2+, Chrome 1+ etc)" via https://caniuse.com/#search=KeyboardEvent.keyCode
Resources:
- https://w3c.github.io/uievents/
- https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent
- https://medium.com/@uistephen/keyboardevent-key-for-cross-browser-key-press-check-61dbad0a067a
Done:
- Confirmed that I've successfully removed all p5js code without breaking the game, yay!
- Fixed up and experimented with canvas drawing styles; added some subtle drop shadows, made text bigger and a bit lighter in color
- Added ASCII emojis to show a random one for each "game over" screen, so cute!
- Updated the top of this README file and pushed these updates to GitHub and to Glitch
Done:
- Refactored interface module, much cleaner now! Moved stuff into separate functions, re-ordered some code into a coherent order.
- Moved "ticks until lock" to
config.js
and moved the definition of tetromino shapes toconstants.js
- Minor refactoring for Tetris module
- Reviewed how the game works, why I had basically 3 loops for animation/game loop
- Removed loop for throttle animation FPS; don't need it! Refactored to move game ticks per second loop into the
animate
function ininterface.js
, pulling it outside of theupdateGame
function.