Skip to content
GameGenresEspañol
Puzzle

Falling-block puzzle

Pieces fall from the top and you shift and rotate them to fit them together below; completing a line or a group clears it. The challenge is sorting quickly without leaving gaps as the pieces fall faster.

Also known as: Tetris-like, falling blocks, falling block puzzle game

Defining mechanics

  • Pieces fall on their own into a well of fixed size, and while they drop you can move them sideways and rotate them.
  • When a piece touches the bottom or the stack it locks in place and the next one appears; seeing the upcoming piece lets you plan.
  • Complete rows disappear and everything above moves down. In the branch of Columns, Dr. Mario and Puyo Puyo, what disappears are same-coloured groups.
  • The fall speeds up with each level, and you lose when the stack reaches the top.
  • In head-to-head play, what you clear reaches your opponent as rows or blocks of «garbage».

Gameplay loop

Look at the current piece and the next one, decide where it fits, move and rotate it, drop it, clear lines and repeat with slightly less time.

Our demo

Controls. Left and right arrows (or A/D) to move, up arrow (or W/X) to rotate, down arrow to drop faster and space to drop the piece at once. On mobile, the on-screen buttons.

Open full screen

A 10 × 20 well where four-block pieces fall. You shift and rotate them to complete rows, which disappear; every five rows the level goes up and the fall speeds up, until the pieces reach the top.

Made with Kaplay, in about 191 lines of JavaScript.

