Tetris, as one machine definition

The whole game is a single machine: gravity is a named timer owned by the Falling state, steering is a stay update that leaves it running, and every lock, clear, and top-out decision is a named guard. The embedded Studio is attached to the same handle — play from the keyboard or drive the machine from Studio's quick events.

← → move · ↑ rotate · ↓ soft drop · space hard drop · P pause

Booting the tetris machine…

the codetetris-machine.ts — the whole game is this one definition
/**
 * Tetris as one machine definition. Gravity is a named state-derived timer owned
 * by the Falling state (pausing cancels it, `stay` steering updates leave it
 * running), every input is a machine event, and lock/clear/top-out decisions
 * are named guards visible in the inspection stream and Studio.
 */
import { Schema } from "effect"
import { Machine } from "effect-state-machine"

// ---------------------------------------------------------------------------
// Pure Tetris logic
// ---------------------------------------------------------------------------

export const WIDTH = 10
export const HEIGHT = 20

export interface PieceData {
  readonly kind: number
  readonly rotation: number
  readonly x: number
  readonly y: number
}

export type BoardData = ReadonlyArray<ReadonlyArray<number>>

const BASE: ReadonlyArray<{
  readonly size: number
  readonly cells: ReadonlyArray<readonly [number, number]>
}> = [
  {
    size: 4,
    cells: [
      [1, 0],
      [1, 1],
      [1, 2],
      [1, 3],
    ],
  }, // I
  {
    size: 3,
    cells: [
      [0, 0],
      [1, 0],
      [1, 1],
      [1, 2],
    ],
  }, // J
  {
    size: 3,
    cells: [
      [0, 2],
      [1, 0],
      [1, 1],
      [1, 2],
    ],
  }, // L
  {
    size: 2,
    cells: [
      [0, 0],
      [0, 1],
      [1, 0],
      [1, 1],
    ],
  }, // O
  {
    size: 3,
    cells: [
      [0, 1],
      [0, 2],
      [1, 0],
      [1, 1],
    ],
  }, // S
  {
    size: 3,
    cells: [
      [0, 1],
      [1, 0],
      [1, 1],
      [1, 2],
    ],
  }, // T
  {
    size: 3,
    cells: [
      [0, 0],
      [0, 1],
      [1, 1],
      [1, 2],
    ],
  }, // Z
]

export const ROTATIONS: ReadonlyArray<ReadonlyArray<ReadonlyArray<readonly [number, number]>>> =
  BASE.map(({ size, cells }) => {
    const rotations: Array<ReadonlyArray<readonly [number, number]>> = [cells]
    for (let step = 0; step < 3; step += 1) {
      const previous = rotations[rotations.length - 1]
      rotations.push(previous.map(([row, column]) => [column, size - 1 - row]))
    }
    return rotations
  })

export const cellsOf = (piece: PieceData): ReadonlyArray<readonly [number, number]> =>
  ROTATIONS[piece.kind][piece.rotation & 3].map(([row, column]) => [
    piece.y + row,
    piece.x + column,
  ])

export const collides = (board: BoardData, piece: PieceData): boolean =>
  cellsOf(piece).some(
    ([row, column]) =>
      column < 0 || column >= WIDTH || row >= HEIGHT || (row >= 0 && board[row][column] !== 0),
  )

const mergePiece = (board: BoardData, piece: PieceData): BoardData => {
  const merged = board.map((row) => [...row])
  for (const [row, column] of cellsOf(piece)) {
    if (row >= 0) merged[row][column] = piece.kind + 1
  }
  return merged
}

const fullRows = (board: BoardData): ReadonlyArray<number> =>
  board.flatMap((row, index) => (row.every((cell) => cell !== 0) ? [index] : []))

const collapse = (board: BoardData, rows: ReadonlyArray<number>): BoardData => {
  const kept = board.filter((_, index) => !rows.includes(index))
  const empty = Array.from({ length: rows.length }, () => Array.from({ length: WIDTH }, () => 0))
  return [...empty, ...kept]
}

const spawn = (kind: number): PieceData => ({ kind, rotation: 0, x: 3, y: kind === 0 ? -1 : 0 })

export const down = (piece: PieceData): PieceData => ({ ...piece, y: piece.y + 1 })

export const dropped = (board: BoardData, piece: PieceData): PieceData => {
  let candidate = piece
  while (!collides(board, down(candidate))) candidate = down(candidate)
  return candidate
}

