DEV Community

SEN LLC
SEN LLC

Posted on

Tetris in Vanilla JS — SRS Rotation, 7-Bag Randomizer, and Why You Should Separate Game Logic from Rendering

"Tetris" sounds simple until you start building it properly. The official Tetris Guideline rules — SRS (Super Rotation System) wall kicks and the 7-bag randomizer — are surprisingly subtle and a naive implementation gets them wrong in ways players feel without being able to name. This post walks through a ~600-line vanilla JS implementation that does both correctly, with the pure board engine separated from the Canvas renderer and verified by 23 unit tests under node --test.

🌐 Demo: https://sen.ltd/portfolio/tetris/
📦 GitHub: https://github.com/sen-ltd/tetris

Screenshot

Separate game logic from rendering

First decision: board.js must not touch DOM or Canvas.

pieces.js   ← 7 tetrominoes, SRS wall-kick tables, 7-bag randomizer
board.js    ← Game logic: fits / move / rotate / hardDrop / clearLines / scoring
render.js   ← Canvas renderer (depends on pieces.js + board.js only)
app.js      ← UI glue: input DAS, game loop, HUD
Enter fullscreen mode Exit fullscreen mode

The dependency arrow goes one direction: app.js → render.js → board.js + pieces.js. With zero side-effects in the bottom two layers, Node's built-in test runner verifies every game rule without a browser:

test("clearLines clears 4 rows for a tetris", () => {
  const b = emptyBoard();
  for (let y = TOTAL_ROWS - 4; y < TOTAL_ROWS; y++) {
    for (let x = 0; x < COLS; x++) b[y][x] = 1;
  }
  assert.equal(clearLines(b), 4);
});
Enter fullscreen mode Exit fullscreen mode

No mocking requestAnimationFrame. No JSDOM. The tests run in ~70ms.

The 7-bag randomizer

The naive implementation is Math.random() * 7 to pick a piece. The result: the same piece appears 5 in a row routinely, and players quit because it feels unfair.

The Tetris Guideline rule is: take a "bag" of all 7 tetrominoes, shuffle, deal them one at a time, refill when empty. Each piece appears exactly once per bag of 7.