How it's made

  1. Setup and controls

    Virtual buttons are defined (left, right, down, rotate, drop) that the rest of the code asks about, so the game works the same with a keyboard or the touch buttons. The constants set the size of the well (10 columns by 20 rows, the genre's standard) and how soon movement repeats when you hold an arrow.

    const k = kaplay({
      width: 640,
      height: 360,
      letterbox: true, pixelDensity,
      background: [24, 26, 44],
      // Virtual buttons: the game asks about "rotate", not about a specific key.
      buttons: {
        left: { keyboard: ["left", "a"] },
        right: { keyboard: ["right", "d"] },
        down: { keyboard: ["down", "s"] },
        rotate: { keyboard: ["up", "w", "x"] },
        drop: { keyboard: ["space"] },
        start: { keyboard: ["space", "enter"] },
      },
    });
    touchControls(k, [
      { button: "left", label: "◀" },
      { button: "down", label: "▼" },
      { button: "right", label: "▶" },
      { button: "rotate", label: "⟳", side: "right" },
      { button: "drop", label: "⤓", side: "right" },
    ]);
    
    const COLS = 10;
    const ROWS = 20;
    const CELL = 16;
    const BOARD_X = 240; // top-left corner of the well, in pixels
    const BOARD_Y = 20;
    const LINES_PER_LEVEL = 5; // real games usually use 10
    const REPEAT_DELAY = 0.17; // holding left/right: wait this long, then repeat…
    const REPEAT_RATE = 0.05; // …one step every this many seconds
  2. Pieces as matrices

    Each piece is a small matrix of ones and zeros: a 1 marks a block. Rotating it 90° means rearranging the matrix: column x, read from bottom to top, becomes row x. So a single function rotates all seven shapes, with no need to draw each orientation by hand. Pieces come out of a «bag» holding the seven shapes shuffled, so none takes too long to come back.

    // Each piece is a small matrix: 1 = block, 0 = empty. Seven shapes of four blocks.
    const SHAPES = {
      I: [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
      O: [[1, 1], [1, 1]],
      T: [[0, 1, 0], [1, 1, 1], [0, 0, 0]],
      S: [[0, 1, 1], [1, 1, 0], [0, 0, 0]],
      Z: [[1, 1, 0], [0, 1, 1], [0, 0, 0]],
      J: [[1, 0, 0], [1, 1, 1], [0, 0, 0]],
      L: [[0, 0, 1], [1, 1, 1], [0, 0, 0]],
    };
    const COLORS = {
      I: [80, 210, 230], O: [240, 210, 70], T: [170, 100, 230], S: [100, 210, 110],
      Z: [230, 90, 90], J: [80, 120, 230], L: [240, 150, 60],
    };
    
    // Rotating clockwise: column x, read from bottom to top, becomes row x.
    const rotate = (m) => m[0].map((_, x) => m.map((row) => row[x]).reverse());
    
    // "Bag" randomiser: shuffle the seven shapes and deal them one by one,
    // so you never wait too long for the piece you need.
    let bag = [];
    function nextKind() {
      if (bag.length === 0) bag = shuffle(Object.keys(SHAPES));
      return bag.pop();
    }
    
    // A piece is its shape matrix plus a position on the board (in cells).
    function spawn(kind) {
      const matrix = SHAPES[kind];
      return { kind, matrix, x: Math.floor((COLS - matrix[0].length) / 2), y: kind === "I" ? -1 : 0 };
    }
  3. The well and collisions

    The well is another matrix, 20 rows by 10 columns: each cell is empty or holds the colour of a block that has already settled. collides() answers the genre's central question: does this matrix fit in this position? It goes through the piece's blocks and checks that none sticks out at the sides or the bottom or lands on a block in the well. Rows above the top edge are allowed, because that is where pieces appear.

    // The well is a ROWS × COLS matrix: null = empty, otherwise the colour of a settled block.
    const emptyRow = () => Array(COLS).fill(null);
    
    // The key question of the genre: would this matrix fit at (x, y)?
    // It collides if any of its blocks is past a wall or the floor, or over a settled block.
    function collides(board, matrix, x, y) {
      return matrix.some((row, r) =>
        row.some((cell, c) => {
          if (!cell) return false;
          const [bx, by] = [x + c, y + r];
          return bx < 0 || bx >= COLS || by >= ROWS || (by >= 0 && board[by][bx] !== null);
        }),
      );
    }
  4. Moving and rotating

    Every move follows the same rule, which is to try the new position and accept it only if it does not collide. When rotating, if the turned piece does not fit, the code tries nudging it one or two cells sideways, a minimal version of the "wall kicks" in real games. Holding an arrow moves once and, after a short pause, repeats the step quickly.

    // Every move is "try, and only commit if the new place is free".
    function tryMove(dx, dy) {
      if (collides(board, piece.matrix, piece.x + dx, piece.y + dy)) return false;
      piece.x += dx;
      piece.y += dy;
      return true;
    }
    // Rotation with simple "wall kicks": if the turned piece does not fit, nudge it sideways.
    function tryRotate() {
      const turned = rotate(piece.matrix);
      for (const kick of [0, -1, 1, -2, 2]) {
        if (!collides(board, turned, piece.x + kick, piece.y)) {
          piece.matrix = turned;
          piece.x += kick;
          return;
        }
      }
    }
    onButtonPress("rotate", tryRotate);
    // Left/right: one step on press, then auto-repeat while the button is held.
    let held = 0;
    for (const [button, dx] of [["left", -1], ["right", 1]]) {
      onButtonPress(button, () => { held = 0; tryMove(dx, 0); });
      onButtonDown(button, () => {
        held += dt();
        if (held > REPEAT_DELAY) {
          tryMove(dx, 0);
          held -= REPEAT_RATE;
        }
      });
    }
  5. Gravity and dropping

    A timer moves the piece down one row at intervals that get shorter with each level. That acceleration is the genre's pressure, because the time to think shrinks. Holding down speeds up the fall (soft drop) and space drops it to the bottom (hard drop); both give a few extra points. The faint «ghost» shows where the piece would land: it is found by moving it down in imagination until it collides.

    // The piece falls one row every fallInterval() seconds, faster at each level.
    const fallInterval = () => Math.max(0.06, 0.8 * Math.pow(0.8, level - 1));
    onUpdate(() => {
      // Soft drop: holding down makes it fall much faster (one point per row).
      const soft = isButtonDown("down");
      fallTimer += dt();
      if (fallTimer < (soft ? Math.min(0.04, fallInterval()) : fallInterval())) return;
      fallTimer = 0;
      if (tryMove(0, 1)) score += soft ? 1 : 0;
      else lockPiece(); // it cannot fall any further: it settles
    });
    // Hard drop: straight to the bottom and lock at once (two points per row).
    onButtonPress("drop", () => {
      while (tryMove(0, 1)) score += 2;
      lockPiece();
    });
    // Where the piece would land: drawn as a faint "ghost" to help you aim.
    function landingY() {
      let y = piece.y;
      while (!collides(board, piece.matrix, piece.x, y + 1)) y++;
      return y;
    }
  6. Locking the piece and clearing lines

    When the piece can no longer move down, its blocks are copied into the well's matrix. Then the code looks for full rows, with no empty cell; each one is removed and an empty row is added on top, so everything above moves down. The scan goes from bottom to top and repeats the index after a clear, because the row above has just taken that place. Clearing several rows at once pays far more, which tempts you to take a risk and leave a gap for the long piece. If the new piece does not fit where it appears, the well is full and the game is over.

    const LINE_POINTS = [0, 100, 300, 500, 800]; // clearing several lines at once pays more
    function lockPiece() {
      // 1) Copy the piece's blocks into the board matrix.
      piece.matrix.forEach((row, r) => row.forEach((cell, c) => {
        if (cell && piece.y + r >= 0) board[piece.y + r][piece.x + c] = COLORS[piece.kind];
      }));
      // 2) A row is full when none of its cells is empty: remove it and add an empty one on top.
      let cleared = 0;
      for (let r = ROWS - 1; r >= 0; r--) {
        if (board[r].every((cell) => cell !== null)) {
          board.splice(r, 1);
          board.unshift(emptyRow());
          cleared++;
          r++; // look at this index again: the row above has just moved down into it
        }
      }
      lines += cleared;
      score += LINE_POINTS[cleared] * level;
      level = 1 + Math.floor(lines / LINES_PER_LEVEL);
      // 3) Next piece. If it does not fit where it appears, the well is full: game over.
      piece = spawn(next);
      next = nextKind();
      fallTimer = 0;
      if (collides(board, piece.matrix, piece.x, piece.y)) go("end", score, lines);
    }
  7. Drawing from the matrices

    There is no object per block. Every frame, onDraw goes through the matrices (the well, the piece, its ghost and the next piece) and paints a square for every filled cell. The game state lives in the data and the drawing only mirrors it, which keeps the code short and easy to check.

    const drawCell = (x, y, c, opacity = 1) =>
      drawRect({ pos: vec2(x, y), width: CELL - 1, height: CELL - 1, color: rgb(...c), opacity });
    // Draws a shape matrix with its top-left corner at pixel (px, py); rows above the well stay hidden.
    const drawMatrix = (matrix, px, py, c, opacity = 1) =>
      matrix.forEach((row, r) =>
        row.forEach((cell, col) => cell && py + r * CELL >= BOARD_Y && drawCell(px + col * CELL, py + r * CELL, c, opacity)),
      );
    const toPx = (x, y) => [BOARD_X + x * CELL, BOARD_Y + y * CELL];
    onDraw(() => {
      const [w, h] = [COLS * CELL + 3, ROWS * CELL + 3];
      drawRect({ pos: vec2(BOARD_X - 2, BOARD_Y - 2), width: w, height: h, color: rgb(12, 14, 26), outline: { width: 2, color: rgb(90, 96, 140) } });
      board.forEach((row, r) => row.forEach((c, x) => c && drawCell(...toPx(x, r), c)));
      drawMatrix(piece.matrix, ...toPx(piece.x, landingY()), COLORS[piece.kind], 0.25);
      drawMatrix(piece.matrix, ...toPx(piece.x, piece.y), COLORS[piece.kind]);
      // Next piece preview and scoreboard.
      drawText({ text: t({ es: "Siguiente", en: "Next" }), pos: vec2(430, 24), size: 16, font: FONT });
      drawMatrix(SHAPES[next], 430, 56, COLORS[next]);
      const stats = t({ es: `Puntos: ${score}\nLíneas: ${lines}\nNivel: ${level}`, en: `Score: ${score}\nLines: ${lines}\nLevel: ${level}` });
      drawText({ text: stats, pos: vec2(40, 24), size: 18, font: FONT, lineSpacing: 10 });
      const help = t({ es: "◀ ▶ mover · ▲ girar\n▼ bajar · espacio soltar", en: "◀ ▶ move · ▲ rotate\n▼ soft drop · space hard drop" });
      drawText({ text: help, pos: vec2(40, 290), size: 13, font: FONT, color: rgb(160, 168, 210), lineSpacing: 6 });
    });
  8. Scenes

    The game has three scenes, title, game and end, and go() jumps between them. Starting a new game creates an empty well, with nothing to clean up by hand.

    function screen(title, subtitle, next) {
      add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]);
      add([
        text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }),
        pos(center().add(0, 30)),
        anchor("center"),
        color(200, 210, 255),
      ]);
      onButtonPress("start", next);
      onClick(next);
    }
    
    scene("title", () =>
      screen(
        t({ es: "Bloques que caen", en: "Falling blocks" }),
        t({
          es: "Flechas para mover, flecha arriba para girar, abajo para bajar más rápido y espacio para soltar la pieza. Completa filas para borrarlas.\nPulsa espacio, Enter o toca para empezar.",
          en: "Arrows to move, up to rotate, down to drop faster and space to drop the piece. Complete rows to clear them.\nPress space, Enter or tap to start.",
        }),
        () => go("game"),
      ),
    );
    
    scene("end", (score, lines) =>
      screen(
        t({ es: "Se llenó el pozo", en: "The well is full" }),
        t({
          es: `Puntos: ${score} · Líneas: ${lines}.\nPulsa espacio o toca para jugar otra vez.`,
          en: `Score: ${score} · Lines: ${lines}.\nPress space or tap to play again.`,
        }),
        () => go("game"),
      ),
    );
    
    go("title");

