A procedural 3D city in the browser, generated in a Web Worker
Buildings, traffic with signals, pedestrians, a subway you can ride, weather, clouds and ridgelines — all from code, no assets but one walk cycle.
- draw calls
- 608
- a frame, about 1.04 million triangles
- generation
- ~750 ms
- 19 ms on the main thread
- 3D payload
- 187 KB
- gzipped, down 54% after minification
- road nodes
- 121
- vehicles and pedestrians as agents
Context
The front page of my membership product is a sales page, and it is also a city you can walk around in. Drag to orbit it, press a key to drop into first person, get into the car on the plaza, or take the stairs down to the metro and ride two stops. Everything is generated from code at load time — buildings, textures, the synthesized soundscape, the moon — and the only assets are one walk-cycle character and the 3D library itself.
That is a strange thing to put in front of prospective customers, and I had already deleted one: an earlier 3D arcade came off the site for overpromising visually and demanding too much of the visitor — 10,404 pixels of scrolling to reach the pricing. So the rule this one is built around is that nothing is ever more than one click away. A navigation link flies the camera to a district and opens its panel; walking is flavour, clicking is transport.
The page also still has to be a page: crawlers, screen readers, visitors without WebGL and visitors who asked for less motion all need the copy without running a megabyte of 3D.
What I built
There is one document. The marketing copy lives in the page as ordinary semantic HTML, and the overlay panels the city opens are those same sections, re-presented by CSS, never cloned or templated. A module toggles classes and nothing else, which buys an unchanged view for crawlers, a free no-WebGL fallback, real text for screen readers, and somewhere for reduced motion to land. There is no opt-out toggle either: the arcade had one, and a flag is a one-way door. The assertion I care most about says a section's text content is never modified; if it fails, the sales page has been gutted.
Generating the city takes about 750 ms, so it happens in a Web Worker and comes back as transferable arrays of floats. The generator therefore could not import the 3D library, so two pieces of it were reimplemented and pinned against its own output: the sRGB-to-linear colour conversion and vertex normals. Neither fails loudly when wrong — a naive hex-to-float conversion does not throw, it just shifts every colour in the city. The main-thread gap is 19 ms instead of 750, with 45 frames rendered while the city builds.

The streets are a 121-node road graph with vehicles and pedestrians as agents on it: cars keep right, each signal head stands on its approach's far side at the driver's right, and walkers wait at the kerb for the phase. The facades above are one merged mesh in one material — what makes a million triangles a frame affordable, and every depth problem harder.

Underground there is a two-station metro with a train you can ride, its car walls showing the next stop. Both of the stairwell's bugs have lived in the one function that decides what height you are standing at. The second let a walker stand in the void behind the stairs and be hauled 5.94 units up through solid structure; it is fixed twice on purpose, by a wall that makes the spot unreachable and a rule refusing any step up over one unit.
Decisions
Give way by rank, not by waiting for whatever is in front of you. Cars queue behind a body going roughly their way and yield to crossing traffic only by a fixed total order. A strict total order cannot contain a cycle, so the highest-ranked car in any group always has someone it need not wait for. Gridlock becomes impossible rather than merely unlikely.
Let the missions carry the product story, not the buildings. Labelling shopfronts with feature names was rejected as weaker marketing than a town that looks like the customer base. The town is already full of the businesses the product serves, so the delivery cargo is real membership work and the payoff is one sentence at the end of a job — which lands only because you drove across town to deliver it.
Dodge and solid, not launch. Asked to make a struck pedestrian fly, scaled by speed, I raised the register once — this page sells membership software to gyms and non-profits — and we settled on people who step aside and a car that cannot pass through them.
What went wrong
A held pointer lock ate the interface. Walking is pointer-locked, and a held lock delivers every mouse event to the canvas however the page is layered above it. Clicking a labelled building on foot had therefore never worked, in the very mode the city exists to be explored in: the specification freezes the cursor coordinates where the mouse sat when lock engaged, so the click aimed at a stale screen point. Aiming comes from the screen centre now, with a crosshair visible before you are on target. The same fact governs everything drawn over the canvas: a modal has to borrow the pointer while open, and a message can offer a close button only where a cursor exists — Escape cannot serve, because the browser eats it to drop the lock.
Two correct commits left 49 signboards outside shops that could not speak. One retired the boards and the street lines together, on a plan where labelled buildings replaced both; a later one restored the boards, because from the opening orbit those labels measure about 1.3 pixels per letter and the boards were the only cue at that distance. Neither was wrong — the pair left an affordance with no mechanism, and no test tied a board to the thing it advertised, so it was found by standing at one. A board is now drawn exactly when there is something to say.

The sky was harder than the city. Clouds went in at an altitude reasoned from the top-down camera and rendered zero clouds in frame from every orbit pose — because the orbit camera never looks up. It looks at the city and stops at the horizon, so the sky is a band three degrees tall at the top of the frame and clouds read only when they are far out and low: the layer sits at 350 units now, fading as the camera climbs. Ridgelines failed the other way: fog reaches full strength at 950 units, so anything beyond that is the sky colour and vanishes. The three rings are unlit and exempt from fog, tinted each frame by blending toward the sky's own colour, which inherits the day cycle and the weather for free.
Outcome
608 draw calls and about 1.04 million triangles a frame, from a 3D payload of 187 KB gzipped, down 54% after minification. The traffic rules took the worst measured freeze from 356 seconds to 10.6, exactly one red phase; rebuilding the signals as real objects cut the scene from 786 draw calls to 642 at the same pose while adding detail. The clouds cost one draw call and the ridgelines three.
All of it is held by browser harnesses driving the dependency-free modules, plus rendered-frame checks for what only a picture can settle. What that suite has never had anything to say about is discoverability: the pointer-lock bugs, the mute signboards and the missing crosshair were all found by walking around the city.