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.

Aerial view of City Drive: a cluster of tall towers, a circular junction with four roads, lawns with houses, a park, a wheat field and lake water on both sides
An aerial camera over seed 412, a few seconds after start. Everything here was generated in the browser, and the lake is simply where the generated land stops.

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.

Diagram of the road graph: junctions with arms, edges made of segment nodes, and a rejected arm start junction junction (4 arms) far junction far junction edge = list of nodes, one every 4.2 m arm dropped: path not clear each new edge creates a new far junction, so the graph is a tree, not a grid
The graph grows outward. Each arm becomes an edge ending in a brand-new junction, and arms whose road would pass too close to existing roads are dropped.

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.

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 fieldWhat it holds
ja, jb, armA, armBThe junctions at each end and the arm records that point back to this edge
nodes, segmentCountThe node chain; segment i runs from node i to node i+1
firstBlock, lastBlockThe block numbers this edge reserved
name, streetId, districtIndexStreet identity, shared with the edge it continues if it is the straight arm
startCornerClear, endCornerClearHow 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.

One 18-segment block: intersection segments 0 to 2, a possible street clock at 3, frontage lots centred on segments 5, 9, 13 and 17, backdrop towers at 3, 7, 11 and 15 01234567891011121314151617 lot 0 (seg 5) lot 1 (seg 9) lot 2 (seg 13) lot 3 (seg 17) each 14.6 m lot is centred on the start of its owner segment intersection cross street clock? filled squares: backdrop towers (segment % 4 = 3)outlined boxes: frontage lots, each built by one segment
Inside one block. Each frontage building is built by the single segment that owns its lot, even though the building is longer than that segment.

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.

DistrictDouble-lot chanceBackdrop towersCharacter
Brownstone Row0.1214 to 32 mBrownstones, Georgian and Victorian terraces
Financial Canyon0.560 to 130 mLimestone setbacks, colonnades, temple banks
Cast-Iron Lofts0.1522 to 48 mCast-iron lofts and warehouses
Civic Avenue0.434 to 68 mTemple banks, clock towers, fountain plazas (the default)
Glass Midtown0.5570 to 140 mBronze glass towers and Art Deco supertalls
Maple Heights0.1noneSuburban homes on lawns
West Side Warehouses0.1418 to 40 mStorage 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.

Street-level view behind a red car: stone towers on both sides, a colonnade on the right, street lamps, and a bridge crossing the road ahead
Street level on the same seed. The colonnade on the right, the stepped plaza on the left and the bridge over the road all come from seeded rolls, not from a stored map.

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.

Top-down view of a four-way junction: a circular road pad, four roads at uneven angles, corner buildings rotated toward the junction, lawns, a park with trees, a wheat field and a dry field
The starting junction from above. The circular pad, uneven arm angles and corner buildings turned along each corner's bisector are all visible, along with parks and fields further along the roads.

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.

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.