What we simplified

  • Rotation uses a simple sideways nudge when the piece hits a wall; commercial games follow much more detailed rotation and "wall kick" tables.
  • The piece locks as soon as it cannot move down; there is no "lock delay", the half second many games give you to slide it before it settles.
  • There is no hold piece, no line-clear animation, no sound and no two-player mode.
  • The level goes up every five rows, half the usual amount, so that the speed-up shows in a short game.

To modify it

  • Add a hold piece that you can swap with the current one once per piece, and think about what new decisions it gives the player.
  • Add a half-second lock delay so you can slide the piece before it settles. How does the feel change at high speeds?
  • Change a piece's matrix or add a five-block shape. What happens to rotation and to difficulty?
  • Show the next three pieces instead of one and see whether you play differently.
Full code
// Falling-block puzzle demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, touchControls, pixelDensity } from "../common.js";

// #region setup
const k = kaplay({
  width: 640,
  height: 360,
  letterbox: true, pixelDensity,
  background: [24, 26, 44],
  // Virtual buttons: the game asks about "rotate", not about a specific key.
  buttons: {
    left: { keyboard: ["left", "a"] },
    right: { keyboard: ["right", "d"] },
    down: { keyboard: ["down", "s"] },
    rotate: { keyboard: ["up", "w", "x"] },
    drop: { keyboard: ["space"] },
    start: { keyboard: ["space", "enter"] },
  },
});
touchControls(k, [
  { button: "left", label: "◀" },
  { button: "down", label: "▼" },
  { button: "right", label: "▶" },
  { button: "rotate", label: "⟳", side: "right" },
  { button: "drop", label: "⤓", side: "right" },
]);

