GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Tetris JavaScript: Build It in 9 Canvas Steps

Falling blocks, stacking, line clears — computed from rotation matrices and collision checks. A Tetris built in nine steps, one canvas, zero libraries.

Auto-plays · click a tile to jump to its section · all nine in one zip

Demo

Tetris in JavaScript is a falling-block puzzle where the entire game — the board, the seven pieces, the line clears — is computed from one 2D array, a collision function, and delta-time gravity, pasted into a single HTML file. Nine scenes, one pipeline: every move is tested by a four-condition collision check, shapes turn by the (x,y)→(y,−x) rotation, and gravity integrates cells-per-second against the frame delta. The piece positions you see only ease toward the computed grid values — the display never stands in for the computation. Item 06's ghost and hard drop paint the widest footprint of the set at a measured 27.695 percent cumulative area, with item 08's clear particles close behind at 26.844 percent, while item 02's rotation lab is the narrowest at 8.513 percent because it spins a single piece inside a 6×6 stage. Item 09's finished game is the brightest at 142.5 average intensity. Of 24 sampled frames, every item animated 23 frames except item 06 at 22 — a falling-block puzzle is supposed to move every single frame.

Each scene seeds its own mulberry32 generator once (seeds 3301 through 3309) and consumes it in sequence, so the autoplay you watch replays the exact same game on every visit — the piece order, the kick nudges, the particle bursts are all deterministic. Interact where the chips say so: left and right arrows spin the piece in item 02, the spacebar hard-drops in item 06, shift banks a piece in item 07, and a click on the board in item 01 slams the current piece to its precomputed landing row.

Why this order for the nine steps

The sequence follows what the game actually needs first. 01 is the board itself — one 10×20 array, redrawn cell by cell with fillRect every frame; nothing else can exist until this array and its collision check are solid. 02 isolates rotation onto a 6×6 lab stage, turning one piece 90 degrees at a time so the (x,y)→(y,−x) transform and the wall kick can be watched alone. 03 adds consequence: rows with zero empty cells flash for 0.18 s, get spliced out, and everything above collapses. 04 puts numbers on it — the 100×n² scoring curve and the 0.85-per-level drop-interval ramp. 05 looks forward with a seven-piece bag feeding a five-slot preview. 06 computes the landing row before you commit to it, drawing the ghost and honoring the spacebar. 07 adds the hold slot and the once-per-drop flag that stops infinite swapping. 08 makes the cleared cells burst into particles with integrated velocity and gravity instead of vanishing. 09 assembles everything into a finished game with three skins and a persisted high score. Each step adds exactly one mechanism to the previous one's board.

# Item Core technique Painted area Peak frame Avg intensity
01 Board grid 10×20 array, fillRect, choose() autoplay 13.931% 9.049% 125.9
02 Piece rotation (x,y)→(y,−x), kick flash 0.9 s 8.513% 4.178% 77.0
03 Line clear zero-empty scan, flash 0.18 s, splice 24.708% 21.085% 141.5
04 Scoring and level 100×n², ×0.85/level, floor 0.06 s 12.365% 3.817% 111.3
05 Next queue seeded Fisher–Yates bag of 7, preview 5 16.403% 4.888% 134.4
06 Ghost and hard drop landingRow walk, spacebar teleport 27.695% 11.875% 54.4
07 Hold swap canHold flag, 0.35 s→0.4 s attempt rhythm 15.657% 5.834% 103.4
08 Clear particles 6 particles/cell, 30 cells/s² gravity, 1.1 s life 26.844% 13.066% 99.8
09 Skins and high score 3 palettes from CSS variables, localStorage 12.078% 5.812% 142.5

The low numbers on items 02, 04, and 09 are a structural ceiling, not a flaw: item 02's stage is a 6×6 box, item 04 centers on numbers and a graph more than the board, and item 09's finished game holds brief static beats after each lock. At the other end, item 06 slams pieces to the bottom in one step and item 08 scatters particles across the whole frame — that is where the area lives in this set. Anyone who wants the tetris javascript code copy and paste ready will find all nine vanilla files plus nine React ports in the zip, and the fences below are the exact logic those ports share.

Code per step

Each fence comes straight out of that item's own source — the real constants, the real identifiers — with screen strings swapped through the same dictionary the shipped demos localize with and comments translated. Nothing is trimmed with an ellipsis; the zip carries the nine complete files.

01The board grid — drawing 10×20 cells from an array

