logo
ProceduralCity

world · procedural-world

ProceduralCity

Listens toMesh
3
Unlock

Install with the CLI:

bjs download ProceduralCity

ProceduralCity

One line of scene JSON, one mining town — and the mine under it.

JSON
"World": {
  "tags": ["world"],
  "components": {
    "ProceduralCity": { "seed": 7, "width": 22, "depth": 22, "maxStories": 3 }
  }
}

Change the seed and you get a different town. Same seed, same town, every time — which is what lets a screenshot pipeline take the same picture twice and a bug report say "seed 41, the building at the north-west corner".

What it builds

A grid of cells, 1.9 units on a side because that is the size every piece in the kit was normalised to. Streets run through it on a lattice. The blocks between them hold buildings: one to maxStories floors of walls, a tin roof or an open deck on top, and — the part that took the work — a way up to every floor. Two cells of stairs or three of ramp, laid off the perimeter, arriving on a landing one storey higher, with the floor above the run cut out and a railing on every side of the hole that meets floor. If a floor has no room for a way up, the building stops at that floor. Nothing is built that you cannot walk up to.

Yards between buildings collect crates, barrels and lamps. Roofs are flat slabs with a parapet on every outside edge; the kit's corrugated shed turns up on rooftops and in yards.

Rail and pipes

The solver has no say in either of these.

The rail line is a tramway between places. It starts at a portal on the edge of the map, where the line leaves town, crosses to another portal, and sends a spur to a loading point outside every mine-head building. Outside, never through the door, and never indoors at all. Below the street the same router lays a line per mine level, from the foot of each shaft out to the dead ends, with carts standing on the stops.

The pipe run is ornamental and routed all the same. It climbs out of the ground at one building, runs along the outside of a wall, turns the building's corner, takes a riser to the storey above, and crosses to the next building on a bridge three and a half metres over the street. It never goes inside either: a pipe hangs on an outside face by definition. Tall buildings also get a stack, a branch climbing to the top wall and ending in a vent.

Both used to be sockets, and both looked like it. A socket can say "a track may continue across this face", which is a purely local fact, so the line was locally plausible and globally meaningless: it started nowhere, ran three cells, and stopped because a stub happened to be cheap. Pipes were worse — a prop a fill module dropped on a floor, so no two of them ever met.

So neither is a socket now. Both are routed in ProceduralCity.net.core.ts by one Dijkstra over (cell, arrival direction), which is the bit that matters: carrying the arrival direction in the state lets a corner COST something. Going straight on costs one, turning costs four, so the cheapest path is a long straight with as few corners as it can manage — which is what a laid line looks like. Set netTurnCost: 0 in the scene and watch both networks start to wander; that is the old behaviour, and it is worth seeing once.

Neither network writes a cell. The solver fills the street exactly as it always did, and the line is drawn over the top through the kit's OVERLAY — the same route the hole railings have always taken.

The mine

Below the street is a second world, generated separately and joined deliberately. It starts as solid rock. Under some of the buildings — the mine heads — a chamber the size of the footprint is carved. Chambers are joined by corridors so every level is one connected network, and a few spurs wander off to dead ends where a cart sits on the last length of rail.

The seam between the two is the shaft: a stair or ramp run inside the chamber, arriving on a railed landing in the building's ground floor, exactly as a storey connector does upstairs. With mineLevels: 2 the deeper level shafts up into the chamber above it. A level no building can shaft down to is simply not dug. Nothing underground exists that you cannot walk to from the street.

Tunnels have their own module family. A tunnel wall is the tunnel-wall GLB flush with the rock; a house wall is a panel and a column. Even where the two share a floor slab they are different modules with the same sockets, so the solver can never put a room where a tunnel should be.

layers: "mine" spawns only the pieces below the street — the same plan, cut away. The ProceduralMine scene is that view.

How it decides

Three layers, and it matters which one owns what.

The atlas knows the kit. Every GLB was measured; half the floors were authored standing on edge and the atlas knows to lay them down. It composes pieces into modules — one cell's worth: a slab, a wall panel, and the column that makes a 1.34-wide panel span a 1.9-wide edge — and gives each module a socket on all six faces. Rotate a module and its sockets and its pieces turn together, so a wall is authored once facing +Z and the solver gets four.