const COLS = 10;
const ROWS = 20;
const CELL = 16;
const BOARD_X = 240; // top-left corner of the well, in pixels
const BOARD_Y = 20;
const LINES_PER_LEVEL = 5; // real games usually use 10
const REPEAT_DELAY = 0.17; // holding left/right: wait this long, then repeat…
const REPEAT_RATE = 0.05; // …one step every this many seconds
// #endregion

// #region pieces
// Each piece is a small matrix: 1 = block, 0 = empty. Seven shapes of four blocks.
const SHAPES = {
  I: [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
  O: [[1, 1], [1, 1]],
  T: [[0, 1, 0], [1, 1, 1], [0, 0, 0]],
  S: [[0, 1, 1], [1, 1, 0], [0, 0, 0]],
  Z: [[1, 1, 0], [0, 1, 1], [0, 0, 0]],
  J: [[1, 0, 0], [1, 1, 1], [0, 0, 0]],
  L: [[0, 0, 1], [1, 1, 1], [0, 0, 0]],
};
const COLORS = {
  I: [80, 210, 230], O: [240, 210, 70], T: [170, 100, 230], S: [100, 210, 110],
  Z: [230, 90, 90], J: [80, 120, 230], L: [240, 150, 60],
};

// Rotating clockwise: column x, read from bottom to top, becomes row x.
const rotate = (m) => m[0].map((_, x) => m.map((row) => row[x]).reverse());

// "Bag" randomiser: shuffle the seven shapes and deal them one by one,
// so you never wait too long for the piece you need.
let bag = [];
function nextKind() {
  if (bag.length === 0) bag = shuffle(Object.keys(SHAPES));
  return bag.pop();
}

// A piece is its shape matrix plus a position on the board (in cells).
function spawn(kind) {
  const matrix = SHAPES[kind];
  return { kind, matrix, x: Math.floor((COLS - matrix[0].length) / 2), y: kind === "I" ? -1 : 0 };
}
// #endregion

// #region board
// The well is a ROWS × COLS matrix: null = empty, otherwise the colour of a settled block.
const emptyRow = () => Array(COLS).fill(null);

// The key question of the genre: would this matrix fit at (x, y)?
// It collides if any of its blocks is past a wall or the floor, or over a settled block.
function collides(board, matrix, x, y) {
  return matrix.some((row, r) =>
    row.some((cell, c) => {
      if (!cell) return false;
      const [bx, by] = [x + c, y + r];
      return bx < 0 || bx >= COLS || by >= ROWS || (by >= 0 && board[by][bx] !== null);
    }),
  );
}
// #endregion

scene("game", () => {
  const board = Array.from({ length: ROWS }, emptyRow);
  bag = [];
  let piece = spawn(nextKind());
  let next = nextKind();
  let [score, lines, level, fallTimer] = [0, 0, 1, 0];

  // #region movement
  // Every move is "try, and only commit if the new place is free".
  function tryMove(dx, dy) {
    if (collides(board, piece.matrix, piece.x + dx, piece.y + dy)) return false;
    piece.x += dx;
    piece.y += dy;
    return true;
  }
  // Rotation with simple "wall kicks": if the turned piece does not fit, nudge it sideways.
  function tryRotate() {
    const turned = rotate(piece.matrix);
    for (const kick of [0, -1, 1, -2, 2]) {
      if (!collides(board, turned, piece.x + kick, piece.y)) {
        piece.matrix = turned;
        piece.x += kick;
        return;
      }
    }
  }
  onButtonPress("rotate", tryRotate);
  // Left/right: one step on press, then auto-repeat while the button is held.
  let held = 0;
  for (const [button, dx] of [["left", -1], ["right", 1]]) {
    onButtonPress(button, () => { held = 0; tryMove(dx, 0); });
    onButtonDown(button, () => {
      held += dt();
      if (held > REPEAT_DELAY) {
        tryMove(dx, 0);
        held -= REPEAT_RATE;
      }
    });
  }
  // #endregion

  // #region gravity
  // The piece falls one row every fallInterval() seconds, faster at each level.
  const fallInterval = () => Math.max(0.06, 0.8 * Math.pow(0.8, level - 1));
  onUpdate(() => {
    // Soft drop: holding down makes it fall much faster (one point per row).
    const soft = isButtonDown("down");
    fallTimer += dt();
    if (fallTimer < (soft ? Math.min(0.04, fallInterval()) : fallInterval())) return;
    fallTimer = 0;
    if (tryMove(0, 1)) score += soft ? 1 : 0;
    else lockPiece(); // it cannot fall any further: it settles
  });
  // Hard drop: straight to the bottom and lock at once (two points per row).
  onButtonPress("drop", () => {
    while (tryMove(0, 1)) score += 2;
    lockPiece();
  });
  // Where the piece would land: drawn as a faint "ghost" to help you aim.
  function landingY() {
    let y = piece.y;
    while (!collides(board, piece.matrix, piece.x, y + 1)) y++;
    return y;
  }
  // #endregion

  // #region lines
  const LINE_POINTS = [0, 100, 300, 500, 800]; // clearing several lines at once pays more
  function lockPiece() {
    // 1) Copy the piece's blocks into the board matrix.
    piece.matrix.forEach((row, r) => row.forEach((cell, c) => {
      if (cell && piece.y + r >= 0) board[piece.y + r][piece.x + c] = COLORS[piece.kind];
    }));
    // 2) A row is full when none of its cells is empty: remove it and add an empty one on top.
    let cleared = 0;
    for (let r = ROWS - 1; r >= 0; r--) {
      if (board[r].every((cell) => cell !== null)) {
        board.splice(r, 1);
        board.unshift(emptyRow());
        cleared++;
        r++; // look at this index again: the row above has just moved down into it
      }
    }
    lines += cleared;
    score += LINE_POINTS[cleared] * level;
    level = 1 + Math.floor(lines / LINES_PER_LEVEL);
    // 3) Next piece. If it does not fit where it appears, the well is full: game over.
    piece = spawn(next);
    next = nextKind();
    fallTimer = 0;
    if (collides(board, piece.matrix, piece.x, piece.y)) go("end", score, lines);
  }
  // #endregion

  // #region draw
  const drawCell = (x, y, c, opacity = 1) =>
    drawRect({ pos: vec2(x, y), width: CELL - 1, height: CELL - 1, color: rgb(...c), opacity });
  // Draws a shape matrix with its top-left corner at pixel (px, py); rows above the well stay hidden.
  const drawMatrix = (matrix, px, py, c, opacity = 1) =>
    matrix.forEach((row, r) =>
      row.forEach((cell, col) => cell && py + r * CELL >= BOARD_Y && drawCell(px + col * CELL, py + r * CELL, c, opacity)),
    );
  const toPx = (x, y) => [BOARD_X + x * CELL, BOARD_Y + y * CELL];
  onDraw(() => {
    const [w, h] = [COLS * CELL + 3, ROWS * CELL + 3];
    drawRect({ pos: vec2(BOARD_X - 2, BOARD_Y - 2), width: w, height: h, color: rgb(12, 14, 26), outline: { width: 2, color: rgb(90, 96, 140) } });
    board.forEach((row, r) => row.forEach((c, x) => c && drawCell(...toPx(x, r), c)));
    drawMatrix(piece.matrix, ...toPx(piece.x, landingY()), COLORS[piece.kind], 0.25);
    drawMatrix(piece.matrix, ...toPx(piece.x, piece.y), COLORS[piece.kind]);
    // Next piece preview and scoreboard.
    drawText({ text: t({ es: "Siguiente", en: "Next" }), pos: vec2(430, 24), size: 16, font: FONT });
    drawMatrix(SHAPES[next], 430, 56, COLORS[next]);
    const stats = t({ es: `Puntos: ${score}\nLíneas: ${lines}\nNivel: ${level}`, en: `Score: ${score}\nLines: ${lines}\nLevel: ${level}` });
    drawText({ text: stats, pos: vec2(40, 24), size: 18, font: FONT, lineSpacing: 10 });
    const help = t({ es: "◀ ▶ mover · ▲ girar\n▼ bajar · espacio soltar", en: "◀ ▶ move · ▲ rotate\n▼ soft drop · space hard drop" });
    drawText({ text: help, pos: vec2(40, 290), size: 13, font: FONT, color: rgb(160, 168, 210), lineSpacing: 6 });
  });
  // #endregion
});

// #region scenes
function screen(title, subtitle, next) {
  add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]);
  add([
    text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }),
    pos(center().add(0, 30)),
    anchor("center"),
    color(200, 210, 255),
  ]);
  onButtonPress("start", next);
  onClick(next);
}

