Tetris

The last project in this part is a game: Tetris. Shapes made of four squares fall into a well, one at a time. You slide each one left or right and turn it as it falls. When it lands, it stays where it is, and the next shape appears at the top. A row with no gaps in it disappears, everything above it moves down, and you score points. The game ends when there is no room for the next shape to appear.

It is a bigger program than A Calculator, and it uses nearly every chapter in this part: lists of lists for the well, objects for the shapes, loops inside loops to draw them, decisions to say what may move where, tests for every rule, and, last of all, a clock and the keyboard from Time and Events.

What the game remembers

Before any code, decide what the game has to remember, as the calculator chapter did. Tetris needs five facts:

Notice that the falling piece is not part of the board. It moves on top of the board, and only when it lands is it written into it. Keeping the two apart makes every rule simpler: "can the piece go here?" means "are those squares of the board empty?", and the piece never gets in its own way.

Everything below is built in steps, and every step is a live example that runs and tests what it adds. A live example cannot use code from another example, so each one carries the functions from the steps before it, unchanged, and adds its own after them. When an example looks long, the new part is at the end, immediately above the tests.

The game rules are all plain functions that take plain data (a board, a piece, a place) and give back an answer. None of them knows about the screen, the clock or the keyboard. That is the lesson of Responding to the Reader's "Keep the rules out of the buttons", made bigger: the tests can check every rule without a page, and the part that does touch the page stays so thin there is little in it to get wrong.

The board as data

A board is a grid: 20 rows, each 10 squares across. You already know how to keep a row, a list of values (from Lists). A board is a list of rows: a list of lists, which you met in Recursion (Lists inside lists). Here every row is the same length, so plain loops are enough, no recursion needed. Each square holds a string, '' for empty or a colour name, such as 'orange', for a square that is filled.

To make an empty board, use loops inside loops, as grid did in Repetition (Loops inside loops): the outer loop makes the rows, and for each row, the inner loop fills it with empty squares.

import { elements } from 'tosijs'

const { pre } = elements

function emptyBoard(rows: 20, columns: 10) {
  const board = []
  for (let row = 0; row < rows; row++) {
    const cells = []
    for (let column = 0; column < columns; column++) {
      cells.push('')
    }
    board.push(cells)
  }
  return board
}

function boardText(board: [['']]) {
  let text = ''
  for (const row of board) {
    for (const cell of row) {
      text = text + (cell == '' ? '. ' : '# ')
    }
    text = text + '\n'
  }
  return text
}

const board = emptyBoard(20, 10)
board[19][0] = 'orange'
board[19][1] = 'orange'
board[18][0] = 'orange'

preview.append(pre(boardText(board)))

test 'an empty board' {
  expect(emptyBoard(2, 3)).toEqual([['', '', ''], ['', '', '']])
  expect(emptyBoard(20, 10).length).toBe(20)
  expect(emptyBoard(20, 10)[0].length).toBe(10)
  expect(emptyBoard(0, 10)).toEqual([])
}

test 'changing one square changes only that square' {
  const small = emptyBoard(2, 3)
  small[1][2] = 'red'
  expect(small).toEqual([['', '', ''], ['', '', 'red']])
}

test 'a board as text' {
  const small = emptyBoard(2, 3)
  small[1][0] = 'red'
  expect(boardText(small)).toBe('. . . \n# . . \n')
}

Change the three orange lines to put an L shape somewhere else on the board.

A common mistake: one row, many names

Why does emptyBoard make cells inside the outer loop? Here is what happens if a board's rows are all the same list:

const row = ['', '', '']
const shared = [row, row]

shared[0][1] = 'red'

preview.append(`row 0: [${shared[0]}], row 1: [${shared[1]}]`)

test 'two names for one row' {
  expect(shared[1][1]).toBe('red')
  expect(shared[0] == shared[1]).toBe(true)
}

Filling a square in row 0 fills the same square in row 1. shared is not two rows; it is one row, listed twice. This is Same parts, different objects from Things with Parts: putting row into a list does not copy it, it gives the same list another place to be found. In emptyBoard, const cells = [] runs again every round of the outer loop, so every row is a new list of its own. Move that line above the outer loop, and the test 'changing one square changes only that square' turns red: the 'red' appears in both rows.

Drawing the board

Text is enough to test with, but a game should look like one. Each square becomes a coloured rect (a rectangle), drawn with svg, as the polygons were in Repetition (Shapes from a rule, A honeycomb). A rectangle needs a position, x across and y down, and a width and height.

import { svgElements } from 'tosijs'

const { svg, rect } = svgElements

function emptyBoard(rows: 20, columns: 10) {
  const board = []
  for (let row = 0; row < rows; row++) {
    const cells = []
    for (let column = 0; column < columns; column++) {
      cells.push('')
    }
    board.push(cells)
  }
  return board
}

const squareSize = 16 // pixels across each square

function colourOf(cell: '') {
  return cell == '' ? 'gainsboro' : cell
}

function square(row: 0, column: 0, colour: 'red') {
  return rect({
    x: column * squareSize,
    y: row * squareSize,
    width: squareSize,
    height: squareSize,
    fill: colour,
    stroke: 'white',
  })
}

function drawBoard(board: [['']]) {
  const drawing = svg({
    width: board[0].length * squareSize,
    height: board.length * squareSize,
  })
  for (let row = 0; row < board.length; row++) {
    for (let column = 0; column < board[row].length; column++) {
      drawing.append(square(row, column, colourOf(board[row][column])))
    }
  }
  return drawing
}

const board = emptyBoard(20, 10)
board[19][0] = 'orange'
board[19][1] = 'orange'
board[18][0] = 'orange'
board[19][5] = 'cyan'
board[19][6] = 'cyan'
board[19][7] = 'cyan'
board[19][8] = 'cyan'

preview.append(drawBoard(board))

test 'the colour of a square' {
  expect(colourOf('')).toBe('gainsboro')
  expect(colourOf('orange')).toBe('orange')
}

drawBoard depends on the board and nothing else. Give it the same board and you get the same picture; change the board and draw it again, and the picture changes. The game will work exactly that way: change the facts, then draw them.

Try squareSize = 30, or change 'gainsboro' to 'black' for a night-time well.

A piece as data

There are seven pieces, named after the letters they look like: I, O, T, S, Z, J and L. Each one is easiest to describe as a small picture, so the program writes it that way: a grid of text in which X is a square of the piece and . is empty.

The rules need something else: a list of the piece's squares, each with a row and a column, counted inside the little grid. cellsOf turns the picture into that list, with loops inside loops once more. It reads each line of the picture one letter at a time: grid[row][column] works on a list of strings as it does on a list of lists, because a string can be read by position with square brackets, as a list can ('.X.'[1] is 'X').

import { svgElements } from 'tosijs'

const { svg, rect } = svgElements

function emptyBoard(rows: 20, columns: 10) {
  const board = []
  for (let row = 0; row < rows; row++) {
    const cells = []
    for (let column = 0; column < columns; column++) {
      cells.push('')
    }
    board.push(cells)
  }
  return board
}

const squareSize = 16 // pixels across each square

function colourOf(cell: '') {
  return cell == '' ? 'gainsboro' : cell
}

