DEV Community

Cover image for A browser arcade in plain TypeScript: 8 classic games, 35 kB, no framework
Minjia Hu
Minjia Hu

Posted on

A browser arcade in plain TypeScript: 8 classic games, 35 kB, no framework

Play it: https://minjia-hu.github.io/retro-arcade/
Source: https://github.com/Minjia-Hu/retro-arcade

Retro Arcade: eight classic games

A retro-styled collection of browser games. Open the page and play, no install, no sign-up. Snake, Tetris, Breakout, Flappy, 2048, Minesweeper, Sudoku and Gomoku share one arcade-cabinet UI and work with both keyboard and touch.

Beyond the basic rules, each game got the things that make you play one more round:

  • Tetris: hold, hard drop, on-screen buttons
  • 2048: single-step undo
  • Sudoku: pencil notes, auto-save
  • Gomoku: two-player mode and three AI levels

Tetris in play

The numbers: plain TypeScript and Canvas 2D, zero runtime dependencies, about 35 kB gzipped for the whole build (fonts excluded); 269 unit tests and 28 Playwright end-to-end tests, all in CI; MIT licensed.

Here are the parts of the implementation I think are worth writing down.

One cabinet, eight games

The eight games share one shell: the top bar, the screen well, the result overlay, the key-hint bar. A game implements a single interface and receives a GameContext with everything it is allowed to use:

export interface GameContext {
  audio: AudioFx;          // WebAudio-synthesised sound effects
  storage: ArcadeStorage;  // localStorage wrapper
  input: InputService;     // keyboard + touch gestures
  overlay(view: OverlayView | null): void;  // result card / start menu
  head: HTMLElement | null;  // DOM slot above the screen
  side: HTMLElement | null;  // side panel slot
  pad: HTMLElement | null;   // touch-pad slot
  setHints(hints: string[]): void;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

To show a result card, a game makes one call and the shell draws it:

ctx.overlay({
  title: 'GAME OVER',
  tone: 'lose',
  lines: ['SCORE 000420', 'BEST 001330'],
  actions: [{ label: '▶ RETRY', onPress: retry }],
  hints: ['SPACE / TAP TO RETRY'],
});
Enter fullscreen mode Exit fullscreen mode

The payoff: result cards, pause, mute and back-to-hub look and behave the same across all eight games, while each game's code only deals with its own rules. Adding a ninth game does not touch the shell.

Rules and rendering never mix

Each game is built around logic.ts and index.ts (Gomoku also has an AI module and a Worker). logic.ts is state and rules only: no DOM, no Canvas, and the random source is passed in as a parameter. index.ts draws the state and turns input into rule calls.

In 2048, merging one row is an ordinary function:

const SIZE = 4;

export function slideLine(line: number[]): { line: number[]; gained: number } {
  const tiles = line.filter((v) => v !== 0);
  const out: number[] = [];
  let gained = 0;
  for (let i = 0; i < tiles.length; i++) {
    if (i + 1 < tiles.length && tiles[i] === tiles[i + 1]) {
      out.push(tiles[i] * 2);
      gained += tiles[i] * 2;
      i++; // skip the tile that was merged in
    } else {
      out.push(tiles[i]);
    }
  }
  while (out.length < SIZE) out.push(0);
  return { line: out, gained };
}
Enter fullscreen mode Exit fullscreen mode

Its tests assert on input and output and run in Node, no browser needed:

it('slideLine compresses and merges: [0,2,0,2] → [4,0,0,0] scores 4', () => {
  expect(slideLine([0, 2, 0, 2])).toEqual({ line: [4, 0, 0, 0], gained: 4 });
});
Enter fullscreen mode Exit fullscreen mode

This split earned its keep halfway through the project. There was a full visual redesign, from dark neon to the current warm paper look. It touched the rendering code, the shared shell and the stylesheet; the eight logic.ts files and their tests did not change by a line. One command confirms it:

git diff --stat <base> -- 'src/games/*/logic.ts' 'tests/*-logic.test.ts'   # must be empty
Enter fullscreen mode Exit fullscreen mode

Boards on Canvas, controls in the DOM

Grids, blocks and sprites are drawn, and the boards themselves take clicks on the Canvas. But the supporting controls, the number pad, the difficulty menu, the undo key, are real <button> elements. The reason is practical: real buttons come with focus rings, keyboard access and touch targets that are easy to size. Buttons painted on a Canvas need all of that written by hand, and it never comes out as well.

The shell offers three DOM slots (above the screen, beside it, and a touch pad below). A game declares which ones it needs and the shell hands over the containers.

The Gomoku AI runs in a Web Worker

The Gomoku AI is minimax with alpha-beta pruning; the three difficulty levels are search depths 1, 2 and 4. Depth 4 on the main thread freezes the UI, so the search runs in a Worker:

worker = new Worker(new URL('./ai.worker.ts', import.meta.url), { type: 'module' });
worker.postMessage({
  board: game.board.slice(),
  player: game.turn,
  depth: aiLevel.depth,
  token,
});
Enter fullscreen mode Exit fullscreen mode

Moving the search off-thread leaves one problem: the player can restart the game while the AI is thinking, and the old task's answer must not land on the new board. Every restart increments token; the Worker's reply carries the token it was sent with, and the main thread checks it against the current game before using the move. This discards stale results, it does not cancel the old search. Keeping the Worker and ignoring stale replies avoids cancel-and-recreate logic; the cost is that the old search still burns CPU and can delay the next request.

Size and first load

No UI framework and no game engine means little runtime code to ship: the whole build is about 35 kB gzipped, fonts excluded. First load is a separate matter: the eight games load on demand through dynamic import(). The hub loads only the shell and the router, and a game's module is fetched when you open it.

load: async () => (await import('./snake')).createSnake(),
Enter fullscreen mode Exit fullscreen mode

These are two different things: code splitting mostly reduces the first load, it does not by itself make the total build smaller.

The only third-party requests are for Google Fonts. There is no backend; scores live in localStorage.

On working with AI

The architecture and most of the code were written together with Claude Code. Every round of work starts with a short design note recording scope, trade-offs and where the result departs from the mockups; those live in docs/design/ in the repo. The lessons learned the hard way are collected in CLAUDE.md, for example "a CSS inset shadow cannot cover a canvas" and "never auto-focus the result overlay's button, or one Space press triggers both the button and the game's own restart". They say more about how the project grew than the code does.

Known issues and what I would like to hear

The controls could be easier to understand. Tetris's six touch buttons are icons only (⟳ rotate, ⇄ hold and so on), so a first-time player has to guess; hold can be used once per drop, and pressing it a second time does nothing without the UI saying why. Improving these two hints is next on my list.

The mobile layout and feel are still being tuned. I would especially like to hear about anything that is hard to tap or easy to mis-tap, and which game you think is the most fun and which needs the most work. Comments here or a GitHub issue both work.

Top comments (0)