Case study: 2048

A terminal-aesthetic 2048 clone built in vanilla TypeScript β€” same visual language as rack, with smooth tile animations and no framework.

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:

  1. Direction normalization β€” four move directions, each with different merge logic, is tedious to write four times and easy to get wrong.
  2. 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.