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.

Mars Rally Championship in-game screenshot: close-up of the rover's dashboard telemetry screen at dusk, showing speed, gear, stage timer and corner damage, framed by the pilot's gloves on the yoke.

In-game screenshot: the cockpit's telemetry screen at dusk. It is a 2D canvas the HUD module redraws and Three.js maps onto the dash as a texture.

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 { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.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.

Diagram: a request sequence between the browser, its cache and the server; game code under max-age=0 is revalidated with a 304 Not Modified, while the Three.js engine under an immutable one-year max-age is served from cache without a request.

Diagram: on a repeat visit the game code costs a 304 round trip, while the version-pinned engine is served from cache, with no request at all for a year.

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 60 Hz tick — the fixed-step scheme behind the per-wheel tire model and bit-exact 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, 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 bloom and 1024px shadows; MEDIUM is full scale with bloom and 1536px shadows; 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.

Diagram: the six post-processing passes in order, scene render, aerial fog, bloom, colour grade, FXAA and output, with notes on what the LOW and HIGH presets change and the once-a-second dynamic resolution rule.

Diagram: each frame runs render, aerial fog, bloom, colour grade, FXAA and output; LOW skips bloom, and dynamic resolution moves the scale between 0.6 and the preset's ceiling.

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.

Questions, answered


Can you build a Three.js game without a bundler?

Yes. An import map maps the bare specifier three to a real URL, which is the only thing a bundler was strictly required for. Mars Rally Championship runs entirely on plain ES modules served from a static directory, with Three.js self-hosted and no transpile step anywhere in the game code.

Are import maps supported in all browsers?

Yes, in every current browser. Chrome and Edge shipped them in 2021, Firefox in December 2022 and Safari in 16.4 in March 2023, so import maps have been broadly available for years. Older engines fail at module resolution rather than degrading, so there is no partial-support middle ground to design around.

Should I self-host Three.js or use a CDN?

Self-host it if the engine is load-bearing. A CDN is a third party whose outage takes your page down with no degraded mode, and you cannot set your own cache headers on it. Self-hosting under a version-pinned path lets you serve the engine as immutable for a year while keeping your own code revalidating on every load.

How big is a Three.js game download?

The vendored engine here is about 2 MB of uncompressed JavaScript, which compresses substantially over the wire and is then cached immutably, so it is a first-visit cost only. Terrain heightmaps and baked textures dominate after that. The game code itself is a small fraction of the total payload.

Does Mars Rally Championship need a download or an account?

No. It runs in the browser at the play URL with no download, no install and no signup, on desktop, phone and Chromebook. Progress and personal bests are stored in your browser's local storage, and online racing and the leaderboard work without an account too.

Keep reading


LOG · October 14, 2026Browser Gaming

Best Racing Games With Leaderboards You Can Trust

Racing games with leaderboards, judged on trust: who checks a run before it posts, whether you can watch it or race its ghost, and if you need an account.

Read the dispatch
LOG · October 14, 2026Browser Gaming

Best Drift Simulator Games: Realistic Physics Compared

The best drifting simulator games compared on tire model, surfaces, assists, wheel support and scoring, from Assetto Corsa to free browser options.

Read the dispatch
LOG · September 27, 2026Browser Gaming

Racing Games for Low-End PCs and Old Laptops (All Free)

Racing games for low-end PCs and old laptops, all free: browser games that install nothing, downloads with official specs, and the settings that cost frames.

Read the dispatch