One 2D array holds the entire board state, each cell is a fillRect, and the autoplay scores every placement by (completed lines − holes − height) before the piece follows the plan at 4.6 cells/s fall and 9.5 cells/s slide.

The board grid — drawing 10×20 cells from an array
Open in a new tab

01 · The board grid — drawing 10×20 cells from an array — Measured area 13.931% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fillRectrequestAnimationFrame
function hits(grid, cells, row, col) {
  for (var i = 0; i < cells.length; i++) {
    var x = col + cells[i][0], y = row + cells[i][1];
    if (x < 0 || x >= COLS || y >= ROWS) return true;
    if (y >= 0 && grid[y][x]) return true;
  }
  return false;
}
function landingRow(grid, cells, col) {
  var row = -4;
  while (!hits(grid, cells, row + 1, col)) row += 1;
  return row;
}
function emptyGrid() {
  var g = [];
  for (var y = 0; y < ROWS; y++) { g.push([]); for (var x = 0; x < COLS; x++) g[y].push(0); }
  return g;
}

Why one 2D array is the whole board

The four conditions inside hits — x<0, x>=COLS, y>=ROWS, cell occupied — are the entire verdict for every move, rotation, and drop in this set, and landing is decided by testing the next row and clamping y to the row just above it.

function step(now) {
  var dt = Math.min(64, now - last) / 1000; last = now;
  if (flashT > 0) { flashT -= dt; if (flashT <= 0) spliceFull(); }
  if (piece) {
    var plan = piece.plan;
    var dx = plan.c - piece.x;
    piece.x += Math.max(-piece.slide * dt, Math.min(piece.slide * dt, dx));
    if (piece.r !== plan.r && Math.abs(plan.c - piece.x) < 0.6) tryRotate();
    piece.y += piece.drop * dt;
    var rx = Math.round(piece.x), ry = Math.floor(piece.y) + 1;
    if (hits(grid, piece.cells, ry, rx)) {
      piece.y = ry - 1;
      restT += dt;
      var aligned = piece.r === plan.r && rx === plan.c;
      if (aligned || restT > 1.4) { piece.x = plan.c; lock(); }
    } else restT = 0;
  }
  draw(now);

Gravity is integration, not a timer

The fall is a constant drop * dt added every frame with the delta clamped to a 64 ms ceiling — never setInterval, never frame counting — and the next-row failure clamps y to the row above.

02Seven pieces and rotation — shapes that turn by matrix multiply

Each piece is a coordinate array; applying (x,y)→(y,−x) inside its n×n box turns it 90 degrees at a time, and when the rotated result leaves the 6×6 stage, a kick pushes it back and flashes for 0.9 s — left and right arrow keys spin it directly.

Seven pieces and rotation — shapes that turn by matrix multiply
Open in a new tab

02 · Seven pieces and rotation — shapes that turn by matrix multiply — Measured area 8.513% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

keydownfillRect
// rotation — (x,y) → (y,−x). adding n−1 to y keeps the piece inside its n×n box.
// this formula and a center-based 90-degree rotation matrix give the same result
function rotCW(cells, n) {
  var out = [];
  for (var i = 0; i < cells.length; i++) out.push([cells[i][1], n - 1 - cells[i][0]]);
  return out;
}
function startTurn() {
  from = cells.map(function (c) { return c.slice(); });
  phase = 'turn'; phaseT = 0; theta = 0;
}

The turn is a hold 0.18 s / interpolate 0.55 s phase

The drawn angle eases over 0.55 s with a cubic ease-out, but the verdict is always the rotated grid coordinates taken at the end of the phase — the interpolation is presentation, never state.

  var kick = 0;
  if (minX < 0) kick = -minX;
  if (maxX > BOXN - 1) kick = BOXN - 1 - maxX;
  if (kick !== 0) { offX += kick; kickFlash = 0.9; kickCount += 1; kickEl.textContent = 'Wall kick 1 cell'; }
  cells = rotated;
  theta = 0;
  spin += 1;
  degEl.textContent = DEGS[spin % 4];
  countEl.textContent = String(spin);
  phase = 'hold'; phaseT = 0;

A kick is a measured push, not a guess

The rotated shape's minX/maxX is measured against the box bounds, and the kick is exactly the number of cells that puts it back inside — one cell here, counted and announced on screen each time it happens.

03Line clear — finding full rows, erasing, collapsing

Each lock scans row by row, picks only rows with zero empty cells, flashes them for 0.18 s, splices them out, and drops the cells above over 0.12 s — the same check drives a single clear and a chained collapse.

Line clear — finding full rows, erasing, collapsing
Open in a new tab

03 · Line clear — finding full rows, erasing, collapsing — Measured area 24.708% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fillRectrequestAnimationFrame
      // row scan — only rows with zero empty cells
      flashRows = [];
      for (var y = 0; y < ROWS; y++) {
        var n = 0;
        for (var x = 0; x < COLS; x++) if (grid[y][x]) n += 1;
        if (n === COLS) flashRows.push(y);
      }
      if (flashRows.length) { flashT = FLASH_S; }
    }
    piece = null;
  }
  function spliceRows() {
    flashRows.sort(function (a, b) { return a - b; });
    cleared += flashRows.length;
    countEl.textContent = String(cleared);

Zero empty cells, or the row does not clear

A row that is merely mostly full must never clear — one gap means the collapse above it would drop into the wrong places, so the count has to equal COLS exactly.

    for (var i = 0; i < flashRows.length; i++) {
      grid.splice(flashRows[i], 1);
      grid.unshift(new Array(COLS).fill(0));
    }
    flashRows = [];
    dropY = 1; // collapse visual — cells above slide down one row while it runs
  }