// Plain LCG keeps the piece stream a pure function of the seed; a 7-bag
// randomizer would reduce streaks but needs no library features this demo shows.
const nextSeed = (seed: number): number => (Math.imul(seed, 1664525) + 1013904223) >>> 0
const roll = (seed: number): readonly [number, number] => {
  const advanced = nextSeed(seed)
  return [(advanced >>> 16) % 7, advanced]
}

const LINE_SCORES = [0, 100, 300, 500, 800]
const gravityMs = (level: number): number => Math.max(90, 750 - (level - 1) * 60)

const EMPTY_BOARD: BoardData = Array.from({ length: HEIGHT }, () =>
  Array.from({ length: WIDTH }, () => 0),
)

// ---------------------------------------------------------------------------
// Machine definition
// ---------------------------------------------------------------------------

const Piece = Schema.Struct({
  kind: Schema.Number,
  rotation: Schema.Number,
  x: Schema.Number,
  y: Schema.Number,
})
const Board = Schema.Array(Schema.Array(Schema.Number))

const progressFields = {
  board: Board,
  queue: Schema.Array(Schema.Number),
  score: Schema.Number,
  lines: Schema.Number,
  level: Schema.Number,
  seed: Schema.Number,
}

const Input = Schema.Struct({ seed: Schema.Number }).annotate({
  description: "Seed for the pure piece generator; the same seed replays the same game.",
})

const State = Machine.taggedUnion({
  Falling: {
    fields: { ...progressFields, piece: Piece },
    description: "One piece is falling. A named state-derived timer owns the gravity interval.",
  },
  Clearing: {
    fields: { ...progressFields, cleared: Schema.Array(Schema.Number) },
    description: "Hold completed rows on screen briefly before collapsing them.",
  },
  Paused: {
    fields: { ...progressFields, piece: Piece },
    description: "Leaving Falling cancels its gravity timer; nothing ticks here.",
  },
  GameOver: {
    fields: { board: Board, score: Schema.Number, lines: Schema.Number, level: Schema.Number },
    description: "A locked piece blocked the spawn row. The machine is complete.",
  },
})

const Event = Machine.taggedUnion({
  MoveLeft: { fields: {}, description: "Shift the falling piece one column left." },
  MoveRight: { fields: {}, description: "Shift the falling piece one column right." },
  Rotate: { fields: {}, description: "Rotate clockwise, nudging off walls when needed." },
  SoftDrop: { fields: {}, description: "Descend one row without waiting for gravity." },
  HardDrop: { fields: {}, description: "Drop to the floor and lock immediately." },
  Pause: { fields: {}, description: "Suspend play; cancels the gravity timer." },
  Resume: { fields: {}, description: "Return to Falling; gravity starts on entry." },
})

type FallingState = {
  readonly board: BoardData
  readonly piece: PieceData
  readonly queue: ReadonlyArray<number>
  readonly score: number
  readonly lines: number
  readonly level: number
  readonly seed: number
}

type ProgressState = Omit<FallingState, "piece">

const spawnNext = (state: ProgressState, board: BoardData) => {
  const [kind, seed] = roll(state.seed)
  return {
    _tag: "Falling" as const,
    board,
    piece: spawn(state.queue[0]),
    queue: [...state.queue.slice(1), kind],
    score: state.score,
    lines: state.lines,
    level: state.level,
    seed,
  }
}

const toClearing = (state: FallingState, merged: BoardData, cleared: ReadonlyArray<number>) => {
  const lines = state.lines + cleared.length
  return {
    _tag: "Clearing" as const,
    board: merged,
    cleared,
    queue: state.queue,
    score: state.score + LINE_SCORES[cleared.length] * state.level,
    lines,
    level: 1 + Math.floor(lines / 10),
    seed: state.seed,
  }
}

const toGameOver = (state: ProgressState, board: BoardData) => ({
  _tag: "GameOver" as const,
  board,
  score: state.score,
  lines: state.lines,
  level: state.level,
})