scene("title", () =>
  screen(
    t({ es: "Bloques que caen", en: "Falling blocks" }),
    t({
      es: "Flechas para mover, flecha arriba para girar, abajo para bajar más rápido y espacio para soltar la pieza. Completa filas para borrarlas.\nPulsa espacio, Enter o toca para empezar.",
      en: "Arrows to move, up to rotate, down to drop faster and space to drop the piece. Complete rows to clear them.\nPress space, Enter or tap to start.",
    }),
    () => go("game"),
  ),
);

scene("end", (score, lines) =>
  screen(
    t({ es: "Se llenó el pozo", en: "The well is full" }),
    t({
      es: `Puntos: ${score} · Líneas: ${lines}.\nPulsa espacio o toca para jugar otra vez.`,
      en: `Score: ${score} · Lines: ${lines}.\nPress space or tap to play again.`,
    }),
    () => go("game"),
  ),
);

go("title");
// #endregion

Design challenges

  • Choosing how pieces are drawn. Pure chance can leave long droughts without the piece you need; a «bag» with the seven shapes shuffled guarantees each one comes up once per round.
  • Tuning the feel of control, with auto-repeat when holding a direction, the time before a piece locks and rotation that nudges the piece when it hits a wall.
  • A speed curve that starts calm and speeds up until it demands almost instant decisions, with no unfair jumps.
  • Making everything readable at a glance, with one colour per shape, a preview of the next piece and a ghost that shows where it will land.
  • Rewarding risk, so that clearing several rows at once is worth far more than clearing them one by one.

