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

Mars Rally Championship, a free browser rally game, ships as plain ES modules with no bundler, no transpiler and no build step. The browser loads one HTML document, an import map resolves the bare specifier three to a self-hosted copy of the engine, and an entry module imports 42 subsystem files in a fixed order. Nothing is minified and nothing is tree-shaken. The file I edit is the byte-for-byte file the browser runs. Here is how that is wired, and what it costs.

The whole game is 44 files the browser reads directly

There are 44 JavaScript modules under public/js/, one per subsystem: physics.js, terrain.js, weather.js, audio.js, hud.js, net.js. They are served as-is. No dist/, no .map files, no watcher — a bare static file server is a complete dev environment.

That is not a purity stunt but a consequence of the dependency graph: the game has exactly one runtime dependency, and it is Three.js. A bundler earns its keep against thirty packages, mixed module formats, JSX and a type layer — not against one.

Import maps are what replace the bundler

The one job a bundler still does for free is resolving bare specifiers. import * as THREE from 'three' is not a valid URL, so the browser cannot fetch it — historically that alone forced everyone into a build step. An import map removes the reason. It sits immediately before the entry script:

<script type="importmap">
{
  "imports": {
    "three": "/vendor/three-0.174.0/build/three.module.js",
    "three/addons/": "/vendor/three-0.174.0/jsm/"
  }
}
</script>

<script type="module" src="/js/boot.js"></script>

The trailing-slash key is the part people miss: "three/addons/" is a prefix mapping, so every addon path the Three.js docs use resolves without another entry:

import * as THREE from 'three';
import { EffectComposer }  from 'three/addons/postprocessing/EffectComposer.js';
import { SSAOPass }        from 'three/addons/postprocessing/SSAOPass.js';
import { FXAAShader }      from 'three/addons/shaders/FXAAShader.js';
import { GLTFLoader }      from 'three/addons/loaders/GLTFLoader.js';

Those are the real import lines from main.js and environment.js, and they are identical to what a Vite project would contain. None of it is in a dialect that only works here.

Why the engine is vendored instead of loaded from a CDN

Three.js r0.174 lives in the repo at public/vendor/three-0.174.0/, trimmed to the addons the game imports. It is not fetched from a public CDN, and that was a deliberate reversal of the original setup: a CDN import map is one line shorter and one third party away from an outage taking the game down completely. The engine is not an enhancement here — without it the page is a blank canvas — so a CDN would be a single point of failure with no degraded mode. Self-hosting also makes the version in the URL a fact about my repository rather than someone else's uptime, which is what the caching policy below depends on.

Two cache policies, because the two halves change at different rates

Self-hosting only pays off if you cache correctly, and the engine and the game code need opposite headers. The vendor path is immutable: three-0.174.0 can never contain different bytes, because a new version is a new directory and therefore a new URL. The game's own modules have no content hash — /js/physics.js is always /js/physics.js — so caching them could leave a player on a stale mix of old and new modules after a deploy, and both the multiplayer protocol and the service worker assume everyone online runs current code.

next.config.ts sets one rule per asset class:

PathCache-ControlWhy
/playpublic,max-age=0,must-revalidateCarries the import map and env; must match the code it boots
/js/*public,max-age=0,must-revalidateNo content hash — a stale module means a mixed-version client
/css/*public,max-age=0,must-revalidateShips in lockstep with the HUD markup
/vendor/three-0.174.0/*public,max-age=31536000,immutableThe version is in the path, so the bytes can never change
/terrain/*public,max-age=86400Heightmaps only change when a stage is re-cut
/assets/*public,max-age=3600Baked textures and rocks, edited rarely, unversioned names

Revalidating the small files is cheap — a conditional request returning 304 costs a round trip, not a download — while the 2 MB of engine that dominates the payload is fetched once per version and never again. A bundler reaches the same place by hashing everything into one artifact; import maps do it by putting the version in a directory name.

A shared mutable context instead of a framework

Cross-module state lives on one exported object in state.js:

export const S = {};

S.renderer   = undefined;
S.scene      = undefined;
S.camera     = undefined;
S.composer   = undefined;
S.carState   = undefined;
S.terrain    = undefined;
S.gameState  = undefined;

Every key is declared up front as undefined — a hundred-odd of them — and the owning module assigns at its original initialisation point. This is the least fashionable pattern in the post, and it came from a constraint: the game was one enormous script first, and S is what those top-level let bindings became when it was split. Declaring them in one file meant the split changed no initialisation order and no timing.

It has held up. No store, no reactivity, no subscription graph, and one Ctrl+F shows every piece of state any subsystem can see. The physics loop reads and writes S.carState 8 times per rendered frame — the fixed-substep scheme behind the per-wheel tire model and bit-exact ghost replays — and a reactive layer there would be a liability.

Fixed import order, and the test that stops a module going missing

Without a bundler, module order is mine to get right. boot.js is nothing but the ordered list, reasons in comments:

import './state.js';
import './core.js';
// Telemetry first: its window error listeners should be armed before
// any later module's top-level evaluation can throw.
import './telemetry.js';
// …
import './haptics.js';    // before juice.js — juice's toasts tap it
import './juice.js';
import './net.js';
import './rivalfield.js'; // after net.js — reuses its nameplate helper
import { init } from './main.js';

init();

The failure mode this creates is nasty: a module nobody imports never evaluates, so the window.* handlers it registers silently do not exist and the button calling one throws in production. Three cheap static checks close that gap, all running in CI:

  • The smoke test walks public/js/ and fails on any file no other file imports: orphan module: public/js/foo.js. Wiring a new module up is not optional.
  • It runs node --check over every module, catching a syntax error that would otherwise surface only when that module is first requested.
  • ESLint runs exactly one rule, no-undef, with the bare-called window.* handlers declared as globals: every identifier must resolve to an import, a declaration, or a known browser global.

The same file fails the build if a deploy placeholder leaks out of the game document, or if the site's mirror of the game's economy constants drifts. Without a compiler, the rules have to be executable rather than written down.

Making a 2 MB engine feel like it loaded fast

Module graphs are discovered by walking them, the one real performance cost of no bundling: the browser parses the HTML, fetches boot.js, parses it, fetches state.js, then core.js, reaching Three.js several hops in. Two lines in the <head> fix it:

<link rel="modulepreload" href="/vendor/three-0.174.0/build/three.module.js">
<link rel="modulepreload" href="/vendor/three-0.174.0/build/three.core.js">

modulepreload starts the largest fetch at parse time and, unlike preload, has the browser parse it into the module map so it is ready, not merely downloaded.

The other half is what the player looks at meanwhile. First paint is a styled boot splash — title, progress bar, "ENTERING MARS ORBIT…" — not a black canvas. It is removed two animation frames after the render loop starts, so the crossfade reveals terrain that has been drawn:

S.renderer.setAnimationLoop(animate);
requestAnimationFrame(() => requestAnimationFrame(() => {
  document.getElementById('boot-splash')?.classList.add('gone');
}));

A capability check runs first: without WebGL 2.0 and Web Audio, the player gets a panel explaining why, not a black screen.

Post-processing behind three presets, plus dynamic resolution

Rendering runs through an EffectComposer stack: render pass, SSAO, bloom, a colour grade, FXAA, output. Expensive on a laptop, so three flat presets gate it. LOW drops to 0.75 render scale with no SSAO or bloom and 1024px shadows; MEDIUM is full scale with both at 1536; HIGH caps device pixel ratio at 1.5 with the full grade and 2048px shadows. The game ships on HIGH; the pause menu steps down.

On top sits dynamic resolution scaling, the only automatic adjustment left. Once a second during play it averages a rolling 60-sample frame-time window: above 19 ms it drops the scale by 0.1 to a floor of 0.6, below 13 ms it climbs back to the preset's ceiling. Setting pixel ratio is not enough on its own — FXAA needs its texel offsets re-synced or it samples at stale resolution — so the whole quality path is re-applied, not just the renderer.

There used to be an auto preset that benchmarked once and cached the verdict forever, but it sampled during the countdown — frames dominated by cold texture streaming and shader compiles — so it read fast machines as slow and pinned them to LOW. Deleting it was a straight improvement.

The game document is served verbatim by a Next.js route handler

The site around the game is a normal Next.js 15 App Router app in React. The game is not, and the seam is a single route handler at /play:

export const dynamic = 'force-static';

export function GET(): Response {
  const template = readFileSync(join(process.cwd(), 'game', 'index.html'), 'utf8');
  const html = template
    .replaceAll('__SUPABASE_URL__', process.env.SUPABASE_URL ?? '')
    .replaceAll('__MULTIPLAYER_WS_URL__', process.env.MULTIPLAYER_WS_URL ?? '');
  return new Response(html, { headers: { 'Content-Type': 'text/html' } });
}

Nothing goes through React, so the import map, the boot order and the handler wiring stay byte-controlled. The placeholders exist because the game needs backend URLs it cannot hardcode, and they live in that one file only — the smoke test fails if one appears in a module, where it would ship literally. Unset variables substitute to an empty string: without Supabase, local personal bests still work; without a websocket URL, online racing shows an unavailable toast.

What the no-build trade actually costs

Four real costs.

No tree-shaking. The vendored engine is trimmed by hand to the addons the game imports, a chore repeated on every upgrade. Worse, the level editor modules sit on the import graph permanently even though the UI is dev-only, because the graph is what the smoke test enforces. A bundler would have dropped them.

No type checking in the game. The site is TypeScript and type-checks on every build; the game's entire static safety net is no-undef plus node --check. That catches a missing import, not a Vector3 passed where a number belongs.

Manual dependency ordering. The comments in boot.js are load-bearing. Put telemetry after a module that throws at evaluation time and you lose the error report for the crash you are debugging.

No minification or dead-code elimination: every byte I write is a byte on the wire.

I would make the same call again here. Reload is instant, because there is nothing to rebuild. Stack traces point at the real file, line and column, in code I recognise. And the loop from "that corner feels wrong" to a fixed constant is edit, save, refresh — which, for a game whose design problem is feel, is what determines how good it gets. Change one input — a second dependency, a team, a type layer — and the answer flips.

Where to look at this yourself

The game is at /play — no download, no account — and the first thing it does is what this post describes. More on the project is on the about page, the stage authoring tools on the level editor page, and the determinism the fixed-substep loop serves in ghost replays and deterministic physics.

Keep reading


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

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

What it actually takes to build a game with AI coding agents — 291 commits, 54 agent-branch PRs, the guardrails that held, and the things that went wrong.

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