The constraint
Games are the canonical example of state that changes constantly, and the canonical implementation is a mutable grid updated in place. The brief here forbids that: no mutable collections, no reassignable variables. Every transition has to produce a new game state rather than edit the existing one.
That sounds like an obstacle and mostly turns out to be a simplification. A state transition that cannot touch anything outside itself is testable in isolation: feed it a board, check the board that comes back. There is no hidden state to set up first and none left behind afterwards.
Representing pieces
A tetromino is a set of points relative to an anchor, not a fixed pattern stamped into grid coordinates. Position is the anchor; shape is the offsets. That separation is what makes rotation tractable, because rotating becomes a transformation of the offsets around the anchor, independent of where the piece sits on the board.
Rotation, and why it is the hard part
Five of the seven tetrominoes — J, L, S, T and Z — rotate cleanly: 90-degree pivots about a centre block that genuinely exists in the piece. The other two do not, and they are why naive Tetris implementations feel subtly wrong:
- The O piece is a 2×2 square. It has no centre block and rotating it changes nothing, so its rotation must be an explicit no-op rather than a transformation that happens to land back where it started.
- The I piece is four in a row. Its centre of rotation falls between cells, not on one, so the standard pivot pushes it off by a cell in one direction or the other.
Anchor-relative representation is what resolves this. Because the anchor is a property of the piece rather than a fixed grid cell, the I and O pieces can define anchors that make the same rotation formula produce correct results for them too — instead of special-casing them inside the rotation code.
Structurally this is an abstract tetromino type with subclasses supplying the three distinct rotation behaviours. It is a deliberately object-oriented seam inside an otherwise functional design, and it is the right one: the variation is in behaviour per piece type, which is exactly what subtyping expresses well.
Collision and hard drop
Collision detection is a predicate over the candidate piece's occupied cells: does any of
them fall outside the board or onto a filled cell? Expressed with exists
over the offsets, it is a single expression, and every movement operation — left, right,
rotate, tick down — becomes "compute the candidate, accept it if it is legal, otherwise
keep the current state".
Hard drop reuses that same predicate: find the furthest downward translation for which the piece is still legal, and move there in one step. No loop with a mutable counter — just a search over candidate positions filtered by the collision predicate.
Why it was worth doing
Writing it this way forces every rule of the game to be stated explicitly as a function, rather than emerging from the order in which a loop happens to mutate things. Line clearing, collision, rotation and scoring each become a transformation you can point at, test and reason about on its own.