export function createBag(rng) {
  let bag = [];
  function refill() {
    bag = [...PIECE_NAMES]; // ["T", "J", "L", "S", "Z", "I", "O"]
    // Fisher–Yates shuffle
    for (let i = bag.length - 1; i > 0; i--) {
      const j = Math.floor(rng() * (i + 1));
      [bag[i], bag[j]] = [bag[j], bag[i]];
    }
  }
  return {
    next() {
      if (bag.length === 0) refill();
      return bag.shift();
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Properties this guarantees:

  • No long droughts — you can never go 30 pieces without seeing an I.
  • Maximum 2 in a row — possible only at a bag boundary (last of one bag + first of the next).
  • Never 3 in a row — this is what makes the game feel predictable to skilled players.

The test enforces it:

test("yields all 7 pieces before any repeat", () => {
  const bag = createBag(() => 0.5);
  const first7 = [];
  for (let i = 0; i < 7; i++) first7.push(bag.next());
  assert.deepEqual(first7.sort(), [...PIECE_NAMES].sort());
});
Enter fullscreen mode Exit fullscreen mode

SRS rotation with wall kicks

Naive rotation = "rotate the matrix 90°." The problem: you get stuck against walls. Real Tetris uses SRS: when a rotation would cause a collision, try shifting the piece a few cells and re-test, in a specific order.

Each piece has 5 kick offsets per rotation transition (0→1, 1→2, etc.), and the first non-colliding offset wins:

// Simplified SRS for JLSTZ pieces
const KICKS_JLSTZ = {
  "0->1": [[0,0], [-1,0], [-1,+1], [0,-2], [-1,-2]],
  "1->0": [[0,0], [+1,0], [+1,-1], [0,+2], [+1,+2]],
  "1->2": [[0,0], [+1,0], [+1,-1], [0,+2], [+1,+2]],
  "2->1": [[0,0], [-1,0], [-1,+1], [0,-2], [-1,-2]],
  // ...
};

export function rotate(board, state, dir) {
  const toRot = (state.rot + dir + 4) % 4;
  const kicks = getKicks(state.piece, state.rot, toRot);
  for (const [kx, ky] of kicks) {
    const next = { ...state, rot: toRot, x: state.x + kx, y: state.y + ky };
    if (fits(board, next)) return next;   // first non-colliding kick wins
  }
  return null; // every kick collided — no rotation possible
}
Enter fullscreen mode Exit fullscreen mode

Three subtleties:

  1. [0,0] is always first — if a plain rotation fits, do nothing else.
  2. The I-piece has its own table — its rotation axis differs from the 3×3 pieces, so its kicks aren't a simple variant of JLSTZ.
  3. CW and CCW have different tables — not just sign-flipped, genuinely different offsets.

Get this right and "T-spin" — wedging a T-piece into an L-shaped slot — becomes physically possible. It's not an exploit; it's what SRS was designed to enable.

Frame-rate-independent gravity

Naive: drop one row per requestAnimationFrame. Problem: monitor refresh rate determines difficulty. A 120Hz display becomes unplayable.

Use a ms-accumulator instead:

function tick(ms) {
  game.dropTimer += ms;
  const interval = gravityMs(game.level);  // level 0 = 800ms, level 19+ = 30ms
  while (game.dropTimer >= interval) {
    game.dropTimer -= interval;
    const next = move(game.board, game.current, 0, 1);
    if (next === null) {
      lockAndSpawn();
      break;
    }
    game.current = next;
  }
}
Enter fullscreen mode Exit fullscreen mode

The while loop is so that if a frame drops and ms > interval * 2, the piece drops multiple rows at once instead of seeming to teleport. There's also a clamp at the call site: dt = Math.min(now - lastFrame, 100), which prevents the runaway "I switched tabs for 5 seconds, game advanced 6 minutes" disaster.

gravityMs(level) is the NES gravity table:

export function gravityMs(level) {
  const table = [
    800, 720, 630, 550, 470, 380, 300, 220, 130, 100,
    80, 80, 80, 70, 70, 70, 50, 50, 50, 30,
  ];
  if (level >= table.length) return 30;
  return table[level];
}
Enter fullscreen mode Exit fullscreen mode

DAS (Delayed Auto-Shift) for keyboard repeat

keydown fires once, then at the OS's auto-repeat rate (usually 30Hz, often delayed by 500ms). That's not what Tetris players expect — they want a controllable initial pause, then a fast steady repeat.

The solution is to ignore OS auto-repeat and manage your own:

const DAS_DELAY = 170;     // delay before auto-repeat kicks in
const DAS_INTERVAL = 50;   // ms between repeats

function onKeyDown(e) {
  if (keysHeld[e.key]) return;  // ignore OS-driven repeats
  keysHeld[e.key] = true;
  applyAction(action);
  if (action === "left" || action === "right" || action === "down") {
    dasTimers[e.key] = setTimeout(() => startRepeat(e.key, action), DAS_DELAY);
  }
}

function startRepeat(key, action) {
  dasTimers[key] = setInterval(() => {
    if (!keysHeld[key]) { clearInterval(dasTimers[key]); return; }
    applyAction(action);
  }, DAS_INTERVAL);
}
Enter fullscreen mode Exit fullscreen mode

This gives you "tap left for one cell, hold left to slide to the wall." Hard drop (Space) and rotation (Z/X) deliberately skip DAS — repeating those keys causes accidents.

NES-style scoring shapes the strategy

export function lineScore(lines, level) {
  const base = { 0: 0, 1: 40, 2: 100, 3: 300, 4: 1200 }[lines] ?? 0;
  return base * (level + 1);
}
Enter fullscreen mode Exit fullscreen mode

A 4-line clear (a "Tetris") scores 30× a single-line clear — not 4×. That non-linearity is why skilled players deliberately leave a vertical column open while stacking flat, waiting for an I-piece. The 7-bag guarantees an I will arrive eventually; the scoring rewards the patience.

Hard drop adds +2 per cell and soft drop adds +1 per cell, rewarding active play (the longer you let gravity carry the piece, the less you score).

Tests as design specification

All 23 unit tests fit on one page and cover:

Category Tests
Board shape empty board structure
spawn / fits wall collision, occupied-cell collision
move normal movement, blocking
rotate (SRS) full T-piece rotation cycle, O-piece no-op, wall-kick at edge
hardDrop / lock floor landing, persistence
clearLines 1-line and 4-line clears, partial rows ignored
scoring NES table, level scaling
gravity speed table, level-19 plateau
7-bag no repeats within bag, multi-bag continuity
helpers 3×3 rotation, SHAPES table consistency

Because they're all pure-function tests, npm test runs them in 70ms. Adding T-spin detection, IRS (initial rotation system), or hold-piece functionality means writing tests first; the dependency direction makes regressions structurally hard.

Try it

Hold a column at x=9 open, stack flat, wait for the I-piece. The 7-bag will deliver it.

Takeaways

  • Separating game logic from rendering lets Node verify every rule of the game without spinning up a browser.
  • 7-bag randomizer is the design foundation that makes Tetris feel fair — naive RNG produces droughts that drive players away.
  • SRS rotation with wall kicks is a 5-attempt-per-transition algorithm. Implementing it correctly enables T-spins and the rest of the modern Tetris vocabulary.
  • ms-accumulator gravity + a frame-time clamp gives frame-rate independence and tab-switch resilience for free.
  • Custom DAS beats the OS auto-repeat for any keyboard-driven game; the right values are roughly 150–200ms initial delay, 30–60ms repeat interval.
  • Non-linear scoring is a design tool, not a balance number — the 30× Tetris bonus is what creates strategy.

This is OSS portfolio #243 from SEN LLC (Tokyo). We ship small, sharp tools continuously: https://sen.ltd/portfolio/

Top comments (0)