History

  1. 1984
    OriginTetris

    Tetris, by Alexey Pajitnov, for the Soviet Electronika 60 computer. Sources differ: Henk Rogers places the first version in 1984; Pajitnov said 1985 in a 1993 interview.

    Tetris (Wikipedia)
  2. 1989
    Tetris

    Nintendo bundles the Game Boy version of Tetris with the console in North America and Europe; it sells over 35 million copies and takes the genre to the mass market.

    Tile-matching video game (Wikipedia)
  3. 1989
    Columns

    Jay Geertsen's Columns drops columns of three jewels whose order you can change; three or more alike in a line clear, diagonals included.

    Columns (video game) (Wikipedia)
  4. 1990
    Dr. Mario

    Dr. Mario changes the goal, since it is no longer about surviving but about clearing a bottle full of viruses by lining up four of the same colour with two-coloured capsules.

    Dr. Mario (Wikipedia)
  5. 1991
    Puyo Puyo

    Puyo Puyo, building on Dr. Mario, drops pairs of coloured pieces that clear when grouped.

    Tile-matching video game (Wikipedia)
  6. 1996
    Tetris

    The Tetris Company starts maintaining standard specifications, revised every year, for every authorised version of Tetris.

    Tetris (Wikipedia)
  7. 2004
    Lumines: Puzzle Fusion

    Lumines brings the genre together with music, as a timeline sweeps the screen to the beat and clears the single-colour squares you built from 2 × 2 blocks.

    Lumines: Puzzle Fusion (Wikipedia)
  8. 2019
    Tetris 99

    Tetris 99 takes the genre into battle royale, with 99 online players sending each other garbage rows until only one is left.

    Tetris 99 (Wikipedia)