function square(row: 0, column: 0, colour: 'red') {
  return rect({
    x: column * squareSize,
    y: row * squareSize,
    width: squareSize,
    height: squareSize,
    fill: colour,
    stroke: 'white',
  })
}

function drawBoard(board: [['']]) {
  const drawing = svg({
    width: board[0].length * squareSize,
    height: board.length * squareSize,
  })
  for (let row = 0; row < board.length; row++) {
    for (let column = 0; column < board[row].length; column++) {
      drawing.append(square(row, column, colourOf(board[row][column])))
    }
  }
  return drawing
}

// New in this step:

const shapes = {
  I: { colour: 'cyan', grid: ['....', 'XXXX', '....', '....'] },
  O: { colour: 'gold', grid: ['XX', 'XX'] },
  T: { colour: 'purple', grid: ['.X.', 'XXX', '...'] },
  S: { colour: 'green', grid: ['.XX', 'XX.', '...'] },
  Z: { colour: 'red', grid: ['XX.', '.XX', '...'] },
  J: { colour: 'blue', grid: ['X..', 'XXX', '...'] },
  L: { colour: 'orange', grid: ['..X', 'XXX', '...'] },
}

Type Piece {
  description: 'a shape: its colour, the size of its square grid, and its squares'
  example: { name: 'T', colour: 'purple', size: 3, cells: [{ row: 0, column: 0 }] }
}

Type Place {
  description: 'a row and a column on the board'
  example: { row: 0, column: 0 }
}

function cellsOf(grid: ['']) {
  const cells = []
  for (let row = 0; row < grid.length; row++) {
    for (let column = 0; column < grid[row].length; column++) {
      if (grid[row][column] == 'X') {
        cells.push({ row, column })
      }
    }
  }
  return cells
}

function makePiece(name: 'T') {
  const shape = shapes[name]
  return {
    name,
    colour: shape.colour,
    size: shape.grid.length,
    cells: cellsOf(shape.grid),
  }
}

function picture(piece: Piece) {
  const grid = emptyBoard(piece.size, piece.size)
  for (const cell of piece.cells) {
    grid[cell.row][cell.column] = 'X'
  }
  const lines = []
  for (const row of grid) {
    let line = ''
    for (const cell of row) {
      line = line + (cell == '' ? '.' : 'X')
    }
    lines.push(line)
  }
  return lines
}

function drawGame(board: [['']], piece: Piece, place: Place) {
  const drawing = drawBoard(board)
  for (const cell of piece.cells) {
    drawing.append(
      square(place.row + cell.row, place.column + cell.column, piece.colour)
    )
  }
  return drawing
}

for (const name of Object.keys(shapes)) {
  const piece = makePiece(name)
  const board = emptyBoard(piece.size, piece.size)
  preview.append(drawGame(board, piece, { row: 0, column: 0 }), ' ')
}

test 'a picture becomes a list of squares' {
  expect(cellsOf(['.X.', 'XXX', '...'])).toEqual([
    { row: 0, column: 1 },
    { row: 1, column: 0 },
    { row: 1, column: 1 },
    { row: 1, column: 2 },
  ])
}

test 'every piece has four squares, and draws back as its picture' {
  for (const name of Object.keys(shapes)) {
    const piece = makePiece(name)
    expect(piece.cells.length).toBe(4)
    expect(picture(piece)).toEqual(shapes[name].grid)
  }
}

test 'a piece knows its name, colour and size' {
  const t = makePiece('T')
  expect(t.name).toBe('T')
  expect(t.colour).toBe('purple')
  expect(t.size).toBe(3)
  expect(makePiece('I').size).toBe(4)
}

test 'a piece with a missing part is refused' {
  expect(picture({ name: 'T', colour: 'purple', cells: [] }) instanceof Error).toBe(true)
}

Change a grid in shapes (give the T a fifth square, say) and run it. The picture changes, and the test 'every piece has four squares' says which piece broke the rule.

Moving and colliding

The most important rule in the game is one question: can this piece be at this place? Every move asks it. Sliding left asks it about the place one column to the left; falling asks it about the place one row down; turning asks it about the turned piece. If the answer is no, the move does not happen.

A piece fits if every one of its squares is on the board and empty. A loop goes through the squares, and the first one that fails is enough to say no, so the function gives back false at once, the way break stopped the search in Repetition (Stopping early with break):

import { elements, svgElements } from 'tosijs'

const { div } = elements
const { svg, rect } = svgElements

function emptyBoard(rows: 20, columns: 10) {
  const board = []
  for (let row = 0; row < rows; row++) {
    const cells = []
    for (let column = 0; column < columns; column++) {
      cells.push('')
    }
    board.push(cells)
  }
  return board
}

const squareSize = 16 // pixels across each square

function colourOf(cell: '') {
  return cell == '' ? 'gainsboro' : cell
}

function square(row: 0, column: 0, colour: 'red') {
  return rect({
    x: column * squareSize,
    y: row * squareSize,
    width: squareSize,
    height: squareSize,
    fill: colour,
    stroke: 'white',
  })
}

function drawBoard(board: [['']]) {
  const drawing = svg({
    width: board[0].length * squareSize,
    height: board.length * squareSize,
  })
  for (let row = 0; row < board.length; row++) {
    for (let column = 0; column < board[row].length; column++) {
      drawing.append(square(row, column, colourOf(board[row][column])))
    }
  }
  return drawing
}

const shapes = {
  I: { colour: 'cyan', grid: ['....', 'XXXX', '....', '....'] },
  O: { colour: 'gold', grid: ['XX', 'XX'] },
  T: { colour: 'purple', grid: ['.X.', 'XXX', '...'] },
  S: { colour: 'green', grid: ['.XX', 'XX.', '...'] },
  Z: { colour: 'red', grid: ['XX.', '.XX', '...'] },
  J: { colour: 'blue', grid: ['X..', 'XXX', '...'] },
  L: { colour: 'orange', grid: ['..X', 'XXX', '...'] },
}

Type Piece {
  description: 'a shape: its colour, the size of its square grid, and its squares'
  example: { name: 'T', colour: 'purple', size: 3, cells: [{ row: 0, column: 0 }] }
}

Type Place {
  description: 'a row and a column on the board'
  example: { row: 0, column: 0 }
}

function cellsOf(grid: ['']) {
  const cells = []
  for (let row = 0; row < grid.length; row++) {
    for (let column = 0; column < grid[row].length; column++) {
      if (grid[row][column] == 'X') {
        cells.push({ row, column })
      }
    }
  }
  return cells
}

function makePiece(name: 'T') {
  const shape = shapes[name]
  return {
    name,
    colour: shape.colour,
    size: shape.grid.length,
    cells: cellsOf(shape.grid),
  }
}

function drawGame(board: [['']], piece: Piece, place: Place) {
  const drawing = drawBoard(board)
  for (const cell of piece.cells) {
    drawing.append(
      square(place.row + cell.row, place.column + cell.column, piece.colour)
    )
  }
  return drawing
}

// New in this step:

