POLIS technical analysis↗ showcase
Engineering teardown

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.

⚙ Vanilla JS + Canvas 2D 📄 One file · ~13k lines 🚀 Zero dependencies · no build 🧪 Playwright invariant suite 🌐 polis-game.com
1 · Design constraints

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.

2 · File anatomy

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.

RegionContents
<head> + CSSDesign 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

3 · Data model

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.

pollutionleisurecrimelandVal cover.policecover.firecover.schoolcover.healthcover.transit

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.

Data-driven by table. Tile behaviour lives in lookup tables (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.

4 · The simulation loop

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.

Fire + rubblespread & cleanup
Per-cell growthlevel up / decay
Aggregatepop · jobs · edu · crime
Demand + moodrecompute RCI
Every 30 daysbudget + autosave

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).

5 · Growth model

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.

  1. Adjacent road — frontage onto a road (roads carry cars, power and water).
  2. Power & water — both must flood-fill to the tile through the network.
  3. Demand — positive RCI demand for that zone type.
  4. Land value — over rising thresholds (industry is exempt — its own pollution suppresses value).
  5. 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)
};
6 · Networks

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.

7 · Economy

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.

LineFormula
Residential taxpop × tax × 0.26
Job taxjobs × tax × 0.36
+ Tourismfrom working airports, scales with population
− Roadsflat 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)
− ServicesUPKEEP × fund × 2.6 × svcLoad × diff per service — svcLoad grows the caseload with population
− Administrationmax(0, pop − 500) × 1.6 × diff — flat per-head civic overhead; road wear + service load now share this size brake
− Loansamortized monthly
= NetS.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.

8 · Renderer

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.

Picking. Mouse/touch hit-testing renders an off-screen colour-id pass: a cell index (<65536) is packed into R+G, with a nonlinear hash in B so antialiased edge pixels fail the check and are discarded — exact tile selection without raycasting.
9 · Agents

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.

10 · Map generation

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:

ArchLand
0 / 1meandering north–south or east–west river
2great lake + a thin brook
3twin streams
4open sea hugging one coast (wavy shoreline + bays)
5pure 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.

11 · Persistence

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.

Never reorder or remove 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 (TLV5TLV6) and keep reading the old ones. Persistence goes through a store abstraction (artifact API → localStorage fallback); autosave fires monthly.

12 · Audio

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.

13 · Testing & CI

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
Verify the consequence, not the artifact. The dangerous bug class here is an entity registered in several decoupled tables where one registration silently fails (valid JS, boots fine, draws fine, does nothing). So tests drive the sim and assert the effect — e.g. a lot within reach of a water source actually becomes wt — not just that the tool exists.

14 · Performance

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:

15 · Extending the engine

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.

After mutating the map, refresh derived state: recomputeNets() (if connectivity changed) → recomputeFields()dirty = true. Forgetting dirty is the usual cause of "my change doesn't show up."
16 · Deploy

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.