How I Built a 3D Mars Rally Game With AI Coding Agents

Mars Rally Championship, a free browser rally game, is 291 commits old. It started on 26 April 2026 from a game-jam starter pack, and most of the typing was done by AI coding agents working on branches: 54 merged pull requests came off agent branches, and 83 of the 291 commits carry an agent as the author rather than me. The thing that made it survivable was not prompting technique. It was a small set of machine-checkable invariants that fail CI the moment an agent writes code that is plausible and quietly wrong.

It started as a game-jam starter pack

The first commit is the Vibe Jam Starter Pack, dropped in whole: four standalone game starters and a bundle of shared agent skills. I kept the 3D one and deleted nothing, which is why projects/ and bonus/ are still in the repo as dead weight. Late April 2026, no plan beyond "make a rally car drive on Mars".

What it is now: a no-build browser game — 44 vanilla ES modules under public/js/, a self-hosted copy of Three.js resolved through an import map, zero bundler — plus a Next.js App Router site wrapped around it. The file I edit is the byte-for-byte file the browser runs. I wrote up that architecture separately in building a 3D rally game in Three.js with no build step; it turned out to matter for agents too, because a diff against a source file is a diff against production.

The honest scale

291 commits between 26 April and 6 September 2026. That is 19 weeks, roughly 2.2 commits a day, mostly evenings. About 38,000 lines across the game modules, the site, and the shared helpers, excluding the vendored engine.

The authorship split: 208 commits under my name, 83 under two agent identities. The repo's commit trailers show more than one agent was used — I moved between them over the summer, and I am not going to pretend I ran a controlled comparison.

Treat that 29% as a crude proxy and nothing more. Commit authorship measures who pressed the button, not who wrote the code. A large share of "my" commits are me fixing or trimming agent output and committing it locally; a share of the agent commits are mechanical renames across 44 files. If you measured characters typed, the agents wrote the large majority of this repo. If you measured decisions, they wrote almost none of it.

Guardrails are the actual work

This is the part people get wrong, so it is the part worth being precise about.

Agents are extremely fast at producing plausible code. The failure mode is almost never garbage. It is code that reads well, passes a skim, parses, type-checks, and violates an invariant nobody ever wrote down — because the invariant lived in my head, or in one comment, or in the shape of a file the agent never opened. Human review does not scale against that. You get three good reviews and then you get a tired one, and the tired one is the one that ships.

So the leverage is entirely in invariants a machine can check. Not "be careful", not a longer instructions file — a script that exits non-zero.

The import-graph check

./test.sh is a zero-dependency Node script, 25 structural checks, no npm install, runs in about a second. One check does something almost embarrassingly simple:

check('every js module is in the import graph', () => {
  for (const f of JS_FILES) {
    const base = path.basename(f);
    if (!combined.includes(`./${base}`) && !combined.includes(`js/${base}`))
      return `orphan module: public/js/${base}`;
  }
});

With no bundler, an orphan module is invisible. Nothing errors — the file just never loads, and the feature looks complete in the diff and does nothing in the browser. Agents create orphan modules constantly: a new subsystem file, beautifully written, never wired into boot.js. This check has caught it every single time.

The drift guard

The garage, the pilot dossier and the achievement list live on the site, not in the game, and they operate on the game's own localStorage profile. That means lib/game-data.ts is a deliberate second copy of the game's cosmetics catalog, crate economy and medal thresholds. Two sources of truth by design, which is normally a smell.

The smoke test reads both files and diffs them: cosmetic ids must match exactly, and literals like the 500-credit crate cost and the gold/silver/bronze medal speeds must be present on both sides. Drift means either un-equippable items or wrong medals shown to players. Before the guard existed, an agent updating one side and not the other was routine.

The design-system gate

Any visual change to the site has to load a design-system skill first — tokens, type registers, imagery rules, verification steps. Without it, every agent invents its own spacing scale and the site stops looking like one artifact after about four sessions.

Rules about files that must never exist

Two of the sharpest guardrails are prohibitions. public/index.html must never be created, because it would silently shadow the Next.js landing page. Deploy-time placeholders live in exactly one document and nowhere else. Both are named checks that fail loudly, because both are the kind of "helpful" file an agent adds without being asked.

Tests that run the real game

52 Playwright tests across 11 spec files boot the actual game at /play, drive a stage, cross checkpoints, exercise the tutorial and the multiplayer lobby, and assert things about the site. This is the expensive guardrail — the job runs about 37 minutes and eats most of my CI budget — and it is the only one that catches "the code is fine, the game is broken".