function fits(board: [['']], piece: Piece, place: Place) {
  for (const cell of piece.cells) {
    const row = place.row + cell.row
    const column = place.column + cell.column
    if (row < 0 || row >= board.length) {
      return false // above the top, or below the floor
    }
    if (column < 0 || column >= board[0].length) {
      return false // through a wall
    }
    if (board[row][column] != '') {
      return false // the square is already filled
    }
  }
  return true
}

const board = emptyBoard(20, 10)
board[19][3] = 'blue'
board[19][4] = 'blue'
board[19][5] = 'blue'
board[18][5] = 'blue'

const piece = makePiece('T')
const place = { row: 16, column: 3 }

preview.append(
  div(`Does the T fit at row ${place.row}, column ${place.column}? ${fits(board, piece, place)}`),
  drawGame(board, piece, place)
)

test 'a piece fits in open space' {
  const t = makePiece('T')
  expect(fits(emptyBoard(20, 10), t, { row: 0, column: 0 })).toBe(true)
  expect(fits(emptyBoard(20, 10), t, { row: 10, column: 4 })).toBe(true)
}

test 'the walls' {
  const board = emptyBoard(20, 10)
  const t = makePiece('T')
  expect(fits(board, t, { row: 0, column: 0 })).toBe(true)
  expect(fits(board, t, { row: 0, column: -1 })).toBe(false)
  expect(fits(board, t, { row: 0, column: 7 })).toBe(true)
  expect(fits(board, t, { row: 0, column: 8 })).toBe(false)
}

test 'the floor' {
  const board = emptyBoard(20, 10)
  const t = makePiece('T')
  expect(fits(board, t, { row: 18, column: 0 })).toBe(true)
  expect(fits(board, t, { row: 19, column: 0 })).toBe(false)
}

test 'other blocks' {
  const board = emptyBoard(20, 10)
  board[5][1] = 'red'
  const t = makePiece('T')
  expect(fits(board, t, { row: 4, column: 0 })).toBe(false)
  expect(fits(board, t, { row: 5, column: 0 })).toBe(false)
  expect(fits(board, t, { row: 6, column: 0 })).toBe(true)
  expect(fits(board, t, { row: 4, column: 2 })).toBe(true)
}

Each if is one way to fail, with || (or) joining the two edges of each kind: a row above the top or below the floor, a column through the left or the right wall. A square of the board that is not '' is already filled. Only a piece that gets past every check, for every square, reaches return true.

The tests are built from the edges, as Checking Your Work recommends (Test the edges). The T is 3 squares wide, so at column 7 its right-hand square is in column 9, the last one, and fits; at column 8 it would need a column 10, and does not. Each pair of checks stands on either side of a boundary, so a rule that is off by one fails one of them.

In the example, the T at row 16 sits right above the blue blocks. Change place to row 17 and the answer is false: the T's right-hand square would be where a blue one already is. The drawing shows the piece anyway, overlapping, because drawing only draws; deciding is the job of fits.

Turning a piece

Turning a piece a quarter turn clockwise changes where its squares are in its little grid. Picture a sheet of squared paper turned to the right: the top row becomes the right-hand column, and the left-hand column becomes the top row. For each square, that is:

Here is the T worked through, with size 3:

square old row, column new row new column
top 0, 1 1 3 - 1 - 0 = 2
left 1, 0 0 3 - 1 - 1 = 1
middle 1, 1 1 3 - 1 - 1 = 1
right 1, 2 2 3 - 1 - 1 = 1

The T pointing up becomes a T pointing right: ['.X.', '.XX', '.X.']. rotate does that to every square, with one loop, and gives back a new piece; the piece it was given is left alone.

Turning can fail like any move. A long I standing upright against the right wall has no room to lie down. turn asks fits about the turned piece, and gives back either the turned piece or, when it does not fit, the piece unchanged:

import { elements, svgElements } from 'tosijs'

const { div } = elements
const { svg, rect } = svgElements

function emptyBoard(rows: 20, columns: 10) {
  const board = []
  for (let row = 0; row < rows; row++) {
    const cells = []
    for (let column = 0; column < columns; column++) {
      cells.push('')
    }
    board.push(cells)
  }
  return board
}

const squareSize = 16 // pixels across each square

function colourOf(cell: '') {
  return cell == '' ? 'gainsboro' : cell
}

function square(row: 0, column: 0, colour: 'red') {
  return rect({
    x: column * squareSize,
    y: row * squareSize,
    width: squareSize,
    height: squareSize,
    fill: colour,
    stroke: 'white',
  })
}

function drawBoard(board: [['']]) {
  const drawing = svg({
    width: board[0].length * squareSize,
    height: board.length * squareSize,
  })
  for (let row = 0; row < board.length; row++) {
    for (let column = 0; column < board[row].length; column++) {
      drawing.append(square(row, column, colourOf(board[row][column])))
    }
  }
  return drawing
}

const shapes = {
  I: { colour: 'cyan', grid: ['....', 'XXXX', '....', '....'] },
  O: { colour: 'gold', grid: ['XX', 'XX'] },
  T: { colour: 'purple', grid: ['.X.', 'XXX', '...'] },
  S: { colour: 'green', grid: ['.XX', 'XX.', '...'] },
  Z: { colour: 'red', grid: ['XX.', '.XX', '...'] },
  J: { colour: 'blue', grid: ['X..', 'XXX', '...'] },
  L: { colour: 'orange', grid: ['..X', 'XXX', '...'] },
}

Type Piece {
  description: 'a shape: its colour, the size of its square grid, and its squares'
  example: { name: 'T', colour: 'purple', size: 3, cells: [{ row: 0, column: 0 }] }
}

Type Place {
  description: 'a row and a column on the board'
  example: { row: 0, column: 0 }
}

function cellsOf(grid: ['']) {
  const cells = []
  for (let row = 0; row < grid.length; row++) {
    for (let column = 0; column < grid[row].length; column++) {
      if (grid[row][column] == 'X') {
        cells.push({ row, column })
      }
    }
  }
  return cells
}

function makePiece(name: 'T') {
  const shape = shapes[name]
  return {
    name,
    colour: shape.colour,
    size: shape.grid.length,
    cells: cellsOf(shape.grid),
  }
}

function picture(piece: Piece) {
  const grid = emptyBoard(piece.size, piece.size)
  for (const cell of piece.cells) {
    grid[cell.row][cell.column] = 'X'
  }
  const lines = []
  for (const row of grid) {
    let line = ''
    for (const cell of row) {
      line = line + (cell == '' ? '.' : 'X')
    }
    lines.push(line)
  }
  return lines
}

function drawGame(board: [['']], piece: Piece, place: Place) {
  const drawing = drawBoard(board)
  for (const cell of piece.cells) {
    drawing.append(
      square(place.row + cell.row, place.column + cell.column, piece.colour)
    )
  }
  return drawing
}

function fits(board: [['']], piece: Piece, place: Place) {
  for (const cell of piece.cells) {
    const row = place.row + cell.row
    const column = place.column + cell.column
    if (row < 0 || row >= board.length) {
      return false // above the top, or below the floor
    }
    if (column < 0 || column >= board[0].length) {
      return false // through a wall
    }
    if (board[row][column] != '') {
      return false // the square is already filled
    }
  }
  return true
}

// New in this step:

function rotate(piece: Piece) {
  const cells = []
  for (const cell of piece.cells) {
    cells.push({ row: cell.column, column: piece.size - 1 - cell.row })
  }
  return { name: piece.name, colour: piece.colour, size: piece.size, cells }
}

function turn(board: [['']], piece: Piece, place: Place) {
  const turned = rotate(piece)
  return fits(board, turned, place) ? turned : piece
}

let t = makePiece('T')
for (let turns = 0; turns < 4; turns++) {
  preview.append(drawGame(emptyBoard(3, 3), t, { row: 0, column: 0 }), ' ')
  t = rotate(t)
}

const board = emptyBoard(6, 10)
const upright = rotate(makePiece('I'))
const place = { row: 1, column: 7 }
const after = turn(board, upright, place)

preview.append(
  div(`Against the wall, the I ${after == upright ? 'cannot' : 'can'} turn.`),
  drawGame(board, after, place)
)

test 'turning the T' {
  const t = makePiece('T')
  expect(picture(rotate(t))).toEqual(['.X.', '.XX', '.X.'])
  expect(picture(rotate(rotate(t)))).toEqual(['...', 'XXX', '.X.'])
  expect(picture(rotate(rotate(rotate(t))))).toEqual(['.X.', 'XX.', '.X.'])
}

test 'four turns bring a piece back' {
  for (const name of Object.keys(shapes)) {
    const piece = makePiece(name)
    expect(rotate(rotate(rotate(rotate(piece))))).toEqual(piece)
  }
}

test 'the O looks the same after a turn' {
  expect(picture(rotate(makePiece('O')))).toEqual(['XX', 'XX'])
}

test 'rotate leaves the piece it was given alone' {
  const t = makePiece('T')
  rotate(t)
  expect(picture(t)).toEqual(['.X.', 'XXX', '...'])
}

test 'a turn that does not fit is refused' {
  const board = emptyBoard(20, 10)
  const upright = rotate(makePiece('I'))
  expect(picture(upright)).toEqual(['..X.', '..X.', '..X.', '..X.'])
  expect(turn(board, upright, { row: 0, column: 7 })).toEqual(upright)
  expect(picture(turn(board, upright, { row: 0, column: 3 }))).toEqual([
    '....',
    '....',
    'XXXX',
    '....',
  ])
}

test 'a turn into another block is refused' {
  const board = emptyBoard(20, 10)
  board[2][4] = 'red'
  const t = makePiece('T')
  expect(turn(board, t, { row: 0, column: 3 })).toEqual(t)
}

The four Ts in the preview are one piece, drawn after each turn: t = rotate(t) replaces it with its turned copy every round, which is why t is a let. The test 'four turns bring a piece back' checks the rule for all seven pieces at once: four quarter turns are a whole turn, so every square must end where it started.

That test is not enough on its own. Make a mistake in rotate: change piece.size - 1 - cell.row to piece.size - cell.row, without the - 1. 'four turns bring a piece back' still passes, because the mistake moves each square one column too far right on every turn and undoes itself by the fourth. 'turning the T' fails, and its message shows the T sliding out of its grid: ['..X', '..XX', '..X']. A test that checks a real answer catches what a test of a round trip cannot, which is Checking Your Work's question, Does your test catch anything?, again.

In the second picture the I is pressed against the right wall, upright in column 9. Lying down would need columns 7 to 10, and there is no column 10, so turn gives back the upright piece and the preview says it cannot turn. Change the place to column 5 and it lies down.

turn uses the same question every move uses, fits. Nothing about turning needed a new rule for walls, the floor or other blocks: it got all three for free.

Landing a piece

When a piece cannot fall any further, it lands: its squares are written into the board, in its colour, and from then on they are part of the well. land gives back a new board with the piece copied in, rather than changing the board it was given:

import { svgElements } from 'tosijs'

const { svg, rect } = svgElements

function emptyBoard(rows: 20, columns: 10) {
  const board = []
  for (let row = 0; row < rows; row++) {
    const cells = []
    for (let column = 0; column < columns; column++) {
      cells.push('')
    }
    board.push(cells)
  }
  return board
}

const squareSize = 16 // pixels across each square

function colourOf(cell: '') {
  return cell == '' ? 'gainsboro' : cell
}

function square(row: 0, column: 0, colour: 'red') {
  return rect({
    x: column * squareSize,
    y: row * squareSize,
    width: squareSize,
    height: squareSize,
    fill: colour,
    stroke: 'white',
  })
}

function drawBoard(board: [['']]) {
  const drawing = svg({
    width: board[0].length * squareSize,
    height: board.length * squareSize,
  })
  for (let row = 0; row < board.length; row++) {
    for (let column = 0; column < board[row].length; column++) {
      drawing.append(square(row, column, colourOf(board[row][column])))
    }
  }
  return drawing
}

const shapes = {
  I: { colour: 'cyan', grid: ['....', 'XXXX', '....', '....'] },
  O: { colour: 'gold', grid: ['XX', 'XX'] },
  T: { colour: 'purple', grid: ['.X.', 'XXX', '...'] },
  S: { colour: 'green', grid: ['.XX', 'XX.', '...'] },
  Z: { colour: 'red', grid: ['XX.', '.XX', '...'] },
  J: { colour: 'blue', grid: ['X..', 'XXX', '...'] },
  L: { colour: 'orange', grid: ['..X', 'XXX', '...'] },
}

Type Piece {
  description: 'a shape: its colour, the size of its square grid, and its squares'
  example: { name: 'T', colour: 'purple', size: 3, cells: [{ row: 0, column: 0 }] }
}

Type Place {
  description: 'a row and a column on the board'
  example: { row: 0, column: 0 }
}

function cellsOf(grid: ['']) {
  const cells = []
  for (let row = 0; row < grid.length; row++) {
    for (let column = 0; column < grid[row].length; column++) {
      if (grid[row][column] == 'X') {
        cells.push({ row, column })
      }
    }
  }
  return cells
}

function makePiece(name: 'T') {
  const shape = shapes[name]
  return {
    name,
    colour: shape.colour,
    size: shape.grid.length,
    cells: cellsOf(shape.grid),
  }
}

function fits(board: [['']], piece: Piece, place: Place) {
  for (const cell of piece.cells) {
    const row = place.row + cell.row
    const column = place.column + cell.column
    if (row < 0 || row >= board.length) {
      return false // above the top, or below the floor
    }
    if (column < 0 || column >= board[0].length) {
      return false // through a wall
    }
    if (board[row][column] != '') {
      return false // the square is already filled
    }
  }
  return true
}

function rotate(piece: Piece) {
  const cells = []
  for (const cell of piece.cells) {
    cells.push({ row: cell.column, column: piece.size - 1 - cell.row })
  }
  return { name: piece.name, colour: piece.colour, size: piece.size, cells }
}

// New in this step:

function land(board: [['']], piece: Piece, place: Place) {
  const landed = board.map((row) => [...row])
  for (const cell of piece.cells) {
    landed[place.row + cell.row][place.column + cell.column] = piece.colour
  }
  return landed
}

