Case study: 2048
Context
The goal was a 2048 that fit the terminal aesthetic already established by rack: dark background, monospace font, a colour ramp that communicates progress without being garish. The same constraint applied β no framework, no dependencies beyond Vite, fully static.
Problem
2048 looks simple but has two genuinely tricky implementation problems:
- Direction normalization β four move directions, each with different merge logic, is tedious to write four times and easy to get wrong.
- Tile animation β if you re-render the grid from scratch on every move, you canβt animate tiles sliding and merging. You need identity tracking across frames.
Approach
Direction normalization
All four directions reduce to a single slideLeft operation via two transforms: transpose (swap rows and columns) and flipH (reverse each row). Right is flipH β slideLeft β flipH. Up is transpose β slideLeft β transpose. Down is flipH then transpose β slideLeft β transpose then flipH. The merge positions returned by slideLeft are mapped back through the inverse transform so the UI knows where merges landed on the actual board.
Tile identity tracking
Every tile has a numeric ID. A Map<id, TileState> holds the DOM element and current position for each live tile. On each move, existing elements slide to their new positions via transform: translate() with a CSS transition. Merged tiles receive a tile-merge class that triggers a scale keyframe, then disappears. Newly spawned tiles get tile-appear β scale 0 β 1.12 β 1. The DOM elements persist; only their position and class change. No teardown, no re-mount.
Colour ramp
Grey at 2 (barely worth noting), amber by 8, gold at 32, orange at 128, pink at 512, purple at 1024, terminal green at 2048. The colour tells you where you are without reading the number.
Stack
Vanilla TypeScript, Vite, CSS custom properties. No runtime dependencies. Dockerized with a two-stage build (node:22-alpine β nginx:alpine). Gitea Actions CI/CD: build image on push to main, push to the Gitea container registry.