GuardrailWhat it catchesWhat it costs to maintain
Import-graph checkNew modules nothing imports — invisible in a no-build gameOne line in boot.js per module; near zero
Catalog drift guardSite's mirror of the game's constants diverging from the gameBoth files must change in one commit, or CI fails
Design-system skillAd-hoc styling that fragments the site's lookThe agent reads the skill before any visual edit; slower first pass
Never-create file rulesFiles that would shadow real routes or leak deploy placeholdersZero — two string checks
Lint for undefined globalsBare-called handlers that were never registered anywhereNew globals must be declared in the ESLint config
Playwright runtime suiteCode that parses, lints and type-checks but does not driveThe big one: ~37 min of CI per run, plus flake triage
Content validatorPublished claims the product cannot back; dead internal linksKeeping a facts sheet current as the game changes

The general point is dull and I think it is the whole lesson: do not spend your budget on reviewing agent output, spend it on making agent output falsifiable. Every hour I put into test.sh paid back more than every hour I put into reading diffs.

What the agents were genuinely good at

Bounded, specified, verifiable work, and they were better at it than I am. The per-wheel tire model came out of a spec, a set of target numbers and three iterations. The deterministic replay pipeline — fixed substeps, seeded noise, recorded inputs, bit-exact ghosts — is the most intricate thing in the repo and an agent did most of it, because "the same inputs must produce the same output" is a property a test can assert.

They were also good at the work I avoid: writing the Playwright specs, SQL migrations, accessibility passes, and mechanical refactors that touch 40 files. And they read the whole repo before answering, which I stopped doing months ago.

What did not work

The findings below are the top of docs/ROADMAP.md, which I keep in the repo and public on purpose.

The engineering ran miles ahead of the product. After three months I had a deterministic simulator with real Mars constants, a per-wheel tire model, a ghost format, mobile touch controls and a genuine accessibility layer — and almost nothing wrapping it. No share surface anywhere. No way for a finished run to become a link someone else clicked. The sim was excellent and unencountered.

Content authoring, not code, became the bottleneck. For most of the project exactly one stage was authored. Stages exist as terrain squares; a stage becomes drivable only when someone hand-places a start position and a checkpoint route in the level editor. Three are drivable today. Everything downstream is gated on that manifest, deliberately — an unauthored stage is hidden from the stage list, its leaderboard, its guide page and the sitemap — so a missing route does not strand a broken page, it strands a whole surface that would have existed. No agent can fix this for you. It is taste and hours in an editor.

The leaderboard was trivially fakeable. For months the table accepted anonymous inserts with a permissive policy, and the submitted time was not checked against the submitted ghost. Any player with devtools could have posted a one-second run. That is now a server route that checks the time against a physics floor derived from route length, verifies the ghost's frame count against the claimed duration, confirms every checkpoint was actually traversed, and rate-limits per device. Worth noticing: agents built the leaderboard, and no agent ever mentioned that it was unauthenticated. I found it during an audit I asked for explicitly.

The site sat effectively unindexed while features kept shipping. A search for the domain returned essentially one URL, and it was a page that did not exist in the app. Meanwhile the roadmap kept filling with mechanics.

That last one is the honest summary of the whole failure mode. An agent will happily build you the next feature forever. It will never interrupt to tell you that your problem is distribution, or that the thing you should do this week is author a stage rather than refactor a subsystem. Vibe coding a 3D game gets you a 3D game surprisingly fast. It does not get you players, and it will not warn you about the difference.

What I would tell someone starting now

Write the invariants before the features. The first day is the cheapest time to add a script that fails when the repo stops being the shape you want, and you will never again know the shape as clearly as you do on day one.

Prefer a check in CI over a rule in a document. A rule in a document binds only the agents that read it that day. A failing exit code binds everyone, including future you at 1 a.m.

Keep an honest findings file in the repo and let the agents read it. Mine is blunt to the point of being unflattering, and it is the single most useful context I hand over at the start of a session.

Budget explicitly for the work agents cannot do: content, taste, and deciding what not to build. On this project that turned out to be the majority of the remaining work, and I planned for none of it.

Where to try this

The game is free, runs in the browser, needs no download and no account, and works on a phone or a school Chromebook. Start driving — a bare link auto-starts a stage, because the game has no menu screen and this website is the menu. The stage list shows what is actually drivable, the level editor page explains the tool that authoring bottleneck runs through, and about covers what the project is. The engineering companions to this post are deterministic ghost replays and the no-build Three.js architecture.

Keep reading


LOG · September 6, 2026Tech & Engineering

Building a 3D Rally Game in Three.js With No Build Step

How I ship a Three.js racing game with no bundler — import maps, a vendored engine, shared mutable state, and the cache headers that make it work.

Read the dispatch
LOG · September 6, 2026Tech & Engineering

Simulating Vehicle Damage in a Browser Game Without Killing the Frame Rate

A vehicle damage simulation a game can afford in a browser tab: four HP pools, an energy-based impact curve, and damage wired into handling.

Read the dispatch
LOG · September 6, 2026Tech & Engineering

A Per-Wheel Tire Model in JavaScript, Running at 480 Hz

How a car physics simulation in JavaScript grew from a steering multiplier into four load-sensitive tire contacts solved at 480 Hz.

Read the dispatch