function lowest(board: [['']], piece: Piece, place: Place) {
  let row = place.row
  while (fits(board, piece, { row: row + 1, column: place.column })) {
    row = row + 1
  }
  return { row, column: place.column }
}

function dropAndLand(board: [['']], piece: Piece, column: 0) {
  return land(board, piece, lowest(board, piece, { row: 0, column }))
}

let board = emptyBoard(20, 10)
board = dropAndLand(board, makePiece('O'), 0)
board = dropAndLand(board, makePiece('T'), 1)
board = dropAndLand(board, makePiece('L'), 6)
board = dropAndLand(board, rotate(makePiece('I')), 7)

preview.append(drawBoard(board))

test 'landing writes the piece into a copy of the board' {
  const board = emptyBoard(4, 4)
  const landed = land(board, makePiece('O'), { row: 2, column: 1 })
  expect(landed[2]).toEqual(['', 'gold', 'gold', ''])
  expect(landed[3]).toEqual(['', 'gold', 'gold', ''])
  expect(board[3]).toEqual(['', '', '', ''])
}

test 'a piece falls to the floor' {
  const board = emptyBoard(20, 10)
  expect(lowest(board, makePiece('O'), { row: 0, column: 4 })).toEqual({ row: 18, column: 4 })
  expect(lowest(board, makePiece('T'), { row: 0, column: 4 })).toEqual({ row: 18, column: 4 })
}

test 'a piece falls onto other blocks' {
  const board = dropAndLand(emptyBoard(20, 10), makePiece('O'), 0)
  expect(board[19][0]).toBe('gold')
  expect(lowest(board, makePiece('O'), { row: 0, column: 0 })).toEqual({ row: 16, column: 0 })
  expect(lowest(board, makePiece('O'), { row: 0, column: 2 })).toEqual({ row: 18, column: 2 })
}

Why a copy, when the calculator's press changed the calculator it was given? Because a test can then compare the board before and after, and because a board that is never changed in place can be shared safely. land makes the new board; the program decides when to replace the old one with it, in one assignment: board = dropAndLand(…). That is why board is a let here.

Change the columns the four pieces are dropped into, or add a fifth line, and watch the well fill up.

Finished rows

A row with no empty square in it is finished. isFull asks a question of every square in a row: the first empty one is enough to say no, and only a row that gets through the whole loop is full. It is the same shape as fits, a loop that can stop at the first failure.

clearRows keeps the rows that are not full, with filter from Lists, and counts how many it removed. The rows that are left are fewer than the board needs, so it puts that many new empty rows on top. That is what makes everything above a cleared row move down: nothing moves at all; there are fewer rows below it.

Points reward clearing several rows at once: 100 for one row, 300 for two, 500 for three, and 800 for four, which is called a Tetris.

import { elements, svgElements } from 'tosijs'

const { div } = elements
const { svg, rect } = svgElements

function emptyBoard(rows: 20, columns: 10) {
  const board = []
  for (let row = 0; row < rows; row++) {
    const cells = []
    for (let column = 0; column < columns; column++) {
      cells.push('')
    }
    board.push(cells)
  }
  return board
}

const squareSize = 16 // pixels across each square

function colourOf(cell: '') {
  return cell == '' ? 'gainsboro' : cell
}

function square(row: 0, column: 0, colour: 'red') {
  return rect({
    x: column * squareSize,
    y: row * squareSize,
    width: squareSize,
    height: squareSize,
    fill: colour,
    stroke: 'white',
  })
}

function drawBoard(board: [['']]) {
  const drawing = svg({
    width: board[0].length * squareSize,
    height: board.length * squareSize,
  })
  for (let row = 0; row < board.length; row++) {
    for (let column = 0; column < board[row].length; column++) {
      drawing.append(square(row, column, colourOf(board[row][column])))
    }
  }
  return drawing
}

const shapes = {
  I: { colour: 'cyan', grid: ['....', 'XXXX', '....', '....'] },
  O: { colour: 'gold', grid: ['XX', 'XX'] },
  T: { colour: 'purple', grid: ['.X.', 'XXX', '...'] },
  S: { colour: 'green', grid: ['.XX', 'XX.', '...'] },
  Z: { colour: 'red', grid: ['XX.', '.XX', '...'] },
  J: { colour: 'blue', grid: ['X..', 'XXX', '...'] },
  L: { colour: 'orange', grid: ['..X', 'XXX', '...'] },
}

Type Piece {
  description: 'a shape: its colour, the size of its square grid, and its squares'
  example: { name: 'T', colour: 'purple', size: 3, cells: [{ row: 0, column: 0 }] }
}

Type Place {
  description: 'a row and a column on the board'
  example: { row: 0, column: 0 }
}

function cellsOf(grid: ['']) {
  const cells = []
  for (let row = 0; row < grid.length; row++) {
    for (let column = 0; column < grid[row].length; column++) {
      if (grid[row][column] == 'X') {
        cells.push({ row, column })
      }
    }
  }
  return cells
}

function makePiece(name: 'T') {
  const shape = shapes[name]
  return {
    name,
    colour: shape.colour,
    size: shape.grid.length,
    cells: cellsOf(shape.grid),
  }
}

function fits(board: [['']], piece: Piece, place: Place) {
  for (const cell of piece.cells) {
    const row = place.row + cell.row
    const column = place.column + cell.column
    if (row < 0 || row >= board.length) {
      return false // above the top, or below the floor
    }
    if (column < 0 || column >= board[0].length) {
      return false // through a wall
    }
    if (board[row][column] != '') {
      return false // the square is already filled
    }
  }
  return true
}

function rotate(piece: Piece) {
  const cells = []
  for (const cell of piece.cells) {
    cells.push({ row: cell.column, column: piece.size - 1 - cell.row })
  }
  return { name: piece.name, colour: piece.colour, size: piece.size, cells }
}

function land(board: [['']], piece: Piece, place: Place) {
  const landed = board.map((row) => [...row])
  for (const cell of piece.cells) {
    landed[place.row + cell.row][place.column + cell.column] = piece.colour
  }
  return landed
}

function lowest(board: [['']], piece: Piece, place: Place) {
  let row = place.row
  while (fits(board, piece, { row: row + 1, column: place.column })) {
    row = row + 1
  }
  return { row, column: place.column }
}

function dropAndLand(board: [['']], piece: Piece, column: 0) {
  return land(board, piece, lowest(board, piece, { row: 0, column }))
}

// New in this step:

function isFull(row: ['']) {
  for (const cell of row) {
    if (cell == '') {
      return false
    }
  }
  return true
}

function clearRows(board: [['']]) {
  const kept = board.filter((row) => !isFull(row))
  const cleared = board.length - kept.length
  const fresh = emptyBoard(cleared, board[0].length)
  return { board: [...fresh, ...kept], cleared }
}

function points(cleared: 0) {
  return [0, 100, 300, 500, 800][cleared]
}

// Four nearly full rows: two Os finish the top one, leaving a gap down the right for an I.
let board = emptyBoard(20, 10)
for (let row = 16; row < 20; row++) {
  for (let column = 0; column < 9; column++) {
    board[row][column] = row == 16 && column > 4 ? '' : 'blue'
  }
}
board = dropAndLand(board, makePiece('O'), 5)
board = dropAndLand(board, makePiece('O'), 7)
const before = dropAndLand(board, rotate(makePiece('I')), 7)
const after = clearRows(before)

