Building an Arcade from Scratch

┌───────────┐ │ ENGINE │ │ renderer │ │ registry │ │ input/hud │ └─────┬─────┘ │ ┌─────┴─────┐ │ GAMES │ ├───────────┤ │ peek │ │ tetris │ │ battlezone│ │ hustle │ │ racing │ │ signal │ │ ... │ └───────────┘

What happens when you start with a single Three.js scene and keep saying yes? You end up with 12 games, a physics engine, and a CRT terminal — all running in the same WebGL renderer.

The architecture evolved from a monolithic 1300-line main.js into a proper engine/game split. Each game registers itself with the engine via a simple call — declaring its scene, gyro config, HUD elements, and lifecycle hooks. No more hardcoded scene indices.

registerGame('SIGNAL', signalModule, { hudIds: ['signal-canvas-hud'], gyro: { rangeX: 20, rangeY: 20 }, });

The game module interface is intentionally minimal: export a scene, an update(t, dt, camera) function, and optional input state objects. The engine handles everything else — post-processing, scene switching, render modes, gyro routing.

The best architecture isn't the one you design upfront. It's the one that emerges from building 12 things and noticing the patterns.

Text as Gameplay

SIGNAL started as an atmospheric narrative — tilt to tune, read garbled transmissions. It felt cool but passive. The fix was making text itself the game mechanic:

FREQUENCY ├──────────╋──────────┤ SIGNAL 73% ▲ you ▲ target ▓░▒ outpost seven requesting ░▒▓ ▓░▒ status check anyone COPY ░▒▓ ^^^^ TAP TO DECODE

Key words flash briefly. You tap to lock them in. Miss the window, lose your combo. The frequency drifts constantly — you're tilting to track it while reading and tapping. It's three inputs competing for attention, which creates genuine tension from pure typography.

The decoded text saves to the in-game CRT monitor, so the narrative builds physically in the 3D space as you play. Text isn't just UI — it's the world.

0
3D models
100%
canvas text
11
transmissions
waves

Canvas HUDs: Killing the DOM Overlay

Every game started with HTML overlay HUDs — divs positioned over the WebGL canvas. They worked but had problems:

BEFORE AFTER ┌─────────────────┐ ┌─────────────────┐ │ <div id="hud"> │ │ │ │ <span>SCORE</>│ │ canvas.fillText│ │ <div>HP BAR</>│ → │ hud.text() │ │ <div>CENTER</>│ │ hud.bar() │ │ </div> │ │ hud.flash() │ │ ┌─────────────┐ │ │ │ │ │ WebGL canvas│ │ │ (single layer) │ │ └─────────────┘ │ │ │ └─────────────────┘ └─────────────────┘ two layers, reflow one layer, no reflow

The CanvasHUD system draws directly to a screen-space canvas. Pretext handles text measurement — calculating widths and line breaks without touching the DOM. The API is dead simple:

const hud = createHUD('game-hud'); hud.show(); // In your update loop: hud.clear(); hud.text(x, y, 'SCORE 1200', { size: 18, weight: 'bold', color: '#00ff44', shadow: '#00ff4455', shadowBlur: 10 }); hud.bar(x, y, width, height, fillRatio, { fg: '#00ffff', border: '#00ffff' }); hud.flash(x, y, '+500', { duration: 0.8 });

Nine games converted. Zero HTML HUD divs left (except for elements that need pointer events — fire buttons, crosshairs). The rendering pipeline is clean: one WebGL pass, one canvas overlay, done.


On Gyroscope as Game Input

GYRO PIPELINE ───────────── raw sensor │ ▼ calibrate (base angle) │ ▼ deadzone (±3° dead) │ ▼ normalize (±15° = 1.0) │ ▼ curve (cubic: x³) │ ▼ game input (-1 .. +1)

Phone gyro is the most underused game input on the web. Every game in this arcade uses it differently — steering, aiming, tuning, dodging. The key insight: the response curve matters more than the sensitivity.

A cubic curve (x³) gives ultra-precise control at small tilts and full speed at moderate tilts. Linear mapping feels twitchy. Quadratic is okay. Cubic is the sweet spot — same curve Battlezone, Racing, and Tank War all converged on independently.

The 3-degree deadzone is essential. Without it, the phone's sensor noise creates constant micro-drift. With it, holding the phone still means zero input. Simple but critical for feel.

The best mobile game controls are the ones you stop noticing. Gyro done right feels like the game is reading your mind. Gyro done wrong feels like the game is drunk.

What I Think About While Building

There's a specific feeling when a game mechanic clicks. Not when you design it — when you play it and your hands know what to do before your brain catches up. That's the signal.

Most of the games in this arcade went through a phase where they were technically working but didn't feel right. The fix was almost never adding more features. It was usually removing friction from the one thing that matters.

Peek Shooter's parallax aiming. The moment where you tilt and the gaps align and you see the enemy through three layers of wall. That's the entire game. Everything else — the scoring, the waves, the gun model — exists to create more moments like that.

SIGNAL's frequency hunting. The tilt-to-tune feels like you're physically reaching for a radio dial. The text corruption makes your brain work harder to read, which makes decoding feel like an achievement even though you're just pressing a button at the right time.

Games aren't made of code. They're made of moments. The code is just the scaffolding.

Text Flowing Around 3D Objects

This is the Pretext demo I've been wanting to build. Below, paragraph text flows around a live spinning Three.js model — the line widths vary per line, calculated by Pretext's layoutNextLine() to hug the circular shape of the 3D canvas.

The trick: Pretext measures text without the DOM, so we can ask "how much text fits on a line that's 480px wide on the left but only 260px because a circle is there on the right?" — and get an instant answer. Each line gets its own width based on its Y position relative to the floating shape.


Both models above are rendered to tiny offscreen-style canvases with transparent backgrounds, positioned absolutely over the text canvas. The text canvas is drawn fresh each frame with Pretext calculating the per-line layout. No DOM text wrapping at all — pure canvas.


BACK TO ARCADE