/** The three lock outcomes, shared by the gravity tick and HardDrop. */
const lockBranches = (pieceOf: (state: FallingState) => PieceData) =>
  [
    {
      when: {
        name: "rows-completed",
        description: "Locking this piece completes at least one row.",
        guard: ({ state }: { readonly state: FallingState }) =>
          fullRows(mergePiece(state.board, pieceOf(state))).length > 0,
      },
      target: "Clearing" as const,
      reduce: ({ state }: { readonly state: FallingState }) => {
        const merged = mergePiece(state.board, pieceOf(state))
        return toClearing(state, merged, fullRows(merged))
      },
    },
    {
      when: {
        name: "spawn-blocked",
        description: "The next piece cannot enter the board.",
        guard: ({ state }: { readonly state: FallingState }) =>
          collides(mergePiece(state.board, pieceOf(state)), spawn(state.queue[0])),
      },
      target: "GameOver" as const,
      reduce: ({ state }: { readonly state: FallingState }) =>
        toGameOver(state, mergePiece(state.board, pieceOf(state))),
    },
    {
      otherwise: true as const,
      target: "Falling" as const,
      reduce: ({ state }: { readonly state: FallingState }) =>
        spawnNext(state, mergePiece(state.board, pieceOf(state))),
    },
  ] as const

const tetris = Machine.builder({ input: Input, state: State, event: Event })

export const definition = tetris.define(
  {
    id: "tetris",
    idempotencyKey: ({ seed }) => String(seed),
    description:
      "A complete Tetris game as one machine: gravity as a named timer, inputs as events, lock decisions as named guards.",
    initial: (input) => {
      let seed = input.seed >>> 0
      const kinds: Array<number> = []
      for (let index = 0; index < 4; index += 1) {
        const [kind, advanced] = roll(seed)
        kinds.push(kind)
        seed = advanced
      }
      return {
        _tag: "Falling",
        board: EMPTY_BOARD,
        piece: spawn(kinds[0]),
        queue: kinds.slice(1),
        score: 0,
        lines: 0,
        level: 1,
        seed,
      }
    },
  },
  {
    Falling: tetris.state(
      {
        // `stay` updates state data without exiting or re-entering the node,
        // so steering the piece never restarts the running gravity timer.
        MoveLeft: {
          stay: ({ state }) => {
            const moved = { ...state.piece, x: state.piece.x - 1 }
            return collides(state.board, moved) ? state : { ...state, piece: moved }
          },
        },
        MoveRight: {
          stay: ({ state }) => {
            const moved = { ...state.piece, x: state.piece.x + 1 }
            return collides(state.board, moved) ? state : { ...state, piece: moved }
          },
        },
        Rotate: {
          stay: ({ state }) => {
            const turned = { ...state.piece, rotation: (state.piece.rotation + 1) & 3 }
            for (const nudge of [0, -1, 1, -2, 2]) {
              const candidate = { ...turned, x: turned.x + nudge }
              if (!collides(state.board, candidate)) return { ...state, piece: candidate }
            }
            return state
          },
        },
        SoftDrop: {
          stay: ({ state }) =>
            collides(state.board, down(state.piece))
              ? state
              : { ...state, piece: down(state.piece) },
        },
        HardDrop: {
          branches: [...lockBranches((state) => dropped(state.board, state.piece))],
        },
        Pause: {
          target: "Paused",
          reduce: ({ state }) => ({ ...state, _tag: "Paused" as const }),
        },
      },
      {
        after: {
          duration: {
            name: "gravity-interval",
            description: "750ms minus 60ms per level, with a 90ms floor.",
            compute: (state) => gravityMs(state.level),
          },
          branches: [
            {
              when: {
                name: "piece-can-descend",
                description: "The row below the piece is free.",
                guard: ({ state }) => !collides(state.board, down(state.piece)),
              },
              target: "Falling",
              reduce: ({ state }) => ({ ...state, piece: down(state.piece) }),
            },
            ...lockBranches((state) => state.piece),
          ],
        },
      },
    ),
    Clearing: tetris.state(
      {},
      {
        after: {
          duration: "280 millis",
          description: "Hold the completed rows on screen before collapsing them.",
          branches: [
            {
              when: {
                name: "spawn-blocked",
                description: "Even after clearing, the next piece cannot enter.",
                guard: ({ state }) =>
                  collides(collapse(state.board, state.cleared), spawn(state.queue[0])),
              },
              target: "GameOver",
              reduce: ({ state }) => toGameOver(state, collapse(state.board, state.cleared)),
            },
            {
              otherwise: true,
              target: "Falling",
              reduce: ({ state }) => spawnNext(state, collapse(state.board, state.cleared)),
            },
          ],
        },
      },
    ),
    Paused: tetris.state({
      Resume: {
        target: "Falling",
        reduce: ({ state }) => ({ ...state, _tag: "Falling" as const }),
      },
    }),
    GameOver: tetris.final(),
  },
)