preview.append(
  div(`Rows cleared: ${after.cleared}, for ${points(after.cleared)} points.`),
  drawBoard(before),
  ' ',
  drawBoard(after.board)
)

test 'a row is full only when every square is filled' {
  expect(isFull(['red', 'blue', 'gold'])).toBe(true)
  expect(isFull(['red', '', 'gold'])).toBe(false)
  expect(isFull(['', 'blue', 'gold'])).toBe(false)
  expect(isFull(['red', 'blue', ''])).toBe(false)
}

mock {
  const full = ['x', 'x', 'x']
  const gap = ['x', '', 'x']
  const empty = ['', '', '']
}

test 'no full rows: nothing changes' {
  expect(clearRows([empty, gap, gap])).toEqual({ board: [empty, gap, gap], cleared: 0 })
}

test 'one full row' {
  expect(clearRows([empty, gap, full])).toEqual({ board: [empty, empty, gap], cleared: 1 })
}

test 'two full rows, with a row between them' {
  const result = clearRows([full, gap, full])
  expect(result.cleared).toBe(2)
  expect(result.board).toEqual([empty, empty, gap])
}

test 'four full rows at once' {
  const result = clearRows([gap, full, full, full, full])
  expect(result.cleared).toBe(4)
  expect(result.board).toEqual([empty, empty, empty, empty, gap])
}

test 'the board keeps its size' {
  expect(clearRows([full, full, gap]).board.length).toBe(3)
}

test 'points' {
  expect(points(0)).toBe(0)
  expect(points(1)).toBe(100)
  expect(points(2)).toBe(300)
  expect(points(4)).toBe(800)
}

The mock block (it holds setup for the tests, so the three sample rows are written once) makes three tiny rows, each three squares wide. A rule that works for rows of three works for rows of ten, and a board of five three-square rows is one you can read in a test at a glance.

'two full rows, with a row between them' is the test most worth having. A first attempt at clearRows often goes down the board removing rows one at a time, and gets confused when two rows next to each other, or one apart, are both full. Keeping the rows that are not full, all at once, cannot get confused that way.

The preview shows the board before the I lands and after the rows clear: four full rows, all gone, and the four gold squares that stood on top of them have moved down to the bottom row.

One turn of the game

The rules are all there. What is left is one more set of functions to say what happens on each tick of the clock and each press of a key, and they put the five facts from the start of the chapter into one object, the game. As with the calculator, these functions change the object they are given rather than making a new one, because the game is the one thing that lasts from the first piece to the last.

fall does not choose the next piece; whoever calls it does. On the clock, that will be a random piece. In the tests it is whatever piece the test needs. A rule that picked its own random piece could not be tested, because nobody could say what it should do.

import { elements, svgElements } from 'tosijs'

const { div } = elements
const { svg, rect } = svgElements

function emptyBoard(rows: 20, columns: 10) {
  const board = []
  for (let row = 0; row < rows; row++) {
    const cells = []
    for (let column = 0; column < columns; column++) {
      cells.push('')
    }
    board.push(cells)
  }
  return board
}

const squareSize = 16 // pixels across each square

function colourOf(cell: '') {
  return cell == '' ? 'gainsboro' : cell
}

function square(row: 0, column: 0, colour: 'red') {
  return rect({
    x: column * squareSize,
    y: row * squareSize,
    width: squareSize,
    height: squareSize,
    fill: colour,
    stroke: 'white',
  })
}

function drawBoard(board: [['']]) {
  const drawing = svg({
    width: board[0].length * squareSize,
    height: board.length * squareSize,
  })
  for (let row = 0; row < board.length; row++) {
    for (let column = 0; column < board[row].length; column++) {
      drawing.append(square(row, column, colourOf(board[row][column])))
    }
  }
  return drawing
}

const shapes = {
  I: { colour: 'cyan', grid: ['....', 'XXXX', '....', '....'] },
  O: { colour: 'gold', grid: ['XX', 'XX'] },
  T: { colour: 'purple', grid: ['.X.', 'XXX', '...'] },
  S: { colour: 'green', grid: ['.XX', 'XX.', '...'] },
  Z: { colour: 'red', grid: ['XX.', '.XX', '...'] },
  J: { colour: 'blue', grid: ['X..', 'XXX', '...'] },
  L: { colour: 'orange', grid: ['..X', 'XXX', '...'] },
}

Type Piece {
  description: 'a shape: its colour, the size of its square grid, and its squares'
  example: { name: 'T', colour: 'purple', size: 3, cells: [{ row: 0, column: 0 }] }
}

Type Place {
  description: 'a row and a column on the board'
  example: { row: 0, column: 0 }
}

function cellsOf(grid: ['']) {
  const cells = []
  for (let row = 0; row < grid.length; row++) {
    for (let column = 0; column < grid[row].length; column++) {
      if (grid[row][column] == 'X') {
        cells.push({ row, column })
      }
    }
  }
  return cells
}

function makePiece(name: 'T') {
  const shape = shapes[name]
  return {
    name,
    colour: shape.colour,
    size: shape.grid.length,
    cells: cellsOf(shape.grid),
  }
}

function drawGame(board: [['']], piece: Piece, place: Place) {
  const drawing = drawBoard(board)
  for (const cell of piece.cells) {
    drawing.append(
      square(place.row + cell.row, place.column + cell.column, piece.colour)
    )
  }
  return drawing
}

function fits(board: [['']], piece: Piece, place: Place) {
  for (const cell of piece.cells) {
    const row = place.row + cell.row
    const column = place.column + cell.column
    if (row < 0 || row >= board.length) {
      return false // above the top, or below the floor
    }
    if (column < 0 || column >= board[0].length) {
      return false // through a wall
    }
    if (board[row][column] != '') {
      return false // the square is already filled
    }
  }
  return true
}

function rotate(piece: Piece) {
  const cells = []
  for (const cell of piece.cells) {
    cells.push({ row: cell.column, column: piece.size - 1 - cell.row })
  }
  return { name: piece.name, colour: piece.colour, size: piece.size, cells }
}

function turn(board: [['']], piece: Piece, place: Place) {
  const turned = rotate(piece)
  return fits(board, turned, place) ? turned : piece
}

function land(board: [['']], piece: Piece, place: Place) {
  const landed = board.map((row) => [...row])
  for (const cell of piece.cells) {
    landed[place.row + cell.row][place.column + cell.column] = piece.colour
  }
  return landed
}

function isFull(row: ['']) {
  for (const cell of row) {
    if (cell == '') {
      return false
    }
  }
  return true
}

function clearRows(board: [['']]) {
  const kept = board.filter((row) => !isFull(row))
  const cleared = board.length - kept.length
  const fresh = emptyBoard(cleared, board[0].length)
  return { board: [...fresh, ...kept], cleared }
}

function points(cleared: 0) {
  return [0, 100, 300, 500, 800][cleared]
}

// New in this step:

Type Game {
  description: 'everything the game remembers'
  example: {
    board: [['']],
    piece: { name: 'T', colour: 'purple', size: 3, cells: [{ row: 0, column: 0 }] },
    place: { row: 0, column: 0 },
    score: 0,
    over: false,
  }
}

function startPlace(board: [['']], piece: Piece) {
  return { row: 0, column: Math.floor((board[0].length - piece.size) / 2) }
}

function newGame(piece: Piece) {
  const board = emptyBoard(20, 10)
  return { board, piece, place: startPlace(board, piece), score: 0, over: false }
}

function move(game: Game, down: 0, across: 0) {
  const place = { row: game.place.row + down, column: game.place.column + across }
  if (fits(game.board, game.piece, place)) {
    game.place = place
    return true
  }
  return false
}

function fall(game: Game, next: Piece) {
  if (game.over) {
    return
  }
  if (move(game, 1, 0)) {
    return // it fell one row, and that is all
  }
  const result = clearRows(land(game.board, game.piece, game.place))
  game.board = result.board
  game.score = game.score + points(result.cleared)
  game.piece = next
  game.place = startPlace(game.board, next)
  if (!fits(game.board, next, game.place)) {
    game.over = true
  }
}

function steer(game: Game, key: 'ArrowLeft') {
  if (game.over) {
    return
  }
  given key {
    'ArrowLeft' { move(game, 0, -1) }
    'ArrowRight' { move(game, 0, 1) }
    'ArrowDown' { move(game, 1, 0) }
    'ArrowUp' { game.piece = turn(game.board, game.piece, game.place) }
  }
}

// A few moves, played by the program instead of a person:
const game = newGame(makePiece('T'))
steer(game, 'ArrowUp')
steer(game, 'ArrowLeft')
steer(game, 'ArrowLeft')
for (let tick = 0; tick < 40; tick++) {
  fall(game, makePiece('L'))
}

preview.append(
  div(`Score: ${game.score}. Over: ${game.over}.`),
  drawGame(game.board, game.piece, game.place)
)

test 'a new game puts the piece at the top, in the middle' {
  const game = newGame(makePiece('T'))
  expect(game.place).toEqual({ row: 0, column: 3 })
  expect(newGame(makePiece('O')).place).toEqual({ row: 0, column: 4 })
  expect(game.score).toBe(0)
  expect(game.over).toBe(false)
}

test 'a tick moves the piece down one row' {
  const game = newGame(makePiece('T'))
  fall(game, makePiece('O'))
  expect(game.place).toEqual({ row: 1, column: 3 })
  expect(game.piece.name).toBe('T')
}

test 'a piece that cannot fall lands, and the next one appears' {
  const game = newGame(makePiece('O'))
  game.place = { row: 18, column: 0 }
  fall(game, makePiece('T'))
  expect(game.board[19][0]).toBe('gold')
  expect(game.piece.name).toBe('T')
  expect(game.place).toEqual({ row: 0, column: 3 })
}

test 'landing that fills a row scores' {
  const game = newGame(makePiece('I'))
  for (let column = 0; column < 6; column++) {
    game.board[19][column] = 'blue'
  }
  game.place = { row: 18, column: 6 } // the I lies in row 1 of its grid: row 19
  fall(game, makePiece('O'))
  expect(game.score).toBe(100)
  expect(game.board[19]).toEqual(['', '', '', '', '', '', '', '', '', ''])
}

test 'the keys' {
  const game = newGame(makePiece('T'))
  steer(game, 'ArrowLeft')
  expect(game.place.column).toBe(2)
  steer(game, 'ArrowRight')
  steer(game, 'ArrowRight')
  expect(game.place.column).toBe(4)
  steer(game, 'ArrowDown')
  expect(game.place.row).toBe(1)
  steer(game, 'ArrowUp')
  expect(game.piece.cells).toEqual(rotate(makePiece('T')).cells)
  steer(game, 'Enter')
  expect(game.place).toEqual({ row: 1, column: 4 })
}

test 'a wall stops the piece' {
  const game = newGame(makePiece('T'))
  for (let presses = 0; presses < 10; presses++) {
    steer(game, 'ArrowLeft')
  }
  expect(game.place.column).toBe(0)
}

test 'the game is over when a new piece has no room' {
  const game = newGame(makePiece('O'))
  game.board[1][4] = 'red'
  game.place = { row: 18, column: 0 }
  fall(game, makePiece('O'))
  expect(game.over).toBe(true)
  fall(game, makePiece('O'))
  steer(game, 'ArrowLeft')
  expect(game.place).toEqual({ row: 0, column: 4 })
}

test 'a game with a missing part is refused' {
  expect(fall({ board: [['']], score: 0, over: false }, makePiece('O')) instanceof Error).toBe(true)
}

The key names, 'ArrowLeft' and the rest, are what the browser calls the arrow keys. They are plain strings here, so the tests can press keys without a keyboard. The next step connects them to a real one.

The preview shows a short game played by the program: the T turned and moved two columns left, then forty ticks of the clock. The T falls to the floor and lands. An L appears at the top, falls, and lands on the T's right-hand corner, leaving a gap under it that nothing can reach now, and the next L is on its way down. Change the moves, or the pieces, and see where they end up. Notice that the program played a game with no clock and no keys, in a handful of lines. That is what keeping the rules apart from the wiring buys.

The game loop

Now the game can be played. Everything new here is wiring, and each piece of it comes from Time and Events:

import { elements, svgElements, tosi } from 'tosijs'

const { div, button, strong } = elements
const { svg, rect } = svgElements

function emptyBoard(rows: 20, columns: 10) {
  const board = []
  for (let row = 0; row < rows; row++) {
    const cells = []
    for (let column = 0; column < columns; column++) {
      cells.push('')
    }
    board.push(cells)
  }
  return board
}

const squareSize = 16 // pixels across each square

function colourOf(cell: '') {
  return cell == '' ? 'gainsboro' : cell
}

function square(row: 0, column: 0, colour: 'red') {
  return rect({
    x: column * squareSize,
    y: row * squareSize,
    width: squareSize,
    height: squareSize,
    fill: colour,
    stroke: 'white',
  })
}

function drawBoard(board: [['']]) {
  const drawing = svg({
    width: board[0].length * squareSize,
    height: board.length * squareSize,
  })
  for (let row = 0; row < board.length; row++) {
    for (let column = 0; column < board[row].length; column++) {
      drawing.append(square(row, column, colourOf(board[row][column])))
    }
  }
  return drawing
}

const shapes = {
  I: { colour: 'cyan', grid: ['....', 'XXXX', '....', '....'] },
  O: { colour: 'gold', grid: ['XX', 'XX'] },
  T: { colour: 'purple', grid: ['.X.', 'XXX', '...'] },
  S: { colour: 'green', grid: ['.XX', 'XX.', '...'] },
  Z: { colour: 'red', grid: ['XX.', '.XX', '...'] },
  J: { colour: 'blue', grid: ['X..', 'XXX', '...'] },
  L: { colour: 'orange', grid: ['..X', 'XXX', '...'] },
}

Type Piece {
  description: 'a shape: its colour, the size of its square grid, and its squares'
  example: { name: 'T', colour: 'purple', size: 3, cells: [{ row: 0, column: 0 }] }
}

