You can build a falling-blocks game that follows every rule and still have players call it unfair within a minute. The pieces fall, rows clear, the score goes up, but something feels off. They get five S pieces in a row. The long bar refuses to turn next to the wall. A piece freezes the instant it touches down, before they can slide it into the gap.
None of those are bugs. They're missing systems, and each one is only a few lines. I added all three to Block Drop, a falling-blocks game in a single HTML file of plain JavaScript and Canvas. Here's what they are and the code for each.
Fix 1: a shuffled bag instead of pure randomness
The obvious way to pick the next piece:
const types = ["I", "O", "T", "S", "Z", "J", "L"]; const next = types[Math.floor(Math.random() * types.length)]; Each piece has a 1-in-7 chance every time, which sounds fair. But true randomness clumps. Players will see the same piece three or four times in a row, and long droughts with no I piece. Over a session someone always hits a 30-piece drought and blames the game.
The fix is to deal from a bag: put all seven pieces in, shuffle, hand them out one at a time, and refill when it's empty.
let bag = []; function takeFromBag() { if (!bag.length) { bag = Object.keys(SHAPES); // all seven pieces for (let i = bag.length - 1; i > 0; i--) { // Fisher-Yates shuffle const j = Math.floor(Math.random() * (i + 1)); [bag[i], bag[j]] = [bag[j], bag[i]]; } } return bag.pop(); } The order is still unpredictable, but every piece now shows up once per seven. The worst possible wait for any shape is 12 pieces: one at the very start of a bag, the next at the very end of the following one. Players stop noticing the randomiser at all, which is the goal.
Use a proper Fisher-Yates loop like the one above. The popular one-liner array.sort(() => Math.random() - 0.5) is biased: some orders come up noticeably more often than others.
Fix 2: wall kicks, so turning near a wall works
Rotation itself is easy if each piece is a small square grid of 1s and 0s. Turning it is just reading the grid in a different order:
function rotate(cells, dir) { const n = cells.length; return cells.map((row, y) => row.map((_, x) => dir > 0 ? cells[n - 1 - x][y] : cells[x][n - 1 - y])); } The problem shows up at the edges. Push the long I piece against the left wall and turn it: the rotated shape sticks out past the wall, the collision check says no, and the turn is refused. To the player it looks like the game ignored them.
A wall kick says: if the turned piece doesn't fit where it is, try nudging it a little before giving up.
function tryRotate(dir) { const turned = rotate(piece.cells, dir); const kicks = [[0, 0], [-1, 0], [1, 0], [0, -1], [-2, 0], [2, 0]]; for (const [kx, ky] of kicks) { if (!collides(turned, piece.x + kx, piece.y + ky)) { piece.cells = turned; piece.x += kx; piece.y += ky; return; } } // nothing fit: the turn is refused, which is now rare } The first entry, [0, 0], is "no nudge", so normal turns are unchanged. After that it tries one cell left, one right, one up, then two cells sideways for the long piece. The first position that fits wins.
This only works because there's a single collides(cells, x, y) function that answers "would this shape fit here?" for everything: moving, turning, gravity and the drop preview all ask it. With that in place, wall kicks are a loop over a list.
(Modern versions of the classic use a bigger kick table per piece and per rotation. The short list above covers the cases players actually hit, and it's easy to read.)
Fix 3: lock delay, so landing isn't instant death
The simplest landing rule: when the piece can't move down any more, freeze it into the board. It's correct, and it feels terrible. At higher speeds the piece touches down and locks in the same frame, and the player never gets to slide it under an overhang.
Lock delay gives a grace period. When the piece is resting on something, start a short timer instead of locking. Lock only when the timer runs out:
const LOCK_DELAY = 0.5; // seconds of grace after touching down const MAX_RESETS = 15; // per row reached, to stop infinite stalling function update(dt) { if (grounded()) { lockTimer += dt; if (lockTimer >= LOCK_DELAY) lock(); return; } // ...normal gravity... } // Called after every successful move or turn function moved() { if (grounded() && resets < MAX_RESETS) { lockTimer = 0; resets++; } } Every successful slide or turn while grounded restarts the half second, so the player can tuck a piece into place. The reset cap matters: without it, someone can spin a piece forever and never let the game advance. Block Drop allows 15 resets, and gives a fresh 15 only when the piece reaches a new lowest row, which is plenty for real play.
dt here is the time since the last frame in seconds, so the grace period is the same on a 60 Hz laptop and a 120 Hz phone. Counting frames instead would make the game twice as harsh on slower screens.
Bonus: the ghost piece is free
Once collides() exists, showing where the piece will land costs one small loop:
function dropDistance() { let d = 0; while (!collides(piece.cells, piece.x, piece.y + d + 1)) d++; return d; } Draw a faint copy of the piece dropDistance() rows down and players stop misjudging drops.
The full game
With a bag, wall kicks and lock delay, a falling-blocks game goes from technically working to feeling fair. Block Drop also has levels, line-clear scoring, held-key repeat and touch gestures, all in about 350 lines. You can play it and copy the MIT-licensed code from the falling-blocks game tutorial, which walks through the rest of the file.
And if you're making your own: give it its own name. "Tetris" is a trademark, which is why ours is called Block Drop.
Which of the three would you notice first if it were missing?



