Game State & Logic

In a standard programming language, game state is stored in objects, structs, or global variables. In Tetris-SQL, the entire state of the game must be passed sequentially from one row to the next via the columns of the recursive CTE.

The State Vector (Columns)

Every frame of the game is represented by a single row containing these core columns:

  • frame: Integer, current frame ID.
  • board: A 1D boolean array tracking filled (true) and empty (false) cells.
  • score / lines: Integers tracking player progress.
  • drop_delta: Numeric, represents current gravity (time allowed before a piece naturally falls).
  • pos: An integer array [piece_id, rotation_id, offset_cells, status] tracking the active piece.
  • max_drop_lines: Integer, calculates where the ghost piece lands and handles game-over detection.
  • next_piece: Integer, the ID of the upcoming tetromino for the preview box.
  • last_drop_time / last_input_time: Timestamps used for debouncing input and applying gravity.

The 1D Board Array Architecture

You might assume a Tetris board would be best represented by a 2D array (e.g., boolean[][]). However, PostgreSQL's handling of 2D arrays is notoriously cumbersome, especially when it comes to unnesting, slicing, and index manipulation.

Tetris-SQL uses a flat 1-Dimensional array.

To represent a standard 10x20 Tetris grid, the array is actually sized to 11x20 (width + 1). The 11th cell of every row is permanently forced to true (filled).

Why Add a Permanent Wall?

Having an invisible, permanently filled column on the far right dramatically simplifies collision detection on the edges of the board. Without it, moving a piece off the right edge of the board in a 1D array would wrap it around to the left side of the next row down!

Player Sees:             Database Memory (1D Array Layout):
|          |             |          []|
|    [][]  |             |    [][]  []|
|  [][][][]|             |  [][][][][]|
+----------+             +------------+
(Width: 10)              (Width: 11)

Line Clearing Logic

When a piece locks into place, the query must check for completed lines. With a 1D array, this is handled elegantly using PostgreSQL array operators:

  1. The 11x20 array is split into 20 individual chunks (rows).
  2. A WHERE clause filters out any row that doesn't contain at least one false value using the "contained by" operator: WHERE NOT line <@ ARRAY[true].
  3. The remaining (non-cleared) rows are aggregated back together.
  4. Empty rows (false) are prepended to the top of the array to maintain the 20-row height.

Collision Detection

Tetris pieces (Tetrominoes) are defined in a static CTE as arrays of integer offsets relative to the top-left index of the board.

When a player inputs a move (e.g., Right), the query calculates the theoretical new piece offsets via a LATERAL join. It then checks for collisions against the existing board using this logic:

-- Check if the new piece collides with any filled cell in the board
collision(collides) AS (
    SELECT bool_or(cell) AS collides
    FROM unnest(main.board) WITH ORDINALITY b(cell, ordinality)
    JOIN unnest((SELECT new_piece FROM piece_after_movement)) p(coord)
        ON p.coord + 1 = b.ordinality
)
If collides is true, the move is invalid. If the invalid move was a horizontal shift or rotation, the piece stays in its old position. If the invalid move was a downward drop, the piece locks into the board array, and the next_piece is spawned.

Nintendo (NES) Randomizer Implementation

A truly random Tetris generator is notorious for giving players frustrating "droughts" (e.g., 20 pieces without a straight line).

Tetris-SQL mitigates this by implementing a bias algorithm heavily inspired by the original NES Tetris:

  1. Generate a random piece ID (Roll 1).
  2. If Roll 1 equals the currently falling piece, discard it and generate a new random piece ID (Roll 2).
  3. Accept Roll 2, even if it is a duplicate.

In SQL, this is elegantly handled using an ORDER BY rank limit:

SELECT id
FROM (
    -- Roll 1 (Rank 0): Discard if matches current piece
    SELECT id, 0 AS rank FROM tetromino ORDER BY random() + main.frame LIMIT 1
    WHERE id != movement.pos[1]

    UNION ALL

    -- Roll 2 (Rank 1): Fallback
    SELECT id, 1 AS rank FROM tetromino ORDER BY random() + main.frame LIMIT 1
)
ORDER BY rank LIMIT 1
(Note: main.frame is added to the random() seed to ensure the query optimizer doesn't cache the random result.)