Skip to content
mdr
All work
prototype2025·personal

3D Tetris

Classic Tetris, wrapped around a cylinder. Five game modes, ring-clear algorithm, accessible from keyboard alone.

Screenshot of 3D Tetris

TL;DR

  • Cylindrical grid coordinate system.
  • Instanced rendering for 500+ blocks at 60fps.
  • Five game-loop variants on one engine.
  • Keyboard-only playable; SR-described scene.
  • Atomic React architecture (atoms / molecules / organisms).

Architecture

Flow
INPUTENGINEOUTPUTKeyboardWASD + arrowsTouchmobileCore enginepure functionsGame-loop modes5 strategiesCylindrical gridring coordinatesRendererinstanced 3Dthree.jsA11y layerSR descriptionTest suitepure-logic

Pure game logic stays separate from rendering. Same engine, five game loops.

sync · HTTP / RPCdata · read / writeasync · eventauth · permission

Problem

Classic Tetris is a solved game. Wrapping it around a cylinder breaks every assumption — pieces wrap around the edges, the camera never sees the whole board at once, and 'clear a row' becomes 'clear a ring'. The hard part isn't the rendering, it's keeping the game readable.

Approach

Built a cylindrical grid coordinate system with a ring-clear algorithm. Rendered with instanced Three.js geometry so 500+ blocks can animate at 60fps. Five game-loop variants (Survival, Daily, Mission, Combo, Risk/Reward) share one core engine. Accessible from keyboard alone with an SR-described 3D scene.

Deep dive

Cylindrical coordinates

Cells are addressed by (ring, slot, height). Movement left/right wraps modulo the ring slot count. The win condition for a 'line clear' is when every slot in a single (height, ring) pair is filled — a ring clear. This sounds tiny, but it changes pacing: there are always exits.

tslib/grid.ts — ring-clear in five lines
export const SLOTS_PER_RING = 12;

export const wrap = (slot: number) =>
  ((slot % SLOTS_PER_RING) + SLOTS_PER_RING) % SLOTS_PER_RING;

export function ringIsFull(grid: Grid, height: number) {
  for (let slot = 0; slot < SLOTS_PER_RING; slot++) {
    if (!grid.cell(slot, height)) return false;
  }
  return true;
}

export function clearFullRings(grid: Grid): number {
  let cleared = 0;
  for (let h = 0; h < grid.height; h++) {
    if (ringIsFull(grid, h)) {
      grid.collapseRing(h);
      cleared++;
      h--;
    }
  }
  return cleared;
}

Instanced rendering

Each cube isn't a mesh; it's an instance of one mesh with a per-instance transform matrix. Adding or removing a block updates one entry in a buffer. With 500+ blocks visible on a busy board, this is the difference between 60fps and 12fps on a Pixel 6.

Five modes, one engine

Each game-loop variant is a small strategy object: 'how do we score?', 'when do we end?', 'do power-ups exist?'. The engine doesn't know which mode is active. This kept the surface area small enough to keep iterating on game feel without a refactor.

Outcome

Prototype with extensive design docs. Demonstrates that 3D-native gameplay loops can stay accessible if you design the input model first.

Related

More in games.