A browser-based SimCity 4-style isometric city builder, built with TypeScript, Vite, Preact, and PixiJS v8 (WebGL).
pnpm install
pnpm dev # Vite HMR dev server → http://localhost:5173
pnpm typecheck # tsc --noEmit
pnpm test # Vitest unit tests
pnpm test:watch # Vitest in watch modepnpm build # → dist/
pnpm preview # serve dist/ locally
rsync -avz --delete dist/ user@vps:/var/www/webcity/nginx serves dist/ as a static SPA with /index.html fallback. SSL via Certbot.
| Input | Action |
|---|---|
| Left-click / drag | Place selected tool |
| Middle or right-mouse drag | Pan camera |
| Scroll wheel | Zoom (snaps to 0.5× / 1× / 2× / 4×) |
| Click minimap | Jump camera to that location |
| Drag minimap | Scroll camera continuously |
| Key | Tool |
|---|---|
1 |
Residential zone |
2 |
Commercial zone |
3 |
Industrial zone |
R |
Road |
L |
Power line |
P |
Power plant (coal) |
O |
Police station |
W |
Water tower |
B |
Bulldoze |
Escape |
Deselect tool |
+ / - |
Zoom in / out |
- Isometric camera with pan, zoom, and elevation-aware click hit-testing
- Single sortable
worldContainer; painter's-algorithm viazIndex = (col+row)*3 + layer - Terrain:
PIXI.Graphicssolid-color diamonds with per-tile hash color variation - Roads and buildings:
PIXI.Spritebacked by textures pre-baked at startup intileTextures.ts(~100 textures via OffscreenCanvas → ImageBitmap → PIXI.Texture) - Dirty-tile tracking:
world.dirty: Set<number>drained each frame — only changed tiles rebuild - Minimap with dirty-flag bake, live viewport indicator, and click/drag navigation
- Minimap rendered into a dedicated
HTMLCanvasElement(Canvas 2D) and uploaded as PixiJS texture each frame
- Zone growth (R/C/I) — 9 density stages; requires power + road access within 2 tiles + water
- Power network — BFS flood-fill from power plants (coal, gas turbine, nuclear, solar, wind)
- Water network — BFS flood-fill, 20-tile range from Water Tower
- Crime — coverage radius from Police Stations; affects zone happiness
- Fire — coverage radius from Fire Stations; fire spread simulation
- Traffic — approximate load via zone-density convolution
- Land value — distance-decay from parks, services, water bodies
- Pollution — diffusion grid from industry tiles and traffic load
- Disasters — random disaster event triggers
- Annual budget — zone tax revenue, building upkeep, deficit warning; city log entry each year
| Building | Power/coverage range |
|---|---|
| Coal Power Plant | 50 tiles |
| Gas Turbine | 40 tiles |
| Nuclear Plant | 80 tiles |
| Solar Farm | 30 tiles |
| Wind Turbine | 25 tiles |
| Water Tower | 20 tiles |
| Police Station | 25 tiles |
| Fire Station | 20 tiles |
| Hospital | 20 tiles |
| School | 15 tiles |
| Library | 10 tiles |
- SC4-style toolbar: category rail + flyout panels (zones, infrastructure, services, power, bulldoze, options)
- Placement costs deducted from funds on each tile placed
- Speed control: ⏸ / 1× / 3× / 10×
- Bottom bar: year, population, funds
- City log: year-end budget summary, population milestones, disaster alerts
- Options panel: New City, Save City, Load City (named saves via IndexedDB)
- Budget, data layer, advisor, graph, and zone info panels
utils/floodfill.ts— typed-array BFS (power, water networks)utils/astar.ts— A* road path-finding +isRoadConnectedconnectivity check
Two independent loops:
- Render —
requestAnimationFrameat ~60 fps; callsrenderer.draw() - Sim tick —
setIntervalat 1–10 Hz (speed-controlled); 12 ticks = 1 game year
Engine init is async: new Engine(canvas) is sync, await eng.init() creates the PixiJS app, then eng.start() begins the RAF loop.
Coordinate system: (col, row, elevation) → isoCamera.worldToScreen() → screen (px, py).
Tile size at 1×: 64 × 32 px. Each elevation level = +8 px vertical offset.
| Module | Role |
|---|---|
core/engine.ts |
Game loop, speed control; async init() creates PixiJS renderer |
core/world.ts |
128 × 128 tile grid; world.dirty Set tracks changed tiles |
core/tile.ts |
Tile type enums, ActiveTool |
core/events.ts |
Typed event bus |
rendering/isoCamera.ts |
World ↔ screen transforms, pan, snap-zoom |
rendering/renderer.ts |
PixiJS Application, single sortable worldContainer, dirty-tile processing |
rendering/tileTextures.ts |
Pre-bakes all building + overlay textures at startup: OffscreenCanvas → PIXI.Texture |
rendering/tileRenderer.ts |
Canvas 2D drawing primitives — called by tileTextures.ts |
rendering/minimap.ts |
Canvas 2D minimap; draws at (0,0) into a dedicated HTMLCanvasElement |
simulation/simManager.ts |
Tick coordination |
simulation/zones.ts |
RCI demand + density growth |
simulation/power.ts |
Power BFS |
simulation/water.ts |
Water BFS |
simulation/economy.ts |
Annual budget |
simulation/crime.ts |
Police coverage model |
simulation/fire.ts |
Fire coverage + spread |
simulation/traffic.ts |
Road load (density convolution) |
simulation/landValue.ts |
Distance-decay desirability |
simulation/pollution.ts |
Industry/traffic diffusion grid |
simulation/disasters.ts |
Disaster event triggers |
data/buildings.ts |
Building costs and upkeep |
data/worldGen.ts |
Simplex-noise terrain generation |
Buildings render from a sprite atlas (public/sprites/atlas.png + manifest.json),
with procedural OffscreenCanvas sprites as the fallback for any missing key.
The current atlas is generated from Kenney's CC0 City Kits (Suburban,
Commercial, Industrial). The .glb models are batch-imported, re-origined to the
tile grid, and rendered at the in-game 2:1 dimetric angle by a headless Blender
script, then packed:
blender -b -P tools/blender/import_kenney.py -- \
--config tools/blender/kenney_packs.json --out tools/assets-src
pnpm build:atlasSee tools/README.md for the full asset pipeline and the geometry contract.
Building 3D art: Kenney — City Kit (Suburban), City Kit (Commercial), and City Kit (Industrial), all released under CC0 1.0 (public domain). Crediting Kenney is not required by the license, but very much appreciated — consider supporting their work at kenney.nl/donate.
See TODO.md for the full feature roadmap.
Visual goal: SimCity 4 aesthetic — Blender-rendered sprite atlases (currently sourced from the Kenney City Kits above), with procedural sprites as fallback.