Adding Zoning to a UE5 Runtime City Builder
30 May 2026 · Devlog
CityGen is my runtime C++ city-builder plugin for Unreal Engine 5. Until now it let the player draw roads and place buildings one at a time. In V0.6 I added zoning: you paint zones along your roads and the system auto-fills them with buildings, the same way Cities Skylines does it. This article covers the zone data asset and its weighted building pool, the cell grid each road carries, the two-pass auto-fill, and the fixes that turned it from a demo into something that holds up.
From one building to a painted block
The previous update, Buildings V0.5, gave us:
- Click to place buildings (B key)
- Cities-Skylines-style grid snap aligned to the road
- OBB collision and slope refusal
- A ghost preview: green for valid, red for invalid
Roads draw the streets, and buildings let you place one at a time. Zoning is the third verb: paint a stripe along the road and the system fills it with houses. Zoning V0.6 adds:
- Painting zones along roads (Z key)
- Weighted building pools per zone
- Auto-fill: paint a cell, get a building
- Multiple building variants per asset
- Erasing a zone removes its buildings
- Junction awareness, so zones skip crossing roads
- HUD-driven control, with one button per zone type
Everything in this update
Most of this update is zoning, and that’s what the rest of the article covers. The other items are supporting plumbing and marketplace pre-submission cleanup. Each of those could be its own article, so I’m listing them here so you know what shipped, then moving on.
Zoning
- Zone data asset, cell-grid painter and weighted pools
- Auto-fill on paint, removal on erase
- Mesh variants per building and junction suppression
Plumbing
- Save and load buildings into the save game (F5 / F9)
- Demolish (X) now works on buildings as well as roads
- Random per-placement building height (
ZScaleRange) - HUD API: Blueprint-callable
Toggle*Modefunctions plus a delegate - HUD entry points:
EnableZoneModeandDisablePlacementMode - Cleaner 90° road bends using a single-anchor fillet arc
Fab pre-submission
- Four header renames to avoid name clashes
- Fixes to the module’s
IMPLEMENT_MODULEarguments - Transitive-include fixes
BuildPluginruns clean on UE 5.4, 5.6, 5.7 and 5.8
The zone data asset
Each zone type is a single data asset:
UCityGenZoneAsset ├─ Type Residential / Commercial / Industrial / Custom ├─ Color overlay tint shown on painted cells ├─ BuildingPool TArray<{ Asset, Weight }> │ weighted random pick on every spawn ├─ Density 0..1 target occupancy └─ MaxBuildingsPerSegment
BuildingPool example: { DA_Building_Suburban, Weight = 1.0 } ← common { DA_Building_Townhouse, Weight = 0.6 } { DA_Building_Apartment, Weight = 0.2 } ← rareThe interesting field is BuildingPool. Every entry has a weight, and the picker rolls a uniform random number across the cumulative weight sum. Drop in three building assets at weights 1.0, 0.6 and 0.2 and you get a believable mix of houses, townhouses and the occasional apartment block. Retuning the mix needs no code change; you just edit the weights.
A cell grid on every road
Every road segment carries its own 2D grid of zone-asset slots:
Each road segment owns TArray<FCityGenZoneCellRow> ZoneRows
ZoneCellSize = 400 cm (same as building grid) ZoneDepth = 4 cells per side
── ROAD CENTRELINE ── ↓ ┌──┬──┬──┬──┐ ← RIGHT side, 4 cells deep │ │ │ │ │ row→ ├──┼──┼──┼──┤ │ │ │ │ │ ╞══╪══╪══╪══╡ ← road │ │ │ │ │ row→ ├──┼──┼──┼──┤ │ │ │ │ │ └──┴──┴──┴──┘ ← LEFT side, 4 cells deep
Cells flow along arc length × 2 × ZoneDepth across.Rows run along the road’s arc length and columns go perpendicular to it, four cells deep on each side by default. The cell size matches the building tool’s grid exactly, so when buildings drop into painted cells they snap to the same lines the player has been seeing all along.
Paint mode
Press Z → enter Zone Paint mode wireframe cells drawn along every road distance-culled to 60 m around the cursor
Left-click → paint a 4 × 4 brush of cells on the clicked side, in the active zone's colour Left-click again → re-paint on top (no-op if same zone) Click w/ null zone → eraser, clears the 4 × 4 brush Press Z again → exit paint modeThe cells are drawn as wireframe boxes only, because solid fills tanked the framerate at hundreds of cells. The 60 m cull keeps the draw count bounded as the road network grows. Brush size is exposed on the player controller, and 4 × 4 is the default because it matches a typical residential block depth.
Auto-fill: paint a cell, get a building
This is the interesting part. Every paint click runs a two-pass update on the affected segment:
On every paint click, run ApplyZoneAutoFillForSegment(SegIdx):
PASS 1 — REMOVAL For every auto-spawned building tagged with this segment, check its anchor cell. If the cell is no longer zoned, destroy the building. ← manual buildings always survive.
PASS 2 — SPAWN For each (row, side, depth) in walk order: if depth is the SMALLEST painted depth in this row-side and no auto-spawned building is anchored here yet: - pick a building from BuildingPool (weighted random) - anchor near-road edge at this depth's inner edge - advance walker by building's FootprintCells.YRemoval runs first so we never spawn on top of a stale building, then the spawn pass runs. The anchor rule is that a building’s near-road edge sits at the smallest painted depth, so buildings hug the road and grow outward into the deeper cells. A 1 × 1 cottage sits right at the curb; a 5 × 5 warehouse anchors at the curb and extends four cells inland. Both feel right.
Origin cells make erasing sane
The eraser needs to know which building came from which cell, so every auto-spawned building is tagged with its origin when it spawns:
Every auto-spawned building is tagged at spawn time:
FCityGenBuilding ├─ bAutoSpawned = true ├─ ZoneSegIdx = which road segment ├─ ZoneRowIdx = which row along it └─ ZoneSideDepth = which column
Erase a cell → loop auto-spawned buildings on this segment → match anchor → destroy building & mesh → manual (bAutoSpawned=false) buildings untouched
Demolish (X) an auto-spawned building → also clears every cell it visually covered (full FpX × FpY footprint, not just anchor)Because each auto-spawn records its origin row, side and depth, erasing a cell finds the right building in O(buildings on this segment) without any spatial lookup. Buildings placed by hand with the B key are flagged bAutoSpawned = false, and the eraser ignores them entirely. They survive zone changes around them, the way a hand-placed landmark should.
Mesh variants
The building asset now supports several meshes and a per-placement height roll:
UCityGenBuildingAsset ├─ Mesh ← legacy fallback (single mesh) ├─ MeshVariants[] ← TArray<UStaticMesh*> ├─ ZScaleRange ← per-placement uniform Z scale (e.g. 0.8..1.3) ├─ FootprintCells ← shared by every variant └─ MaxSlopeDegrees ← shared by every variant
PickMesh() returns a uniformly-random non-null entry from MeshVariants, or Mesh if MeshVariants is empty.
Saved on FCityGenBuilding → reload picks the SAME variant AND the SAME Z scale that were rolled at place time.One residential asset with five house meshes and a Z-scale roll between 80% and 130% is enough variety to stop a painted residential block looking like a copy-paste. The variant is picked once at place time and saved on the building, so reloading a city brings back the exact mix of houses you had, with no re-rolling on every load.
Three things that took a while
These issues didn’t show up until the system was running. None of them are headline features, but all three are the difference between a demo and something shippable.
Junction overlap
Without this, painting along one road tiled cells over a perpendicular road. Now any cell whose centre lies within HalfWidth of any other road segment is skipped, both in the paint visualisation and during auto-fill spawning. No buildings end up in intersections.
Accurate ground Z
GetHeightAtWorldPos() is analytic (noise plus flatten), and in places it drifts below the rendered mesh. Because the sample didn’t match the mesh, buildings used to spawn buried, with half of them knee-deep in dirt. The fix is to use the cursor raycast Z instead: auto-fill uses the cell’s polyline Z, and manual placement uses the cursor hit Z directly. SpawnZ is also persisted on the building, so save and load don’t re-bury anything.
Dezone on demolish
Demolishing an auto-spawned building with X now also clears the full footprint of cells it occupied, not just the anchor cell. A 5 × 5 building demolish clears up to 5 × 4 = 20 cells. Without this, the painted cells stayed painted but were impossible to refill, because auto-fill thought the slot was still occupied. Manual buildings still demolish but don’t dezone, since the zone was never theirs.
The HUD: one button per verb
┌─────────────────────────────────────────────────┐ │ │ │ │ │ GAME VIEWPORT │ │ │ │ │ │ ┌────┬────┬────┬────┬────┬────┬────┐ │ │ │Road│Resi│Comm│Indu│Demo│Save│Load│ │ │ └────┴────┴────┴────┴────┴────┴────┘ │ └─────────────────────────────────────────────────┘
Road → ToggleRoadMode() Resi → EnableZoneMode(DA_Zone_Residential) Comm → EnableZoneMode(DA_Zone_Commercial) Indu → EnableZoneMode(DA_Zone_Industrial) Demolish → ToggleDemolishMode() Save → QuickSave() Load → QuickLoad()
All buttons listen to OnPlacementModeChanged → highlight the active one without polling.There are seven buttons, one per verb. The three zone buttons all call EnableZoneMode: same function, different zone asset. Clicking Commercial while Residential is already active doesn’t toggle zoning off; it swaps the active asset and keeps you in paint mode.
Every button binds once to OnPlacementModeChanged. When the mode flips, whether by key, by another button or by DisablePlacementMode, every button updates its highlight in one pass. There’s no per-tick polling and no manual state sync.
What’s next
V0.6 delivers the zone data asset with weighted pools, the per-road cell grid with Z-key paint mode, the 4 × 4 brush and eraser, weighted auto-fill, origin-cell tracking, mesh variants with per-placement Z scale, junction overlap suppression, accurate cursor-Z placement, full-footprint dezone-on-demolish, and the EnableZoneMode / DisablePlacementMode HUD API. Coming next:
- Density-driven sparse fill (the current fill is a greedy 100% fill)
- Per-zone height bias, with industrial taller and residential lower
- Diagonal and corner zone footprints
- Foliage clearance under auto-spawned buildings
- Zone painting onto the world-axis grid for off-road fills
- Decals or vertex colour for the painted zone overlay
Subscribe on YouTube for the next devlog video.
Subscribe104k