Case study·I Was Here·Unity / WebGL·work in progress

A city built from painted cells

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.

seed — a sketch of the real generator, running in your browser

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

The city is the main character

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

From a grid of integers to a living block

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 truth
A flat array of cells: { moduleIndex, rotation, variant }. Roughly 12 bytes per cell of authored data; the rest is generated.
CityModule palette — pieces as data, not models
Road, crosswalk, sidewalk, grass and building are ScriptableObjects holding design parameters (floor height, window spacing, curb radius, lit-window chance). Editing the asset restyles every instance in the city.
Build(ctx, cell, cellSize, neighbours) — per-cell generation
Each module builds its geometry from raw quads and boxes in local space, informed by what its four neighbours are. This is where curbs, corners, facades and entrances get decided.
CityMeshContext — surfaces keyed by material
Geometry is bucketed by a quantized (color, emission, smoothness) key rather than by object. A lit window and an unlit one are just two different buckets.
CityBuilder.Rebuild — one merged mesh per material
All buckets are merged city-wide and emitted as a few combined meshes with generated URP materials — so draw calls scale with the number of distinct surface colors, not the number of buildings.

The interesting parts

Not a tile set — a rule set

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.

BuildingModule.cs — the entrance looks for a street to face
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
}

Draw calls scale with colors, not objects

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.

CityMaterialKey.cs
// 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.

Deterministic variety

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.

CityModule.cs
/// 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);
}

Painting, not modeling

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

Small on purpose

The project's design doc ends with: “the smaller the project remains, the stronger the experience becomes.” The generator follows that rule.

~2,000
lines of C#, total
17
source files
5
paintable modules
0
imported 3D models
0
texture files
≈12 B
authored data per cell

Status

Where it goes from here

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.