Case study·I Was Here·Unity / WebGL·work in progress
I Was Here is a quiet multiplayer world where the only other people you meet are the notes they left behind. It needed a city that feels designed, abandoned and slightly dreamlike — and that ships over the web. This is a look at CityKit, the procedural city generator I built for it: no imported models, no textures, an entire city in a handful of draw calls.
Every rebuild is a different city from a different seed — the same idea the Unity generator uses per cell. The pale scraps on the sidewalks are where the players' notes will live.
The project
There are no NPCs, no quests, no combat and no chat. Players never see each other — only traces: notes pinned to walls, left on benches, dropped in alleys. The design bar for every feature is one question: does this make the city feel more alive?
That puts unusual weight on the environment. The city can't be a backdrop bought from an asset store — it has to carry the whole experience, load fast in a browser, and stay easy for one person to iterate on. So instead of modeling a city, I built a system that grows one.
Constraint 01
WebGL first. Browser GPU budgets are tight — the whole city must render in a few draw calls, not a few thousand scattered objects.
Constraint 02
Atmosphere over asset count. Low-poly flat color, soft lighting, fog and emissive windows. Visual richness comes from light and composition, not polygons or textures.
Constraint 03
Author-friendly. Laying out a street should feel like painting, not modeling. Redesigning every building in the city should be a one-asset edit.
Architecture
The whole city is authored as a flat grid of tiny serialized cells — each just a module index, a quarter-turn rotation and a variant seed. Everything visible is regenerated from that grid on demand; no mesh data is ever saved.
CityGrid — the source of truthCityModule palette — pieces as data, not modelsBuild(ctx, cell, cellSize, neighbours) — per-cell generationCityMeshContext — surfaces keyed by materialCityBuilder.Rebuild — one merged mesh per materialThe interesting parts
Classic tile kits need dozens of hand-made variants: straight sidewalk, corner sidewalk, sidewalk-next-to-building, and so on. CityKit has one sidewalk module that inspects its neighbours and reshapes itself:
Even doors participate: a building scans its neighbours for a road (then a sidewalk) and puts its entrance on that wall, so shopfronts naturally face the street.
static int PickEntranceWall(in CellNeighbours n, System.Random rng) { for (int pass = 0; pass < 2; pass++) { var want = pass == 0 ? CityModuleCategory.Road : CityModuleCategory.Sidewalk; if (n.north == want) return 0; if (n.east == want) return 1; if (n.south == want) return 2; if (n.west == want) return 3; } return rng.Next(0, 4); // landlocked: pick any wall }
Every quad a module emits is tagged with a material key — flat color, optional emission, smoothness. Keys are quantized to 8 bits per channel so float jitter can never spawn near-duplicate materials, and all geometry sharing a key across the whole city is merged into one mesh. A full city of buildings with mullioned windows, balconies, roof props and painted crosswalks lands at a few dozen draw calls — the number of distinct surface colors on screen.
// Quantize to bytes so float jitter never spawns duplicate materials. static int Q(float v) => Mathf.Clamp(Mathf.RoundToInt(v * 255f), 0, 255);
The lit windows that carry the night atmosphere cost nothing special: an emissive key is just another bucket, so all lit windows in the city are one mesh and one material with bloom-friendly emission.
Buildings vary in height (2–6 floors), facade style (plain, banded or pilastered), lit-window pattern, balconies and rooftop props — water tanks, AC units, antenna masts. All of it derives from the cell's variant seed through a hashed RNG, so a rebuild always produces exactly the same city. Players can leave a note under a particular window and that window will still be there tomorrow.
/// Deterministic RNG so a cell's variant always rebuilds identically. protected static System.Random RngFor(in CityCell cell) { int seed = cell.variant * 73856093 ^ (cell.rotation + 1) * 19349663; return new System.Random(seed); }
A custom inspector turns the Scene view into a canvas: toggle paint mode, click and drag to lay roads and blocks, press R to rotate the brush, hold Shift to erase. The city rebuilds live under the brush. Grid resizes preserve everything still in bounds, and the whole layout survives in the scene file as that tiny cell array.
This is the payoff of pieces-as-data: laying out a neighbourhood takes minutes, and because no meshes are persisted, iterating on the building design never invalidates the map.
By the numbers
The project's design doc ends with: “the smaller the project remains, the stronger the experience becomes.” The generator follows that rule.
Status
CityKit is the foundation layer of an unfinished game, and it's built to stay out of the way of what comes next: the note system. Notes will be physical objects at real positions in this city — written, found, moved and occasionally thrown away by people who will never meet. The deterministic world is what makes that promise possible: the place where you left something will still exist when someone else arrives.
Next up: the rain, the fog, and the first note.