One socket rule does most of the architectural work: a facade may only meet open. Two facades never touch. That is the whole reason a wall never appears between two rooms.

The rule layer decides structure before any dice are rolled: where the streets are, how big each footprint is, how tall, where the stairs go, which cell is the door. It paints every cell with a kind (street, yard, interior, deck, roof, hole, air) and pins a role where it needs a specific thing — a stair's lower cell, a landing, a door facing the street.

The solver fills in what is left. Which floor slab. Which wall panel. Where the track curves. Whether the balcony gets a railing or a parapet. It is wave-function collapse over the atlas's tiles: collapse the most-decided cell, propagate, repeat, restart on a contradiction. Domains are bitsets, so a 22×22 town with four storeys solves in a few tens of milliseconds.

Why split it this way? Because wave-function collapse on its own makes texture, not a town. Locally every pair fits; globally there is no reason for anything to be anywhere. The rules supply the reasons. The solver supplies the variety.

Drawing it

With the plugin registered, every file is loaded once and drawn at every placement in a single call per material: thin instances (an InstancedMesh on Three), fed from a matrix buffer the core computes. A project made with bjs create installs it for you: src/main.ts calls every adapter/register.ts that bjs download puts in the project, before the scene loads. If you boot the game from your own code, call it once:

TypeScript
import { registerProceduralCityExtension } from './Components/ProceduralCity/adapter/register';
const adapter = new BabylonAdapter();
await registerProceduralCityExtension(adapter);

The playground does the same. Without it (the mock, a headless test) the System spawns one Mesh entity per piece instead: the same town, ~1700 clones and the draw calls to match.

EngineChunks culledLOD switchShadowsStats + debug overlay
Babylonyesscreen-door cross-fadecast and receiveyes
Threeyesthe same screen-door, on a clone of each kit materialcast and receiveyes
Babylon Liteno: Lite 1.8 culls nothing on the CPU, so every chunk is drawna hard swap. Lite's material takes no custom shader code, so lodFade is ignored and a chunk popsreceive only. Lite's one way to add casters would replace the adapter's listno

Instancing removes draw-call cost, not vertex cost, and a town is ~50 million triangles of 30k-triangle pieces. Three knobs keep the GPU from drawing all of it every frame:

FieldDefaultWhat it does
chunkSize16Instances are split into cubes this wide so the frustum can cull what is off screen. One box per file would never cull.
lodKitUrl(none)A folder of lighter copies of the same files. Chunks past lodDistance draw those instead. The playground uses the decimated kit; a real LOD set works the same way.
lodDistance30World units from the camera at which a chunk shows its LOD copy.
lodFade0.2Seconds the two copies take to cross-fade once a chunk crosses lodDistance. Even copies that match on colour differ in silhouette, and a cube of pieces changing outline on one frame is a pop you see every time; a fifth of a second of screen-door dither hides it without ever being on screen long enough to read as a pattern. The switch itself has a 5% dead zone either side of lodDistance, so a chunk sitting at the line cannot flicker back and forth. Not alpha blending: nothing goes through the transparent pass and the two copies never overlap or leave a gap. 0 is a hard swap.

Floor slabs and everything in the mine never cast shadows: they shadow nothing you can see, and they are half the geometry of the shadow pass.

Events

DirectionEventPayload
emitsproceduralCity.built{ entityId, seed, solved, pieces, buildings, stories, mineLevels, tunnels, shafts, restarts, failure? }
listensproceduralCity.rebuild{ entityId?, seed? } — omit entityId to rebuild every city
listensmesh.loadedfrom Mesh, to apply each piece's exact pose

From the console in the playground:

JavaScript
game.eventBus.emit('proceduralCity.rebuild', { seed: 41 })

Fields

