Arcade
Nine browser games written from scratch — game loop, collision, and state handling on raw canvas, with no game library underneath.
- games
- 9
- lines of component code
- 1,894
- game engines used
- 0
- to open the arcade
- 8.7 kB
Nine games, no engine. Every loop, every collision check, and every bit of state in the arcade is code I wrote against a raw 2D canvas context. The whole thing exists because reading about game loops and writing one are different activities, and only the second one teaches you anything.
Why not use a library
Phaser or PixiJS would have made the games faster to build and less interesting to have built. The point was to find out what a game engine actually does for you — and the answer turns out to be “owns the parts that are easy to get subtly wrong”: the timing of the loop, the ordering of update and draw, the lifecycle of objects that spawn and die every frame.
Writing nine of them meant hitting those problems nine times, which is where the pattern starts to show.
The interesting problem: depth instead of boxes
Runner is the one I’d point at. It’s an endless runner rendered as though it has depth, on a canvas that has none.
Obstacles don’t live at an x/y position. They live at a lane (-1, 0, or 1)
and a z — how far away they are. Everything on screen is derived from
that pair:
function laneToX(lane: number, z: number): number {
const perspective = 1 / (1 + z * 0.008);
return W / 2 + lane * LANE_WIDTH * perspective;
}
function zToY(z: number): number {
const t = z / 200;
return H * HORIZON_Y + (H * PLAYER_Y - H * HORIZON_Y) * (1 - t);
} Which makes collision detection almost trivial, and that’s the payoff. There
are no bounding boxes and no overlap maths. An obstacle is a hit when its z crosses the player’s plane and its lane matches the player’s lane —
two integer comparisons and a range check:
if (obstacles[i].z < 8 && obstacles[i].z > -5) {
if (obstacles[i].lane === playerLane) {
// coin, cleared jump, or crash
}
} The lesson generalised: I spent the effort on the coordinate system rather than on the collision code, and the collision code stopped being hard. That trade shows up constantly in frontend work too — the shape of your state decides how much logic you have to write around it.
Where reactivity belongs, and where it doesn’t
Svelte 5’s runes are genuinely good, and the temptation is to make everything reactive. Inside a 60fps loop that’s a mistake — you don’t want a dependency graph re-evaluating while you’re mutating an array of obstacles sixty times a second.
So each game splits cleanly in two. Anything the player reads is $state:
let score = $state(0);
let coins = $state(0);
let gameOver = $state(false); Anything the loop touches is a plain local, closed over inside onMount and invisible to the reactivity system:
let obstacles: Obstacle[] = [];
let speed = 2;
let playerLane = 0; The loop mutates freely and writes across the boundary only when something actually changed on the scoreboard. Same idea as keeping animation state out of a store — the framework should render the result, not supervise the work.
Difficulty that ramps without a config file
Both endless games scale off the player’s own progress rather than a clock, so nobody gets punished by time that passed while they were losing. Runner reads distance travelled:
speed = 2 + distance * 0.0003;
const spawnRate = Math.max(30, 60 - Math.floor(distance / 500)); Flappy reads pipes cleared, tightening the gap and shortening the interval together:
function getGap() { return Math.max(70, BASE_GAP - score * 1.5); }
function getPipeInterval() { return Math.max(60, 100 - score * 2); } No tuning tables, and each curve is legible at a glance. The Math.max floors matter more than they look — without them the gap closes past the
bird’s own height and the game becomes unwinnable rather than hard.
Shipping nine canvas games without shipping nine canvas games
The games total around 1,900 lines of component code. Originally the arcade page imported all nine statically, which meant every visitor downloaded all of it to look at a grid of buttons.
They’re now registered with a dynamic import each:
{
id: 'runner',
name: 'Runner',
load: () => import('$lib/components/games/RunnerGame.svelte')
} Opening the arcade costs 8.7 kB. The chosen game arrives as its own chunk on click — Runner, the largest, is 9.9 kB. Nobody pays for the eight games they didn’t open.
What I’d change
Audio is the clearest mistake. Two games build an AudioContext inline
per sound effect; the other seven call into a shared manager pulled from
Svelte context — and nothing ever puts one there, so those seven are
silently mute. Half a design, committed twice. Runner’s jump-clearance check
reads the player’s vertical offset directly instead of a proper
“is airborne” state, so the window for clearing a barrier is tighter than
it should be. And the input layer is duplicated across the games that need
both keyboard and touch — one action for swipe-versus-tap would have paid
for itself by the third game.