Type Place {
  description: 'a row and a column on the board'
  example: { row: 0, column: 0 }
}

function cellsOf(grid: ['']) {
  const cells = []
  for (let row = 0; row < grid.length; row++) {
    for (let column = 0; column < grid[row].length; column++) {
      if (grid[row][column] == 'X') {
        cells.push({ row, column })
      }
    }
  }
  return cells
}

function makePiece(name: 'T') {
  const shape = shapes[name]
  return {
    name,
    colour: shape.colour,
    size: shape.grid.length,
    cells: cellsOf(shape.grid),
  }
}

function drawGame(board: [['']], piece: Piece, place: Place) {
  const drawing = drawBoard(board)
  for (const cell of piece.cells) {
    drawing.append(
      square(place.row + cell.row, place.column + cell.column, piece.colour)
    )
  }
  return drawing
}

function fits(board: [['']], piece: Piece, place: Place) {
  for (const cell of piece.cells) {
    const row = place.row + cell.row
    const column = place.column + cell.column
    if (row < 0 || row >= board.length) {
      return false // above the top, or below the floor
    }
    if (column < 0 || column >= board[0].length) {
      return false // through a wall
    }
    if (board[row][column] != '') {
      return false // the square is already filled
    }
  }
  return true
}

function rotate(piece: Piece) {
  const cells = []
  for (const cell of piece.cells) {
    cells.push({ row: cell.column, column: piece.size - 1 - cell.row })
  }
  return { name: piece.name, colour: piece.colour, size: piece.size, cells }
}

function turn(board: [['']], piece: Piece, place: Place) {
  const turned = rotate(piece)
  return fits(board, turned, place) ? turned : piece
}

function land(board: [['']], piece: Piece, place: Place) {
  const landed = board.map((row) => [...row])
  for (const cell of piece.cells) {
    landed[place.row + cell.row][place.column + cell.column] = piece.colour
  }
  return landed
}

function isFull(row: ['']) {
  for (const cell of row) {
    if (cell == '') {
      return false
    }
  }
  return true
}

function clearRows(board: [['']]) {
  const kept = board.filter((row) => !isFull(row))
  const cleared = board.length - kept.length
  const fresh = emptyBoard(cleared, board[0].length)
  return { board: [...fresh, ...kept], cleared }
}

function points(cleared: 0) {
  return [0, 100, 300, 500, 800][cleared]
}

Type Game {
  description: 'everything the game remembers'
  example: {
    board: [['']],
    piece: { name: 'T', colour: 'purple', size: 3, cells: [{ row: 0, column: 0 }] },
    place: { row: 0, column: 0 },
    score: 0,
    over: false,
  }
}

function startPlace(board: [['']], piece: Piece) {
  return { row: 0, column: Math.floor((board[0].length - piece.size) / 2) }
}

function newGame(piece: Piece) {
  const board = emptyBoard(20, 10)
  return { board, piece, place: startPlace(board, piece), score: 0, over: false }
}

function move(game: Game, down: 0, across: 0) {
  const place = { row: game.place.row + down, column: game.place.column + across }
  if (fits(game.board, game.piece, place)) {
    game.place = place
    return true
  }
  return false
}

function fall(game: Game, next: Piece) {
  if (game.over) {
    return
  }
  if (move(game, 1, 0)) {
    return // it fell one row, and that is all
  }
  const result = clearRows(land(game.board, game.piece, game.place))
  game.board = result.board
  game.score = game.score + points(result.cleared)
  game.piece = next
  game.place = startPlace(game.board, next)
  if (!fits(game.board, next, game.place)) {
    game.over = true
  }
}

function steer(game: Game, key: 'ArrowLeft') {
  if (game.over) {
    return
  }
  given key {
    'ArrowLeft' { move(game, 0, -1) }
    'ArrowRight' { move(game, 0, 1) }
    'ArrowDown' { move(game, 1, 0) }
    'ArrowUp' { game.piece = turn(game.board, game.piece, game.place) }
  }
}

// New in this step:

function randomPiece() {
  const names = Object.keys(shapes)
  return makePiece(names[Math.floor(Math.random() * names.length)])
}

const { status } = tosi({ status: { score: 0, message: 'Press Start to play.' } })

let game = newGame(randomPiece())
let timer = null

const screen = div()
screen.append(drawGame(game.board, game.piece, game.place))

function show() {
  screen.replaceChildren(drawGame(game.board, game.piece, game.place))
  status.score = game.score
}

const gameKeys = ['ArrowLeft', 'ArrowRight', 'ArrowDown', 'ArrowUp']

function onKey(event) {
  if (gameKeys.includes(event.key)) {
    event.preventDefault()
    steer(game, event.key)
    show()
  }
}

function stop(message: '') {
  clearInterval(timer)
  timer = null
  document.removeEventListener('keydown', onKey)
  status.message = message
}

function onTick() {
  fall(game, randomPiece())
  show()
  if (game.over) {
    stop('Game over. Press Start to play again.')
  }
}

function start() {
  if (timer != null) {
    return // already running
  }
  if (game.over) {
    game = newGame(randomPiece())
    show()
  }
  status.message = 'Left and right move, up turns, down drops faster.'
  timer = setInterval(onTick, 500)
  document.addEventListener('keydown', onKey)
}

function pause() {
  if (timer != null) {
    stop('Paused. Press Start to carry on.')
  }
}

preview.append(
  div('Score: ', strong(status.score), '. ', status.message),
  div(button({ onClick: start }, 'Start'), ' ', button({ onClick: pause }, 'Pause')),
  screen
)

test 'a random piece is one of the seven' {
  for (let tries = 0; tries < 20; tries++) {
    const piece = randomPiece()
    expect(Object.keys(shapes)).toContain(piece.name)
    expect(piece.cells.length).toBe(4)
  }
}

test 'the rules still hold' {
  const game = newGame(makePiece('I'))
  for (let column = 0; column < 6; column++) {
    game.board[19][column] = 'blue'
  }
  steer(game, 'ArrowRight')
  steer(game, 'ArrowRight')
  steer(game, 'ArrowRight')
  for (let tick = 0; tick < 19; tick++) {
    fall(game, makePiece('O'))
  }
  expect(game.score).toBe(100)
  expect(game.piece.name).toBe('O')
  expect(game.over).toBe(false)
}

Press Start, then use the arrow keys. Each piece falls, lands and is replaced; fill a row and watch it go, and the score go up.

Every function in this step is a few lines, and none of them contains a rule of the game. The test 'the rules still hold' plays the first piece of a game to the end with no clock and no keys, so if the game ever misbehaves, start by asking whether the rules or the wiring are to blame: a test can answer the first question in a moment.

Two things to know while you play. While the game runs, the arrow keys belong to it, all over the page, even in the code editor; press Pause before you change the code. And if you change the example while a game is running, the old game's clock and keys carry on, as the countdown's timer did in Things That Take Time. Pause first, or reload the page.

Try it

Work at the last example, the whole game. For each change, write the rule as a function with a test first, as in A Calculator, then wire it in.

Work them here:

// Your code here. Show results with preview.append(…), check them with a
// test block, then press the refresh button (or ⌘S / Ctrl+S) to run it.