Reference games

1989

Columns

Jay Geertsen. HP 9000, MS-DOS, Mac, arcade, Mega Drive

Columns of three coloured jewels fall and you can reorder them on the way down; three or more alike in a line, diagonals included, disappear. Jay Geertsen shared it in 1989 and Sega released it in arcades and on consoles.

1990

Dr. Mario

Nintendo. NES, Game Boy

Two-coloured capsules fall and you rotate and place them to line up four of the same colour with the viruses in the bottle. You win the level when you have destroyed every virus.

1991

Puyo Puyo

Compile. MSX2, Famicom Disk System, arcade

Pairs of coloured blobs fall and disappear when four alike touch; if the ones above then form another group as they drop, you chain combos. Sega's arcade version (1992) expanded the game with a story mode and a two-player duel.

2004

Lumines: Puzzle Fusion

Q Entertainment. PlayStation Portable

Two-coloured 2 × 2 blocks fall and you form single-colour squares; a timeline that sweeps the screen to the beat of the music clears them. It was designed by Tetsuya Mizuguchi.

2019

SenTetrox

Pim Schreurs. navegador

A take on the classic falling-block game in which the pieces are sentient and flee back up once they have served their purpose. It was made for the js13kGames 2019 competition, which limits the game to 13 kilobytes.

2019

Tetris 99

Arika. Nintendo Switch

Ninety-nine players play online at once; each row you clear sends garbage rows to someone else's well, and the last one standing wins.

Exercise

In the demo, change LINES_PER_LEVEL and the fallInterval() formula and play a game with each version; note the moment you can no longer think. Then draw a new five-block piece on paper as a matrix of ones and zeros, add it to SHAPES and discuss how the difficulty changes.

Sources