Building a procedural city in WebGPU
City Drive never loads a map. Every street, tower, park and bridge is made from one number, the world seed, while you drive. This article walks through the real code: how the road network grows, how a block decides what stands on it, and the shortcuts that keep it fast enough to run in a browser.
Two kinds of randomness
The seed lives in environment.js. If the page sets presetWorldSeed before the scripts run, that value is used; otherwise the game picks a random one. Every random decision in the city then flows through one of two functions.
const WORLD_SEED = globalThis.presetWorldSeed ?? Math.random() * 1000.0 + 137.42;
function seededRandom(seed) {
const x = Math.sin(seed * 12.9898 + WORLD_SEED * 78.233) * 43758.5453;
return x - Math.floor(x);
}
function makeRng(seed) {
let a = (Math.floor(seed) * 2654435761) >>> 0;
return () => {
a = (a + 0x6D2B79F5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
seededRandom is the familiar "fractional part of a big sine" hash from shader code: no state, same input, same output. makeRng in math.js is a Mulberry32-style generator that returns a stream, so call order matters.
The split is deliberate. The road graph uses makeRng streams, because a road is a sequence of decisions. The contents, such as which building stands on a lot, use seededRandom keyed on numbers like the block index. Contents are therefore a pure function of position, the most important property in the design: any small piece of the city can be built alone, in any order, on any thread, and still agree with its neighbours.
A road graph that grows as you drive
The network lives in one object in road_graph.js: two Maps of junctions and edges, an id counter, a block counter and a seed base derived from the world seed. It starts with a single junction at the origin. A junction is a point with a radius and a list of arms, the headings where roads leave it.
function createJunction(x, z, backHeading, rng) {
const travel = backHeading + Math.PI;
const roll = rng();
const sides = roll < 0.55 ? [0, 1, -1] : roll < 0.78 ? [0, 1] : [0, -1];
const backArm = { heading: backHeading, edge: null, end: null, angle: 0 };
const arms = [backArm];
for (const side of sides) {
const spread = side === 0 ? 0.22 : 0.4;
arms.push({ heading: travel + side * Math.PI * 0.5 + (rng() - 0.5) * spread, edge: null, end: null, angle: 0, straight: side === 0 });
}
Every junction has an arm back the way you came, one roughly straight ahead, and a 55% chance of a four-way crossing, otherwise a T to the left or right. Headings are jittered, so streets meet a little off 90 degrees and the city does not look like graph paper. junctionRadius then sizes the pad, growing it when two arms are close so the corners do not overlap, clamped between 11.5 and 18 metres.
Nothing is generated ahead of time. When the streaming code (see the next article) reaches a junction, it calls ensureJunctionArms, and only then does each empty arm get a road.
function createEdgeFromArm(junction, arm, relax = 1.0) {
const rng = makeRng(roadGraph.seedBase + roadGraph.nextId * 7919 + junction.id * 131);
for (let attempt = 0; attempt < 4; attempt++) {
const blockCount = rng() < 0.5 ? 4 : 3;
const firstBlock = roadGraph.nextBlock;
const startX = junction.x - Math.sin(arm.heading) * junction.radius;
const startZ = junction.z - Math.cos(arm.heading) * junction.radius;
const nodes = generateEdgeNodes(startX, startZ, arm.heading, firstBlock * MANHATTAN_BLOCK_PERIOD, blockCount, rng);
const last = nodes[nodes.length - 1];
const probeX = last.x - Math.sin(last.heading) * 15;
const probeZ = last.z - Math.cos(last.heading) * 15;
if (!edgePathIsClear(nodes, probeX, probeZ, junction, relax)) continue;
roadGraph.nextBlock += blockCount + 1;
const farJunctionInfo = createJunction(0, 0, last.heading + Math.PI, rng);
The rest places the far junction beyond the last node, names the street and links both arms. Two details matter.
- Edges never join existing junctions. Every edge ends in a fresh junction, so the network is a tree that branches outward.
edgePathIsClearchecks every third node of the candidate and rejects it if it comes within roughly 62 to 75 metres of another road or 70 metres of another junction. There are four attempts. - The straight-ahead arm is protected. Failed arms are removed, but the straight one gets more tries with the clearance loosened to 70%, 45% and finally zero, so dead ends are rare.
const straight = dropped.find(a => a.straight);
if (straight) {
junction.arms.push(straight);
sortJunctionArms(junction);
for (const relax of [0.7, 0.45, 0.0]) {
if (createEdgeFromArm(junction, straight, relax)) return;
}
junction.arms.splice(junction.arms.indexOf(straight), 1);
}
Behind you, evictFarGraph runs every two seconds and deletes edges whose ends and midpoint are all over 1,400 metres from the car, then empty junctions. Arms that pointed at a deleted edge are emptied so they can grow again.
Edges: chains of 4.2 metre segments
An edge is a list of nodes spaced SEG_LENGTH = 4.2 metres apart. Each node stores a position, a heading, forward and right vectors, and a global index. generateEdgeNodes steers with a gentle curvature: at each block start there is a 45% chance of going straight, otherwise a bend of 0.008 radians per metre, steered back once the heading drifts 0.26 radians from the start. The heading is clamped to within 0.42 radians, so roads wander but never double back.
The global node index is the clever part. Each new edge reserves a run of block numbers from roadGraph.nextBlock, and its node indices start at firstBlock * 18 + 3. Every node therefore has a unique index, from which you get its block (index / 18) and its place in the block (index % 18). An edge of three or four blocks has 51 or 69 segments, about 214 or 290 metres.
| Edge field | What it holds |
|---|---|
ja, jb, armA, armB | The junctions at each end and the arm records that point back to this edge |
nodes, segmentCount | The node chain; segment i runs from node i to node i+1 |
firstBlock, lastBlock | The block numbers this edge reserved |
name, streetId, districtIndex | Street identity, shared with the edge it continues if it is the straight arm |
startCornerClear, endCornerClear | How many segments near each junction must stay free of buildings so corners do not collide |
That last field comes from assignEdgeZones. When two arms meet at a tight angle, buildings on one would poke into the other, so the clear distance is computed from the angle: reach / tan(angle / 2), minus the junction radius, in segments. Each node gets hidesLeftSide and hidesRightSide flags, and when a segment is built, refreshCrossRoadClearance also probes up to 52 metres sideways and hides a side that would land on another road.
Blocks: everything from one index
A block is 18 segments, about 75.6 metres. getManhattanBlockInfo in city_blocks.js turns a node index into a description of that spot: is it an intersection, a crosswalk, a boulevard, is there a bridge overhead, is the left side a park? The result is stored on each node as info.
const isWaterBlock = isOrdinaryBlock && (blockNumber % 9 === 4);
const waterSide = (Math.floor(blockNumber / 6) % 2 === 0) ? 1 : -1;
const waterStyle = Math.floor(blockNumber / 6) % 3;
const leftIsValley = isWaterBlock && (waterSide < 0);
const rightIsValley = isWaterBlock && (waterSide > 0);
const leftIsPark = isOrdinaryBlock && !leftIsValley && (blockNumber % 5 === 1);
const rightIsPark = isOrdinaryBlock && !rightIsValley && (blockNumber % 7 === 3);
const parkTheme = blockNumber % 3;
const leftIsField = isOrdinaryBlock && !leftIsValley && !leftIsPark && seededRandom(blockNumber * 5.71 + 1.3) < OPEN_FIELD_SIDE_CHANCE;
const rightIsField = isOrdinaryBlock && !rightIsValley && !rightIsPark && seededRandom(blockNumber * 8.37 + 4.9) < OPEN_FIELD_SIDE_CHANCE;
Some choices are modular arithmetic (water every ninth block, parks on different cycles per side), others are seeded hashes (20% open fields per side, paving, bridges). The first and last block of every edge never get water, parks, fields or bridges, which keeps junctions tidy. Blocks with blockNumber % 3 !== 2 become boulevards, 8.4 metres from centre to curb instead of 5.6, tapering over three segments.
Districts and street names
city_districts.js defines seven districts, each a bag of weights and colours: preferred building styles, how often lots are doubled, backdrop tower heights and skyline tones.
| District | Double-lot chance | Backdrop towers | Character |
|---|---|---|---|
| Brownstone Row | 0.12 | 14 to 32 m | Brownstones, Georgian and Victorian terraces |
| Financial Canyon | 0.5 | 60 to 130 m | Limestone setbacks, colonnades, temple banks |
| Cast-Iron Lofts | 0.15 | 22 to 48 m | Cast-iron lofts and warehouses |
| Civic Avenue | 0.4 | 34 to 68 m | Temple banks, clock towers, fountain plazas (the default) |
| Glass Midtown | 0.55 | 70 to 140 m | Bronze glass towers and Art Deco supertalls |
| Maple Heights | 0.1 | none | Suburban homes on lawns |
| West Side Warehouses | 0.14 | 18 to 40 m | Storage warehouses |
Districts belong to streets, not areas. In streetIdentityForArm, a straight continuation inherits the name and district of the road it continues, so an avenue keeps its character across junctions. Any other new edge gets a fresh name and a district that differs from every road already at the junction.
function chooseDistrictAvoiding(takenDistrictIndices, seed) {
const candidates = CITY_DISTRICTS.map((_, index) => index).filter(index => !takenDistrictIndices.has(index));
const pool = candidates.length ? candidates : CITY_DISTRICTS.map((_, index) => index);
return pool[Math.floor(seed * pool.length) % pool.length];
}
Turning therefore always takes you somewhere that looks different. Roads heading roughly north-south take names from a short list of famous Manhattan streets; the rest get numbered names like "W 42ND ST".
Planning a block of buildings
Every segment builds its own roadside. pushRoadsideBlocks in road_geometry.js picks one of four kinds of content per side.
for (let side = -1; side <= 1; side += 2) {
const isValleySide = (side < 0 && startInfo.leftIsValley) || (side > 0 && startInfo.rightIsValley);
const isParkSide = (side < 0 && startInfo.leftIsPark) || (side > 0 && startInfo.rightIsPark);
if (segment.isSideHidden(side)) continue;
if (isValleySide) {
offset = pushManhattanWaterFeatureSide(vertices, offset, x, y, z, heading, side, index, startInfo.inBlockIdx, startRoadHalfWidth, startInfo.waterStyle);
} else if (sideIsFieldOf(startInfo, side)) {
offset = pushOpenFieldSide(vertices, offset, { pointAlong: segment.pointAlong, info: startInfo, side, heading, segmentIndex: index });
} else if (isParkSide) {
offset = pushManhattanParkBlock(vertices, offset, x, y, z, heading, side, index, startInfo.inBlockIdx, startRoadHalfWidth, startInfo.parkTheme);
} else {
offset = pushManhattanBuildingBlock(vertices, offset, {
A building side runs a short pipeline in street_buildings.js: plinth, sidewalk furniture, frontage building, possible street clock, backdrop tower. Each side of a block has four frontage lots, owned by segments 5, 9, 13 and 17, and planBlockFrontage decides the whole row from the block number, side and district:
function planBlockFrontage(blockNumber, side, district) {
const lots = [];
let lotIndex = 0;
while (lotIndex < FRONTAGE_LOT_COUNT) {
const spanRoll = seededRandom(blockNumber * 19.7 + side * 5.3 + lotIndex * 3.1);
const styleRoll = seededRandom(blockNumber * 7.9 + side * 2.3 + lotIndex * 11.7);
const canSpanTwoLots = lotIndex % 2 === 0;
if (canSpanTwoLots && spanRoll < district.doubleLotChance) {
const style = pickWeightedStyle(DOUBLE_LOT_STYLES, district.doubleLotWeights, district.otherStyleWeight, styleRoll);
lots.push({ build: style.build, styleId: style.id, lotSpan: 2 });
lots.push(null);
lotIndex += 2;
continue;
}
const style = pickWeightedStyle(SINGLE_LOT_STYLES, district.singleLotWeights, district.otherStyleWeight, styleRoll);
lots.push({ build: style.build, styleId: style.id, lotSpan: 1 });
lotIndex += 1;
}
return lots;
}
Because the plan is a pure function, segment 9 can recompute it, see that lot 1 is the second half of an Art Deco supertall owned by segment 5, and build nothing. No segment needs to know whether its neighbours exist yet. pickWeightedStyle is a plain weighted pick; unlisted styles get the district's small otherStyleWeight, except the suburban home, which only appears where listed. There are fourteen single-lot styles and three double-lot ones.
Styles are built from a kit in architecture_kit.js. Its key helper, lotPoint, turns "distance from the road, distance along it, height" into world space, mirrored per side. Builders like pushBrutalistTower are then lists of boxes, columns and window grids; a brutalist tower gets 12 to 24 storeys of 3.6 metres over a 4.6 metre lobby, the count taken from the lot's seed.
pushBox also registers a collision volume while an obstacle list is active, so geometry and collision data come from the same pass. Bridges work like lots: each ordinary block has a 60% chance of one at segment 7 to 11, in one of seven weighted styles.
Junctions, corners and curbs
Junctions are built separately by junction_geometry.js. The road surface is a 32-sided disc wide enough for the widest arriving road, and each arm gets zebra stripes, a stop line and a signal. Between neighbouring arms is a corner, the wedge of land in that sector.
Each corner's ground is a triangle fan. Buildings are tried at a fixed list of candidate spots (a fraction across the sector and a distance from the pad) and skipped if too close to either arm, another road or a building already placed. About 22% of corners become pocket parks instead. A makeRng stream seeded from the junction id keeps a junction identical every time it is rebuilt.
Where a segment meets a cross street, curb_corners.js rounds the sidewalk corner with a 2.8 metre quarter-circle in six steps.
One surprise: the cross streets every 75 metres are not in the graph. Segments 0 to 2 of each block are flagged as intersections, and pushIntersectionApproach paints a road strip out to a parallel street 64 metres away, with crosswalks and signals. The city reads as a grid, but you can only turn at real junctions.
Parks, water, fields and the far city
The other sides use the same block flags plus per-segment seeds.
- Water (
waterfront_and_parks.js): a lake promenade, cascade fountain or river pier, behind a seawall that is also a collision barrier. The ground drops into a basin 1.45 to 3.85 metres deep. - Parks: a footpath, a monument at segment 9 in one of three themes, trees elsewhere, and gently rolling ground.
- Fields (
open_fields.js): four styles (wheat rows, meadow, pasture and husk grass), with hay bales and lone trees, and a barn at segment 9 of pasture blocks. - Far city (
far_city.js): beyond the roadside band, each segment fills out to 112 metres with a parallel street and two rows of cheap towers scaled by district. It probes sideways in 6 metre steps and stops short of any real road.
road_elevation.js defines terrain as three crossing sine swells, scaled so the steepest combined slope is a 15% grade. Geometry is built flat, then draped: each vertex is lifted by terrainHeightAt(x, z) and each normal recomputed.
function terrainHeightAt(x, z) {
let height = 0;
for (const swell of TERRAIN_SWELLS) {
height += swell.amplitudeMeters * Math.sin((x * swell.directionX + z * swell.directionZ) * swell.wavenumber + swell.phase);
}
return height;
}
shoreline.js defines land as anything within 112 metres either side of a streamed segment, or near a junction. Everything else is lake; the physics uses this to push the car back to shore, and it is why the city looks like a branching island from above.
Key takeaways
- Use a stateful generator for structure (the road graph) and a stateless hash for content, so content can be rebuilt anywhere in any order.
- A global, unique index per road segment lets you derive blocks, lots and features with simple arithmetic.
- Plan a whole row of lots from one key, then let each segment build only its own lot. Neighbours agree without talking to each other.
- Grow the graph lazily, reject roads that would collide, and evict what is far behind.
- Build flat, then drape onto terrain. It keeps every builder simple.
Trade-offs and limitations
The world is not persistent. A new edge's random stream is seeded from roadGraph.nextId, a counter that only rises, and block numbers come from another. Drive 1,400 metres away and back, and the evicted roads are replaced by different ones. The seed repeats the city only if you repeat the route. I suspect that is an accepted trade for an endless drive, but the code does not say so.
No loops. Edges only end in new junctions, so you can never drive round a block back to where you started. The painted cross streets hide this at speed.
Periodic patterns. Water every ninth block and parks on cycles of five and seven are cheap, but over a long drive the rhythm shows if you look for it.
A fixed vertex budget. Each segment must fit in 7,200 vertices, and pushTriangle silently drops triangles past that. A few builders check the remaining room before adding an expensive corner building; most rely on fitting. Overflow leaves holes rather than errors, so it needs checking by hand.
Building the city in 4.2 metre pieces is what lets it be built on background threads while you drive. The next article covers how those pieces are chosen, built in Web Workers and uploaded to the GPU.