How POLIS is built
POLIS is a complete isometric city-builder — renderer, simulation, economy, generative audio, save system, PWA — implemented as a single, dependency-free index.html of vanilla JavaScript and Canvas 2D. This document is an analysis of that machine: its data model, its simulation loop, how it draws and persists a city, and the conventions that keep ~13k lines of global code maintainable.
One file, on purpose
The defining constraint is self-imposed: the entire game must run by opening one HTML file. No bundler, no npm, no framework, no JSX, no transpile step. The only external resource is a Google Fonts <link>, and it degrades gracefully to system fonts.
That constraint shapes everything downstream. There are no modules, so every symbol is global and the code is navigated by symbol name, not import graph. There is no virtual DOM, so the UI is hand-wired DOM plus a single <canvas>. There is no asset pipeline, so all art is procedural geometry and all audio is synthesized at runtime. The payoff: instant load, trivial hosting (a static file), and a codebase you can read top-to-bottom.
🧩 Self-contained
HTML + CSS + one <script>. Ships by copying a file.
🎨 No assets
Buildings are drawn from primitives; music/SFX are WebAudio.
🌍 Global by design
State S, grid map, and lookup tables are top-level.
Inside index.html
The file is three contiguous regions. Line numbers drift as the file grows, so they are approximate — the engine is navigated by the named entry points below.
| Region | Contents |
|---|---|
<head> + CSS | Design tokens as CSS custom properties at :root; all styling for HUD, panels, modals. |
<body> | The DOM skeleton: the canvas #c, the HUD, tool palette, overlays bar, and every modal. |
<script> | The entire engine — state, tables, simulation, renderer, audio, save/load, input, PWA. |
The three entry points
loop()— the requestAnimationFrame frame loop; drives both sim stepping and rendering.simTick()— advances the simulation by exactly one game day.render()— draws one frame via painter's algorithm.
The shape of a city
Three structures hold the whole world: the cell grid, a set of scalar fields, and the global state object.
The grid — map[y][x]
A 2-D array of cells. Grid size G is selectable (32 / 64 / 128) and can be grown afterward up to MAX_G = 256. Helpers idx(x,y)=y*G+x flatten coordinates and inB(x,y) bounds-checks. Each cell carries its type, level, growth, network status, and render hints:
{ t:type, lv:0–7, dev:growth progress, pw:powered, wt:watered,
ws:waste-collected, fire, vary:render noise, grp:[rootX,rootY],
part:bool, bld:construction%, bridge, bus, rz:rubble-origin, tunnel }
Scalar fields — Float32Array(G*G)
Derived spatial data, recomputed in recomputeFields() and read by both the sim and the overlays. A splat() helper paints a radial falloff to accumulate coverage.
Land value is the keystone field: it blends pollution, traffic, service coverage, leisure, nearby water/trees, and crime into the single number that gates high-tier growth.
Global state — S
One object holds the run: money, tax, day, speed, diff, pop, jobs, happy, the RCI demand{r,c,i}, the camera {zoom, ox, oy, rot}, the active tool / overlay, milestone, service fund{…}, and power/water caps.
ZONE, SVC, CONDUCT, CAP, PLANT, WSRC, LEIS, UPKEEP, ROADTYPE…), keyed by type. A system acts on a tile only if its type is a key in that system's table — presence is the wiring.
A day in one tick
loop() runs on requestAnimationFrame. A fixed-step accumulator calls simTick() once per game day; the step length comes from the speed setting: TICK_MS = [∞, 560, 280, 130] (pause / slow / medium / fast). Critically, rendering is decoupled from simulation — a frame governor (PERF.saver) may throttle redraws to save battery, but the sim always runs at full fidelity regardless of frame rate.
The per-cell pass is the hot loop: O(G²) every day. To avoid redundant work it coalesces field refreshes (an RF_DEFER flag batches every per-level-up recomputeFields() into a single pass flushed at the end), decays the traffic field in place, and skips tower part cells (handled via their grp root).
How a lot grows
This is the heart of the game. A zoned lot (res/com/ind) accumulates a hidden dev score and levels up only when every gate is satisfied; miss one and it stalls, miss it for long and dev bleeds back down, dropping a level.
- Adjacent road — frontage onto a road (roads carry cars, power and water).
- Power & water — both must flood-fill to the tile through the network.
- Demand — positive RCI demand for that zone type.
- Land value — over rising thresholds (industry is exempt — its own pollution suppresses value).
- Education & leisure — high tiers also require an educated workforce (
S.edu) and nearby amenities.
Towers: merge & unmerge
Levels 1–3 are single tiles. A mature level-3 lot calls tryMerge() on three neighbors to form a 2×2 tower (grp = root coordinate; the other three become part cells contributing zero — the root holds the whole block's capacity). The block can then rise to skyscraper, highrise, and megatower. Lose power or fall into gridlock and unmerge() breaks it back down — or to rubble after a fire.
Capacity — CAP
Population/jobs per type per level. For levels 4–7 the value is per 2×2 block, so peak residential density is 1200 ÷ 4 = 300 residents/tile.
const CAP = {
res:[0, 8, 22, 48, 210, 380, 700, 1200],
com:[0, 6, 16, 36, 160, 310, 560, 950],
ind:[0, 9, 24, 42, 185, 300, 460, 460], // ind tops out at lv6 (megaplant)
};
Power, water & traffic
Roads are the nervous system: they carry cars, power and water simultaneously. Two flood-fills and one decaying field decide whether a city thrives or chokes.
⚡ Power
recomputePower() flood-fills conductivity (CONDUCT) from plants (PLANT) across roads and buildings. Each plant type has a reach and a smog cost.
💧 Water
recomputeWater() floods from sources (WSRC): pumps need a riverbank, towers work anywhere. Same network shape, different source set.
🚗 Traffic
A Float32Array decayed each day. congestion = traffic / ROAD_CAP (road:40, avenue:120); gridlock throttles growth and land value.
Both flood-fills are invoked together via recomputeNets(). They only need to run when connectivity changes (a tile added/removed from a network), so callers gate them rather than running every frame.
The budget identity
computeBudget() runs every 30 days inside simTick. The accounting is a clean identity — income minus the three cost lines — which is exactly what the test suite asserts.
| Line | Formula |
|---|---|
| Residential tax | pop × tax × 0.26 |
| Job tax | jobs × tax × 0.36 |
| + Tourism | from working airports, scales with population |
| − Roads | flat upkeep (road 0.95 / avenue 2.85 / path / bus / rail) + Σ congestion × WEAR — busy networks wear out faster (avenues priced as 3 roads in one tile) |
| − Services | UPKEEP × fund × 2.6 × svcLoad × diff per service — svcLoad grows the caseload with population |
| − Administration | max(0, pop − 500) × 1.6 × diff — flat per-head civic overhead; road wear + service load now share this size brake |
| − Loans | amortized monthly |
| = Net | S.net = income − roadCost − svcCost − adminCost − loanPay |
Everything scales by the difficulty multipliers in DIFF (income, upkeep, growth, crime, disaster rate, grant size, loan rate) and a time ramp (kmRamp()). Loans come from LOAN_OFFERS with takeLoan/repayLoan.
Drawing in isometric
A fixed 2:1 isometric projection with tile size TW=64, TH=32:
isoX = (x − y) · TW/2
isoY = (x + y) · TH/2
Each draw* function receives a tile's screen anchor sx,sy; the ground plane of a point at grid offset (ox,oy) is reconstructed with the same math (scaled by S.zoom). World height projects straight up the screen — verticals stay vertical, drawn by subtracting pixels. render() walks the grid in painter's order (far to near) across four rotations.
Procedural geometry
There are no sprites. Buildings are assembled from primitives — box, vol, cyl, windows, gable, archRoof, quadF — which place corners, faces and shading on the iso axes correctly. Per-type draw functions (drawRes/drawCom/drawInd, drawTowerTile, drawBigBuilding, drawVenue, civic, drawAirport) compose those primitives.
Atmosphere
The same scene is modulated by day/night (duskAmt), weather (WX, drawPrecip), and seasons (SEAS, updateSeason) — recoloring light, adding glow to windows, and dressing the ground.
Things that move
On top of the static city, lightweight agent arrays animate life: cars[] (stepCars), peds[], planes[], trains[], plus service fleets and rooftop helicopter flights. Cars route on a per-tile cost field (base cost × a congestion penalty) and keep civilized following distances via per-type half-lengths. Developed lots emit commuters onto nearby streets each day, feeding the very traffic field that then gates their growth.
Seeded terrain
The same pure, seeded generator produces both the playable map and the map-picker thumbnails — so what you preview is what you get. Each land rolls a water archetype from the full spread, then fills forests to a target coverage:
| Arch | Land |
|---|---|
0 / 1 | meandering north–south or east–west river |
2 | great lake + a thin brook |
3 | twin streams |
4 | open sea hugging one coast (wavy shoreline + bays) |
5 | pure lake(s), no river |
Rivers are carved along layered sine curves with a connectivity constraint (each row's channel must overlap the previous row's, so flow never breaks into corner-touches). Terrain abundance is tunable before committing via three dials — terrainWater, terrainMtn, terrainTrees — and everything bakes into cells/elevation so a saved city keeps its land. expandWorld(pad) grows the grid outward (the menu adds 4 tiles per side per click), continuing the coastline straight out from the nearest edge, up to MAX_G = 256.
The save format
makeSave() / loadSave() encode a city as TLV5.<base64(JSON)>. The JSON carries per-cell tuples plus the relevant S fields (and, in TLV5, player-sculpted terrain heights eh):
per cell: [ typeIndex, lv, dev, overlayBitmask ]
Tile types are serialized by array index into TYPE_IDS, which makes ordering load-bearing.
TYPE_IDS entries — only append. The index is the on-disk identity; reordering silently corrupts every saved city.The loader is version-aware and backward compatible: it branches by prefix on stride and bitmask (TLV2 stride 3, TLV3 adds an enum 4th value, TLV4/TLV5 use the overlay bitmask). If you change the cell payload shape, bump the prefix (TLV5→TLV6) and keep reading the old ones. Persistence goes through a store abstraction (artifact API → localStorage fallback); autosave fires monthly.
Generative sound
Nothing is pre-recorded. audioInit() spins up WebAudio; musicLoop() composes the soundtrack live across 16 styles (Ambient, Dynamic, Classical, Lo-fi, Chiptune, Synthwave, Aegean, Jazz, Techno, Cinematic, Vangelis, Funk, Bossa, Reggae, Disco, Tango) with a shuffle mode, alongside synthesized placement SFX and an ambient soundscape that shifts with the city, day, and weather. Volumes and style persist via store.
Asserting invariants
A headless Playwright suite in tests/ loads the real index.html over file:// — so every global (S, map, simTick, makeSave, EXAMPLE_CITY, …) exists exactly as in play. The fixture boots the page, sets S.speed = 0 (so TICK_MS[0] = ∞ freezes the accumulator), and hands tests a page-context eval API.
Crucially, the specs assert invariants, not golden numbers: save round-trips, the budget accounting identity, growth gating, network flood-fill reachability, loan amortization. That lets honest balance tuning stay green while still catching real regressions. Determinism uses a test-only RNG seam (__seedRng/__unseedRng); normal play uses Math.random.
cd tests
npm ci
npx playwright install --with-deps chromium
npm test # CI runs this on every push / PR
wt — not just that the tool exists.
Keeping 256² fluid
The cell grid drives an O(G²) daily pass, which is why MAX_G caps at 256 (≈ 65k cells). Several seams keep it smooth:
- Sim/render decoupling — the frame governor can drop redraws to ~20fps in power-saver without touching sim cadence.
- Cached ground layer — coastline/terrain geometry is rebuilt only when
gndVerbumps, not per frame. - Deferred field refresh —
RF_DEFERcollapses many in-looprecomputeFields()calls into one. - Dirty flag — the renderer skips work entirely when nothing changed; mutations set
dirty = true. - DPR-aware canvas — device pixel ratio is clamped (and lowered further in power-saver) to bound fill cost.
Adding a tile type
Because the engine is data-driven, a new tile type only fully exists once it is registered at every site below. The absence of a key is invisible: a building missing from WSRC (or PLANT/SVC/LEIS/…) is silently skipped by that system — it draws perfectly and does nothing.
- Append the id to
TYPE_IDS(never reorder) — save compatibility. - Add a display name in
NAMES. - Register trait tables:
ZONE/SVC/ROADTYPE/WALKTYPE/CONDUCT, plusUPKEEP, andCAP/PLANT/WSRC/LEIS/FOOTas applicable. - Add it to the
TOOLScatalog (cost, icon, group). - Give it a draw path in
render()/ adraw*function. - Handle special placement rules in
place()if needed. - Confirm save/load round-trips it.
recomputeNets() (if connectivity changed) → recomputeFields() → dirty = true. Forgetting dirty is the usual cause of "my change doesn't show up."Commit & push
Hosting is as simple as the build: a static site on Cloudflare Pages (project polis-game). A push to main runs the deploy workflow; the same build is served at polis-game.com and polis.pixelagora.com, the former being canonical. index.html is the document root. Deployment is git commit + git push; the live site updates within ~1 minute. CI runs the Playwright suite on every push/PR so a broken index.html is caught before (or alongside) production.
POLIS — an isometric city builder in one file. This page is the engineering companion to the player showcase.