FieldDefaultWhat it does
seed7Everything follows from this.
width, depth22Grid size in cells.
maxStories3Tallest building. Heights are biased short.
blockSize7Cells between streets.
streetWidth2Cells per street.
deckChance0.5Open roof deck instead of tin roof.
rampChance0.35Ramps instead of stairs, where three cells fit.
mineLevels1Levels dug below the street. 0 for no mine.
mineDepth6Storeys of solid rock between the street and the first mine level — six is about a hundred feet at a sixteen-foot storey. Mine heads reach through it by a lift (one column of shaft cells, a caged platform at the bottom, a railed hole in the ground floor) or, stairShaftChance of the time, a stairwell: a chamber on every rock level with a stair run in each.
stairShaftChance0.3Fraction of mine heads that get a stairwell instead of a lift.
mineHeadChance0.6Buildings that get a shaft down. Never fewer than one per level.
tunnelBranches4Dead-end spurs carved off the corridors, per level.
railLinestrueLay the tramway. false leaves the streets bare.
pipeLinestrueLay the pipe run. false leaves the facades bare.
netTurnCost4What a corner costs the router, in cells. The only thing making these lines look laid rather than grown. 0 lets them wander.
pipeMaxSpan4Widest gap, in cells, a pipe will bridge between two buildings. 0 keeps every line on its own building.
pipeLowPenalty0.8What a pipe pays per cell for hugging the lowest wall. Without it the main never climbs: a crossing down here is cheaper than a riser up and back down.
layersallsurface or mine spawns half of the same plan.
kitUrl/mining-kit/Folder the GLBs are served from.
detailKitUrl/mining-kit/Where the small close-up pieces come from whatever kitUrl says: rail, cart and pipes. A twelve-triangle box is a fine wall panel and a ruined 60 cm rail. Empty to draw everything from kitUrl.
origin[0, 0, 0]World position of cell (0, 0, 0).
cellSize1.9World units per cell.
idPrefixcitySpawned pieces are city-0, city-1, … tagged city-piece.

Assumptions about the kit

A bounding box cannot tell you which way a staircase climbs. The kit file lists what was assumed — the stairs climb toward −Z, the ramp rises toward +X, upright floors show their textured face on +Z. If a piece shows up backwards, flip the one line in ProceduralCity.kit.mining.ts; nothing else needs to change.

A bounding box can mislead you outright, too. The track piece was declared to run along X because X was its longest edge, and it was wrong: histogram the vertices and the rail heads sit in two clusters 1.322 apart along X, while the sleepers each span the whole of X and repeat along Z. X is the GAUGE, Z is the direction of travel, and the piece is 1.481 long rather than 1.9 — Meshy normalised the gauge because the gauge happened to be the longest edge. Laid the old way, every length of track sat a quarter turn from its neighbour and too short to reach it. When a piece has a direction, measure it; the histogram takes a minute and a bounding box will happily lie to you.

Budget

The kit's pieces are about 30k triangles each and a town places over a thousand of them. That is a still render, not a frame rate. The playground scene points at a decimated copy of the kit (scripts/decimate-mining-kit.mjs in the umbrella); a shipped game wants proper LODs on the marketplace copies.

Other kits

Nothing here is mining-specific except the kit file. A second kit is a second list of pieces and modules that speaks the same socket vocabulary — open, facade, in, track, and the vertical slab / support / stairvoid — plus the roles the rule layer pins: door, stairLower, stairUpper, rampLower, rampMid, rampUpper, landing — each of the run roles in both an interior and a tunnel flavour. The kit tests check all of that for you.

A kit may also implement the OVERLAY, which is the vocabulary for the geometry the plan places rather than the solver: a hole railing, a cell of rail line, a cell of pipe run, a cell of pipe crossing. It is optional. A kit without one still builds the whole town and draws no rail, which is the right answer for a kit that has none.

More like this

Mesh
AmbientOcclusion
Asteroid
AsteroidField
BilliardRack
ChaseCamera6DOF
ChaseCameraTarget
FlightIntent
FreighterChain
GameConfig
LensFlare
NebulaSky
OilBlob
PinballPlayfield
PlayerFlightInput
ScreenSpaceReflections
ShipDamageFX
ShipFlight
SpaceDust
SpaceShooterFX
WaveDirector
Waypoint
WorldOriginAnchor

Was this page helpful?

We read every note — tell us what's working and what isn't.

↑↓ NavigateEnter SelectEsc CloseCtrl+K Open Search