splice plus unshift is the collapse

Each cleared row is spliced out and a fresh empty row is unshifted on top, which is the whole gravity of the stack — the 0.12 s drop animation only eases the visual into that new array state.

04Score and level — rewarding simultaneous clears exponentially

Clearing n rows at once scores 100×n² (1, 2, 3, 4 lines = 100, 400, 900, 1600), every 4 cumulative lines raises the level, and the drop interval starts at 0.50 s, multiplies by 0.85 per level, and floors at 0.06 s.

Score and level — rewarding simultaneous clears exponentially
Open in a new tab

04 · Score and level — rewarding simultaneous clears exponentially — Measured area 12.365% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFramelocalStorage
      if (flashRows.length) {
        // exponential reward — score = 100 × n × n (1·2·3·4×)
        var n = flashRows.length;
        var add = 100 * n * n;
        score += add; lines += n;
        scoreEl.textContent = String(score);
        pops.push({ txt: n + 'x bonus' + add, t: 1.0 });
        history.push(n);
        if (history.length > 26) history.shift();

The square is why four lines feel different from two

Doubling the rows quadruples the points, which is the entire incentive structure of simultaneous clears — a linear curve would make stacking two separate singles always optimal.

function dropIv() { return Math.max(0.06, BASE_IV * Math.pow(FACTOR, level - 1)); }
function fmtIv() { return String(Math.round(dropIv() * 1000)); }
      var target = landingRow(grid, piece.cells, Math.round(piece.x));
      if (Math.abs(dx) < 0.05 && piece.r === piece.plan.r) {
        // constant-speed descent — inherit 42 cells/s down to the landing row, then lock exactly there
        var ny = piece.y + 42 * dt;
        piece.y = ny >= target ? target : ny;
        if (piece.y === target) lock();
      } else {
        piece.y += 5 * dt;

Leveling up is a shorter interval, felt as speed

Once the demo piece is aligned it descends at a constant 42 cells/s and clamps at the landing row — level is never a number on screen alone, it is the interval shrinking under your fingers.

05Next queue — previewing upcoming pieces on a mini canvas

The seven piece types shuffle as one set by a seeded Fisher–Yates into a bag, the next five show on mini canvases sharing the board's fillRect drawing code, and when the bag empties it reshuffles.

Next queue — previewing upcoming pieces on a mini canvas
Open in a new tab

05 · Next queue — previewing upcoming pieces on a mini canvas — Measured area 16.403% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fillRect
  var bag = [], bagUsed = 0, bagCount = 0;
  function refill() {
    bag = TYPES.slice();
    for (var i = bag.length - 1; i > 0; i--) {
      var j = Math.floor(rnd() * (i + 1)), tmp = bag[i]; bag[i] = bag[j]; bag[j] = tmp;
    }
    bagUsed = 0; bagCount += 1;
  }
  refill();
  function nextType() { if (!bag.length) refill(); bagUsed += 1; return bag.pop(); }

A bag stays random without repeating

Drawing from pure randomness lets the same piece repeat back-to-back and feel broken; shuffling all seven as one set guarantees each type appears once per cycle while the order inside stays unpredictable.

  function drawMini(slot, t, alpha) {
    var c = slot.ctx, w = slot.w, h = slot.h;
    c.clearRect(0, 0, w, h);
    if (!t) return;
    var cells = SHAPES[t];
    // center the piece inside the slot
    var minX = 9, maxX = -9, minY = 9, maxY = -9;
    for (var i = 0; i < cells.length; i++) {
      minX = Math.min(minX, cells[i][0]); maxX = Math.max(maxX, cells[i][0]);
      minY = Math.min(minY, cells[i][1]); maxY = Math.max(maxY, cells[i][1]);
    }

One drawing routine for board and preview

The preview measures the piece's own bounds and centers it in the slot, reusing the same cell painting as the main board so the queue and the board can never disagree about a shape.

06Ghost piece and hard drop — precomputing the landing spot

Dropping the current piece all the way down until it first collides yields the landing row — drawn translucently first, then the spacebar slams it there in one step and locks, with the demo observing each piece for 0.20 s before auto-dropping.

Ghost piece and hard drop — precomputing the landing spot
Open in a new tab

06 · Ghost piece and hard drop — precomputing the landing spot — Measured area 27.695% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

keydownrequestAnimationFrame
  function hardDrop() {
    // snap to the plan and teleport to the landing row in one step — the beam lights the traveled column for 0.4 s
    piece.x = piece.plan.c;
    piece.cells = shapeRot(piece.t, piece.plan.r);
    piece.r = piece.plan.r;
    var target = landingRow(grid, piece.cells, Math.round(piece.x));
    beamT = 0.4;
    piece.y = target;
    lock();
    drops += 1;
    countEl.textContent = String(drops);
  }

A hard drop is a teleport, not a fast fall

The landing row is computed by repeating the collision check downward, and the drop places the piece there in one assignment — animating it as a multi-frame fall would break the promise the ghost just made.

  function ghostCell(gx, gy, t) {
    var r = Math.max(2, cell * 0.22);
    ctx.beginPath();
    ctx.roundRect(ox + gx * cell + 2.5, oy + gy * cell + 2.5, cell - 5, cell - 5, r);
    ctx.setLineDash([4, 3]);
    ctx.lineWidth = 2;
    ctx.strokeStyle = GHOST;
    ctx.stroke();
    ctx.setLineDash([]);
  }

The ghost is recomputed every frame

Because landingRow runs each frame against the live board, the ghost moves the instant the stack changes underneath it — it is a prediction rendered from the same collision check, not a cached outline.

07Hold — banking one piece and swapping it in

Shift banks the current piece into the hold slot and swaps in the queued one, a canHold flag that passes only once per drop blocks infinite re-swapping, and the demo tries a hold every 0.35 s then every 0.4 s with rejections mixing in as yellow marks from the second attempt.

Hold — banking one piece and swapping it in
Open in a new tab

07 · Hold — banking one piece and swapping it in — Measured area 15.657% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

keydownfillRect
    if (!piece) return;
    if (!canHold) {
      // rejected — rule-violation mark
      marks.push({ x: Math.round(piece.x), y: Math.max(0, Math.floor(piece.y)), ok: false, t: 1.0 });
      return;
    }
    var cur = piece.t;
    if (held === null) {
      held = cur;
      piece = null; // the next queued piece takes the field
    } else {
      var swapIn = held;
      held = cur;
      var plan = choose(grid, swapIn);
      if (!plan) { swapIn = cur; held = cur; plan = piece.plan; }
      piece = { t: swapIn, r: 0, cells: SHAPES[swapIn].slice(), x: 2, y: -0.4, plan: plan, usedHold: true };
    }
    canHold = piece && piece.usedHold ? false : true;

The once-per-drop flag is state math

usedHold travels with the piece, and the flag recomputes from it after every swap — unlimited swapping would erase the tension the slot exists to create, so the rule lives in the state, not in the key handler.

      if (aligned && holdTryN < 2 && holdTryT <= 0) {
        tryHold();       // first attempt succeeds, second is rejected (the piece has not been dropped)
        holdTryN += 1;
        holdTryT = 0.4;
      } else {
        // floating — once both attempts are spent, drop immediately so the rhythm never stalls
        var ny = piece.y + (holdTryN >= 2 ? 26 : 4) * dt;
        piece.y = ny >= target ? target : ny;
        if (piece.y === target) lock();
      }

The fall speed changes after the holds

While attempts remain, the piece floats down at 4 cells/s; after both are spent it commits at 26 cells/s so the demo keeps its rhythm — the same clamp-to-target integration as every other item.

08Clear particles — erased cells bursting apart

Each erased cell becomes six small square particles with velocities (vx ±4.5, vy −3 to −10 cells/s) and gravity of 30 cells/s² integrated every frame; lifetime is 1.1 s jittered ×0.6–1.0, and scattered trails linger in the sky panel.

Clear particles — erased cells bursting apart
Open in a new tab

08 · Clear particles — erased cells bursting apart — Measured area 26.844% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFramefillRect
  var PER_CELL = 6;        // particles per cell
  var GRAV = 30;           // gravity (cells/s²)
  var LIFE = 1.1;          // particle lifetime (s)
    // particle integration — v += g·dt, p += v·dt
    for (var p = particles.length - 1; p >= 0; p--) {
      var pt = particles[p];
      pt.vy += GRAV * dt;
      pt.x += pt.vx * dt;
      pt.y += pt.vy * dt;
      pt.t -= dt;
      if (pt.t <= 0 || pt.y > ROWS + 1) { particles.splice(p, 1); continue; }
      // trail record (sky panel coordinates)
      if (Math.random() < 0.25) trails.push({ x: Math.min(1, Math.max(0, pt.x / COLS)), y: Math.min(1, Math.max(0, pt.y / ROWS)), a: 1, c: pt.c });
    }

The line does not vanish, it bursts

Velocity and gravity are integrated against the same clamped dt as the falling pieces, so a burst at 30 fps and one at 144 fps land in the same place — a library is not doing this, arithmetic is.

09Skins and high score — your Tetris in a pastel palette

Each of the seven pieces gets a palette color read from CSS variables, three skins (pastel, night, mono) apply instantly, the high score persists across visits under localStorage keys p330-09-best and p330-09-skin, and an autoplaying choose() keeps the board playing on its own — click the board at game over to restart.

Skins and high score — your Tetris in a pastel palette
Open in a new tab

09 · Skins and high score — your Tetris in a pastel palette — Measured area 12.078% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fillRectlocalStorage
  var BEST_KEY = 'p330-09-best';
  var SKIN_KEY = 'p330-09-skin';
  var score = 0, best = 0;
  try {
    best = parseInt(localStorage.getItem(BEST_KEY) || '0', 10) || 0;
    var sv = parseInt(localStorage.getItem(SKIN_KEY) || '0', 10) || 0;
    if (sv >= 0 && sv < SKINS.length) skinIdx = sv;
  } catch (e) { /* storage failure is ignored */ }

Persistence wrapped in try/catch

Storage can be unavailable in private windows, so both reads sit inside try/catch and the game falls back to defaults instead of dying at boot.

    b.textContent = SKIN_NAMES[SKINS[sb]];
    b.setAttribute('aria-pressed', sb === skinIdx ? 'true' : 'false');
    (function (idx) {
      b.addEventListener('click', function () {
        skinIdx = idx;
        for (var k = 0; k < skinBtns.length; k++) skinBtns[k].setAttribute('aria-pressed', k === idx ? 'true' : 'false');
        applySkin();
        saveSkin();
      });
    })(sb);

A skin swap recomputes colors, not the board

The buttons toggle aria-pressed and re-read the palette from CSS variables, so the board state never rebuilds — the same grid is painted with different tokens the moment you click.

Outside a tutorial page, here is where these nine scenes land

Pulled apart, 01's board grid and its hits check alone are enough for a 40-minute live-coding session — you don't need the other eight items to show that a two-dimensional array plus one collision test is the whole game. A portfolio site's project page usually only embeds 09, the finished build with three skins and a saved high score, dropped into an iframe so a visitor can actually press play.

Game jams and hackathons tend to lift 07's hold slot wholesale — the canHold flag and the kick logic behind it are already verified, so teams start from that instead of re-deriving collision math under a deadline, and spend the saved hours on their own piece shapes or scoring twist.

The wall the rotation breaks if you skip the kick check

These are the failure modes the set guards against, each one a way the grid quietly stops being true. Moving pieces by coordinate addition without the collision check is the first — frames tunnel through walls, the floor, and the stack, and the four-condition test is the entire verdict. Rotating with only a CSS transform is the second: the screen angle turns while the grid coordinates never move, so the piece you see and the cells you occupy are different objects. Timing gravity with setInterval or frame counting is the third — speed drifts with the frame rate, which is why every drop here integrates cells-per-second against the clamped delta. Animating the hard drop as a multi-frame fall is the fourth: the ghost promised a row, and the drop must land on it in one step. Clearing rows that are merely mostly full is the fifth — one gap and everything above collapses wrongly, which is why the scan demands zero empty cells. Reseeding the generator per frame is the sixth: the bag shuffle spikes and the replay guarantee dies. Drawing the next piece from raw randomness instead of a bag is the seventh, and leaving the hold unrestricted is the eighth. Every one of these was caught the same way: run the seeded replay, watch where the grid and the pixels disagree, and fix the computation rather than the drawing. The full tetris javascript code — nine vanilla scenes plus nine React ports — sits in the password zip behind this post; it opens with e64adxes and every fence above appears there in its complete file.

Shrinking the cell size is also where accessibility catches up

Variant Change What it changes
Cell size 01 cell 24 px → 16 px smaller cells push the board further away, and the game shifts from hands to eyes
Kick allowance 02 kick 1 cell → 2 cells rotations near walls get more forgiving, but pieces also wedge into stacked corners more often
Gravity ramp 04 per-level interval ×0.85 → ×0.7 the steeper ramp makes planning give way to reflexes around level 10

Measured across the set, item 09's finished game runs the brightest board at 142.5 average intensity with item 03's line clear close at 141.5, while item 06's translucent ghost reads darkest at 54.4 — the prediction layer is deliberately quiet so the falling piece carries the contrast.

For prefers-reduced-motion: reduce, every item freezes a representative state instead of collapsing to an empty board — item 01 pre-locks six pieces, item 03 holds a board one cell from a clear, item 04 sets a visible score, level, and interval, item 06 poses a piece above its ghost — because a reduced-motion request means turning off the movement, not erasing the information. Each widget carries a human-readable aria-label (or role="img"), the items with key controls listen on keydown and prevent the default action of arrows, space, and shift, and one line under each demo names the real web API it leans on — requestAnimationFrame, localStorage, keydown — so the fence you copy is also the API you go read about.

Why blocks never clip through walls — rotation and collision math

Nearby posts on this site carry the same computed-first discipline into other canvases: a falling sand simulation where every grain's next cell is decided by a rules pass over the grid, and a collection of browser mini-games whose timers and scores are all derived from measured deltas rather than animation frames.

The one idea under all nine steps: positions are computed, and pixels only report them. Reference specs for the real APIs: CanvasRenderingContext2D.fillRect · Window.requestAnimationFrame · Element: keydown event · Web Storage API. Movement, rotation, gravity, ghost, and hard drop all route through the single hits check — four conditions tested per filled cell — so no frame can tunnel through a wall or the stack. Rotation applies (x,y)→(y,−x) to the coordinate array and nudges through the KICKS order [0,−1,1,−2,2] on contact, which preserves the wrist-feel players expect without needing the full SRS table; the drawn angle may interpolate, but the verdict is always the rotated grid coordinates. Gravity adds speed × dt with the delta clamped to 64 ms and clamps y to the row above the first colliding next row. Skip any of that — move a piece by adding coordinates unchecked, or spin it with a CSS transform while the grid coordinates stand still — and the game is a picture of a game. That is also why every seeded value comes from a mulberry32 generator built once and consumed in sequence: what you verify is what a reader gets, on every replay.

Questions that come up once you start building this tetris javascript set

Is this tetris javascript tutorial set runnable by copy and paste?

Yes — each of the nine scenes is a single self-contained index.html with no library, no framework, and no CDN. The fences above are the exact functions from those files, so pasting any one file into a browser tab runs that step, and the password zip carries all nine plus nine React ports built on the same collision, rotation, and gravity logic.

Why does the piece never clip through the wall or the stack?

Because every proposed position passes through the one hits check before it is accepted: each filled cell of the piece is tested against four conditions — x<0, x>=COLS, y>=ROWS, and board[y][x] occupied — and any single trip rejects the position. Movement, rotation, the ghost walk, and the hard drop all call the same function, so there is no code path that can move a piece without being verified.

How does the seeded replay keep every visit identical?

A mulberry32 generator is built once per scene from a fixed seed (3301 through 3309) and consumed strictly in sequence — the bag shuffle, the garbage rows, and the particle jitter all draw from that one stream. Reseeding per frame or using Math.random for the bag would break the guarantee; here the autoplay you verify is the autoplay every reader sees.

Enter the archive password

The password is inside this article. You will find it as you read.