diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index da0a394..47d93fc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -46,6 +46,27 @@ jobs:
path: coverage/
retention-days: 7
+ gridlock:
+ name: GRIDLOCK Simulation Tests
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # WHY node 20 and no install step: the GRIDLOCK simulation core is
+ # dependency-free ES modules on the built-in node test runner, so it needs
+ # neither the RN toolchain nor npm ci.
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Test simulation core
+ working-directory: games/gridlock
+ run: node --test
+
android-build:
name: Android Build Check
runs-on: ubuntu-latest
diff --git a/README.md b/README.md
index 5a6ca06..1941d42 100644
--- a/README.md
+++ b/README.md
@@ -142,6 +142,30 @@ Open `demo.html` directly in Chrome — no server needed. Runs at native 4K on h
---
+## Also in this repo: GRIDLOCK
+
+> **Everything runs through you.** An isometric neon city-builder.
+
+Open `games/gridlock/index.html` — zero build step, zero dependencies.
+
+Zone it, power it, cool it, and keep it moving. Three pressures separate it from
+a city-painter: **adjacency synergy** (the same building is worth more or less
+depending on what it touches), a rising **Heat Index** (your industry cooks the
+planet you are building on), and **crisis cards** that pause the world and force
+a trade with no clean answer. Build the Zero Point interchange to win.
+
+The simulation core is pure, dependency-free ES modules — `tickMonth(state) →
+state`, no DOM, no clock, no `Math.random` — so it runs headless under
+`node --test`:
+
+```bash
+npm run test:gridlock # 77 tests, no framework
+```
+
+Full design and architecture notes: [`games/gridlock/README.md`](games/gridlock/README.md).
+
+---
+
## Development
```bash
diff --git a/games/gridlock/README.md b/games/gridlock/README.md
new file mode 100644
index 0000000..152938a
--- /dev/null
+++ b/games/gridlock/README.md
@@ -0,0 +1,195 @@
+# GRIDLOCK
+
+> **Everything runs through you.**
+>
+> An isometric neon city-builder. Zone it, power it, cool it, and keep the whole
+> thing moving — while the planet keeps score.
+
+Open `games/gridlock/index.html` in a browser. No build step, no dependencies,
+no server required.
+
+---
+
+## What makes it not-SimCity
+
+SimCity's core loop is zone → watch it fill. GRIDLOCK keeps that loop and adds
+three pressures that make every tile placement a decision:
+
+### 1. Adjacency synergy — placement is a puzzle
+
+The same building is worth materially more or less depending on what it touches.
+Commerce beside housing sells more. Housing beside a foundry loses approval.
+A server farm on the waterfront dumps its heat into the bay.
+
+| Synergy | Subject | Neighbour | Effect | Cap |
+|---|---|---|---|---|
+| Foot Traffic | Commercial | Residential | +8% revenue each | 4 |
+| Retail Cluster | Commercial | Commercial | +5% revenue each | 2 |
+| Supply Chain | Industry | Industry | +7% revenue each | 3 |
+| Direct Feed | Data | Power | +9% revenue each | 2 |
+| Coolant Draw | Data | Open water | −18% heat each | 3 |
+| Green Outlook | Residential | Green | +2.5 appeal each | 3 |
+| Downwind | Residential | Industry | −3.5 appeal each | 3 |
+| Connected | Residential | Transit | +6% housing each | 2 |
+| Canopy | Green | Green | +12% cooling each | 3 |
+| Campus | Data | Civic | +15% research each | 2 |
+| Shoreline | Residential | Open water | +1.5 appeal each | 3 |
+
+The inspector shows exactly what a tile *would* score before you commit.
+
+### 2. The Heat Index — your industry cooks the planet you build on
+
+Generators, factories, and server farms emit heat. Past **55** the index raises
+city-wide cooling demand and starts costing approval; at **88** the city is in
+crisis and the whole map takes on a red cast. Parks, sky gardens, and carbon
+scrubbers pull it back. There is no way to build a large dirty city and ignore it.
+
+### 3. Crisis cards — the world interrupts you
+
+Twelve cards, each fired by real conditions in your city, each pausing the game
+to force a trade with no clean answer:
+
+- **Grid Surge** — shed load city-wide, or run the plants hot and gamble one.
+- **Heatwave** — ration water and take the anger, or buy reserves at spot price.
+- **Corporate Bid** — 18,000 credits now for a decade of thermal load.
+- **Transit Strike** — meet their terms, or break the strike and be remembered.
+- **Arrival Wave** — open the gates, or seal the perimeter.
+- …plus Data Breach, Sovereign Grant, Ground Tremor, Smog Alert, Fusion
+ Breakthrough, Blackout Riots, and the Pulse Festival.
+
+Cards you have not seen are three times as likely to be drawn, so the deck stays
+surprising well into a long game.
+
+---
+
+## The three chains
+
+A structure produces nothing unless all three hold:
+
+```
+road ──▶ power ──▶ water ──▶ live
+```
+
+1. **Road** — it must touch a road wired back to the Nexus. Cut the street and
+ the district goes dark. The HUD shows a pulsing `N CUT OFF` chip.
+2. **Power** — when supply falls short, rationing is deterministic and
+ priority-ordered: water plants and clinics stay lit, factories go dark first.
+3. **Water** — resolved *after* power, so an unpowered pump makes no water. The
+ failure cascades in one direction, on purpose.
+
+---
+
+## Progression
+
+Research is the only thing that advances the era, and academies and labs are the
+only things that bank it.
+
+| Era | Name | Research | Unlocks |
+|---|---|---|---|
+| 1 | Diesel Age | — | Habitat blocks, markets, fabricators, diesel, solar, pumps |
+| 2 | Solar Age | 120 | Sky lofts, plazas, robotics, server farms, wind, geothermal, desal, arena, transit |
+| 3 | Fusion Age | 640 | Arcology spires, corporate towers, orbital foundries, AI labs, fusion, scrubbers, mag-lev |
+
+Build and power the **Zero Point** interchange to win. Eleven directives run in a
+queue of three, paying out credits and research as you clear them.
+
+---
+
+## Controls
+
+| Input | Action |
+|---|---|
+| Click a structure, then click/drag the map | Build (drag is how you lay roads) |
+| Right-drag, or drag with no tool held | Pan |
+| Wheel | Zoom toward the cursor |
+| `Space` | Pause / resume |
+| `1` `2` `3` | Speed |
+| `X` | Bulldoze (35% refund) |
+| `O` | Cycle map view |
+| `S` | Save · `H` Help · `Esc` Drop tool |
+| `` ` `` | Telemetry overlay |
+
+Five map views: **City**, **Desirability**, **Utilities**, **Services**,
+**Emissions**.
+
+---
+
+## Architecture
+
+Zero-build ES modules. The simulation core is pure and runs headless in Node,
+which is what makes it testable.
+
+```
+games/gridlock/
+├── index.html — static shell; all structure, no logic
+├── styles.css — obsidian / gold / violet theme
+└── src/
+ ├── config/ — DATA ONLY: balance, catalog, synergy, eras, crises, directives
+ │ ├── balance.js every tuning knob in the game
+ │ ├── buildings.js 31 structures, signed power/water convention
+ │ ├── synergy.js the adjacency rule table
+ │ ├── events.js the crisis deck
+ │ └── directives.js the objective queue
+ ├── core/ — PURE SIM: no DOM, no clock, no Math.random
+ │ ├── rng.js seeded streams + value noise
+ │ ├── grid.js terrain generation and neighbourhood queries
+ │ ├── state.js construction, cloning, logging
+ │ ├── commands.js place / demolish / tax / speed, all validated here
+ │ ├── network.js road connectivity from the Nexus
+ │ ├── rationing.js deterministic priority-ordered shortfalls
+ │ ├── synergy.js adjacency scoring
+ │ ├── fields.js desirability and service coverage
+ │ ├── stats.js the one grid scan per month
+ │ ├── economy.js approval, migration, heat
+ │ ├── events.js crisis draw and effect interpretation
+ │ ├── directives.js objective evaluation
+ │ └── simulate.js tickMonth(state) → state
+ ├── render/ — canvas: iso projection, terrain, structures, overlays, effects
+ ├── ui/ — DOM: hud, build palette, inspector, panels, modal, toasts
+ ├── io/ — input, localStorage persistence, WebAudio
+ └── telemetry/ — HEALTH / PRESSURE / EFFICIENCY probes
+```
+
+**State flows one way.** Input produces a command, the command returns a *new*
+state, the renderer and UI read it. `tickMonth(state) → state` is pure: no
+wall-clock reads, no `Math.random`, no DOM. Every stochastic decision draws from
+`(seed, rollCount)`, so a save file replays exactly and every crisis outcome is
+reproducible.
+
+**Buildings are data.** Adding a structure to `config/buildings.js` makes it
+appear in the palette, on the map, and in the economy with no other code change —
+the renderer draws every structure procedurally from its catalog entry.
+
+---
+
+## Tests
+
+```bash
+cd games/gridlock
+npm test # node --test tests/*.test.mjs
+```
+
+77 tests, no framework and no dependencies:
+
+| Suite | Covers |
+|---|---|
+| `grid` | terrain determinism, buildable centre, neighbour clipping, seeded RNG |
+| `iso` | projection round-trip under zoom and pan, tile picking, camera clamps |
+| `commands` | every placement rule, refunds, the un-removable Nexus, clamped settings |
+| `network` | road connectivity, orphan islands, deterministic rationing order |
+| `synergy` | per-neighbour multipliers, caps, penalties, non-mutating preview |
+| `simulate` | tick purity and determinism, growth, brownouts, heat bounds, solvency |
+| `events` | deck integrity, cost gating, seeded risk, the Nexus surviving destruction |
+| `conditions` | crisis trigger conditions, grace period, cooldown, single-crisis rule |
+| `directives` | queue refill, streak reset, rewards, victory |
+| `balance` | a fixed district over 10 years: fills, pays for itself, stays cool |
+
+The `conditions` suite includes a guard that no crisis condition compares the
+clamped utility ratio against a value above 1 — a bug that shipped once and made
+Grid Surge fire every single month.
+
+## Telemetry
+
+Press `` ` `` in game, or load `?debug=1`, for the live HEALTH / PRESSURE /
+EFFICIENCY readout: frame rate, draw and tick cost, heap, dropped-frame rate,
+and particle count. A built-out city ticks in well under 1ms and draws in ~3ms.
diff --git a/games/gridlock/index.html b/games/gridlock/index.html
new file mode 100644
index 0000000..a513766
--- /dev/null
+++ b/games/gridlock/index.html
@@ -0,0 +1,131 @@
+
+
+
+
+
+GRIDLOCK — Everything runs through you
+
+
+
+
+
+
+
+
+
+
+
+
+
+ GRIDLOCK
+ JAN 2087
+ STABLE
+
+
+
+ Treasury
+ ₡0
+ +₡0/mo
+
+
+
+ Residents
+ 0
+ 0 working
+
+
+
+ Approval
+ 68%
+
+
+
+
+ Power
+ 0/0
+
+
+
+
+ Water
+ 0/0
+
+
+
+
+ Heat Index
+ 12
+
+
+
+
+ Era
+ ERA 1 · DIESEL AGE
+ 120 research to Solar Age
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/games/gridlock/package.json b/games/gridlock/package.json
new file mode 100644
index 0000000..c9f7d32
--- /dev/null
+++ b/games/gridlock/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "gridlock",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "description": "GRIDLOCK — isometric neon city-builder. Zero-build ES modules, pure simulation core.",
+ "scripts": {
+ "test": "node --test tests/*.test.mjs"
+ }
+}
diff --git a/games/gridlock/src/config/balance.js b/games/gridlock/src/config/balance.js
new file mode 100644
index 0000000..f4db52f
--- /dev/null
+++ b/games/gridlock/src/config/balance.js
@@ -0,0 +1,100 @@
+/**
+ * Economic + simulation constants for GRIDLOCK.
+ *
+ * WHY a single table: every tuning knob lives here so balance changes never
+ * require touching simulation logic. The sim reads constants, it never invents
+ * them. Changing a number here is a config change, not a code change.
+ */
+
+export const GRID = Object.freeze({ width: 32, height: 32 });
+
+/** Real milliseconds per simulated month, indexed by speed setting. */
+export const SPEED_MS = Object.freeze([Infinity, 2000, 900, 380]);
+export const SPEED_LABEL = Object.freeze(['PAUSED', '1×', '2×', '3×']);
+
+export const START = Object.freeze({
+ credits: 30000,
+ approval: 68,
+ heat: 12,
+ taxRate: 0.09,
+});
+
+export const TAX = Object.freeze({
+ min: 0.0,
+ max: 0.22,
+ step: 0.01,
+ /** Credits per resident per month at 100% tax. Scaled by taxRate. */
+ perCapita: 19.5,
+ /** Approval swing per percentage point away from the neutral rate. */
+ neutral: 0.09,
+ approvalSlope: 210,
+});
+
+export const LABOR = Object.freeze({
+ /** Fraction of population that seeks work. */
+ participation: 0.62,
+ /** Population may exceed jobs by this factor before growth stalls. */
+ slack: 1.18,
+});
+
+export const GROWTH = Object.freeze({
+ /** Fraction of the gap to target population closed each month. */
+ rate: 0.12,
+ /** Minimum share of housing that fills even at rock-bottom approval. */
+ floorShare: 0.4,
+ /** Residents who leave per month when a utility is fully out. */
+ exodusRate: 0.09,
+});
+
+export const APPROVAL = Object.freeze({
+ base: 62,
+ inertia: 0.16,
+ unemploymentWeight: 46,
+ pollutionWeight: 28,
+ powerWeight: 34,
+ waterWeight: 28,
+ congestionWeight: 22,
+ coverageWeight: 22,
+ heatWeight: 26,
+ appealCap: 26,
+});
+
+export const CLIMATE = Object.freeze({
+ /** Heat index gained per unit of net thermal load per month. */
+ gain: 0.05,
+ /** Natural radiative decay per month, before emissions. */
+ decay: 0.35,
+ /** Heat index above which penalties begin. */
+ threshold: 55,
+ /** Heat index at which the city is in full crisis. */
+ critical: 88,
+ /** Extra power demand per point of heat above threshold, as a fraction. */
+ coolingLoad: 0.006,
+ max: 100,
+});
+
+export const TRAFFIC = Object.freeze({
+ /** Commuters one road tile can carry per month. */
+ roadCapacity: 26,
+ /** Congestion above this level starts costing commerce and approval. */
+ tolerance: 0.7,
+});
+
+export const SERVICE_KINDS = Object.freeze(['health', 'education', 'safety']);
+
+export const TECH = Object.freeze({
+ /** Research required to unlock each era, cumulative. */
+ eraCost: [0, 120, 640],
+});
+
+export const EVENTS = Object.freeze({
+ /** Months of quiet guaranteed after any crisis resolves. */
+ cooldown: 7,
+ /** Earliest month a crisis may fire. */
+ graceMonths: 10,
+ /** Per-month chance a crisis is drawn once off cooldown. */
+ chance: 0.16,
+});
+
+export const SAVE_KEY = 'gridlock.save.v1';
+export const SAVE_VERSION = 1;
diff --git a/games/gridlock/src/config/buildings.js b/games/gridlock/src/config/buildings.js
new file mode 100644
index 0000000..a385d83
--- /dev/null
+++ b/games/gridlock/src/config/buildings.js
@@ -0,0 +1,284 @@
+/**
+ * The GRIDLOCK build catalog.
+ *
+ * Signed convention: `power` and `water` are positive when a structure supplies
+ * the grid and negative when it draws from it. `pollution` and `heat` are
+ * positive when emitted, negative when scrubbed. One field, one sign, no
+ * special cases in the simulation.
+ *
+ * INVARIANT: every entry is 1×1. Footprint variety is expressed through height
+ * and silhouette, which keeps placement, pathing, and demolition trivial.
+ */
+
+/** @typedef {'road'|'residential'|'commercial'|'industry'|'data'|'power'|'water'|'civic'|'green'|'transit'|'special'} Category */
+
+const B = (def) => Object.freeze({
+ era: 1,
+ upkeep: 0,
+ power: 0,
+ water: 0,
+ residents: 0,
+ jobs: 0,
+ revenue: 0,
+ pollution: 0,
+ heat: 0,
+ tech: 0,
+ appeal: 0,
+ appealRadius: 0,
+ traffic: 0,
+ needs: null,
+ ...def,
+});
+
+export const CATALOG = Object.freeze([
+ B({
+ id: 'nexus', name: 'The Nexus', cat: 'special', cost: 0, upkeep: 0, hidden: true,
+ power: 60, water: 60, jobs: 10, appeal: 6, appealRadius: 5,
+ h: 1.4, color: '#3a3160', accent: '#ffd97a',
+ desc: 'Founding hub. Every road traces its power back here. It cannot be removed.',
+ }),
+
+ B({
+ id: 'road', name: 'Neon Road', cat: 'road', cost: 14, upkeep: 0.25,
+ h: 0.06, color: '#2a2740', accent: '#8b5cf6',
+ desc: 'Carries commuters. Every zone must touch a road wired back to the Nexus.',
+ }),
+
+ // ── Residential ─────────────────────────────────────────────────────────
+ B({
+ id: 'hab', name: 'Habitat Block', cat: 'residential', cost: 300, upkeep: 4,
+ residents: 26, power: -6, water: -5, pollution: 0.6, heat: 0.2,
+ h: 0.9, color: '#3b3560', accent: '#c9a84c',
+ desc: 'Stacked micro-housing. Cheap, dense, and the backbone of a young city.',
+ }),
+ B({
+ id: 'loft', name: 'Sky Loft', cat: 'residential', era: 2, cost: 1100, upkeep: 12,
+ residents: 84, power: -16, water: -13, pollution: 0.8, heat: 0.4, appeal: 1, appealRadius: 2,
+ h: 1.9, color: '#453d78', accent: '#e6c76a',
+ desc: 'Vertical living for citizens who can pay for altitude.',
+ }),
+ B({
+ id: 'spire', name: 'Arcology Spire', cat: 'residential', era: 3, cost: 5200, upkeep: 46,
+ residents: 360, power: -62, water: -48, pollution: 1.2, heat: 0.9, appeal: 3, appealRadius: 3,
+ h: 3.6, color: '#4d4490', accent: '#ffd97a',
+ desc: 'A self-contained district folded into a single tower.',
+ }),
+
+ // ── Commercial ──────────────────────────────────────────────────────────
+ B({
+ id: 'market', name: 'Market Row', cat: 'commercial', cost: 340, upkeep: 5,
+ jobs: 20, revenue: 46, power: -7, water: -4, appeal: 1, appealRadius: 2,
+ h: 0.7, color: '#2f4a55', accent: '#38e1c8',
+ desc: 'Street-level trade. Thrives beside housing, starves in isolation.',
+ }),
+ B({
+ id: 'plaza', name: 'Neon Plaza', cat: 'commercial', era: 2, cost: 1250, upkeep: 14,
+ jobs: 68, revenue: 168, power: -18, water: -9, appeal: 3, appealRadius: 3,
+ h: 1.5, color: '#2f5a6b', accent: '#4df0d6',
+ desc: 'Night markets, holo-signage, and a permanent crowd.',
+ }),
+ B({
+ id: 'tower', name: 'Corporate Tower', cat: 'commercial', era: 3, cost: 4800, upkeep: 44,
+ jobs: 240, revenue: 620, power: -55, water: -26, appeal: 2, appealRadius: 3,
+ h: 3.2, color: '#2c6478', accent: '#7ef7e2',
+ desc: 'Where the city\'s money actually lives.',
+ }),
+
+ // ── Industry ────────────────────────────────────────────────────────────
+ B({
+ id: 'fab', name: 'Fabricator', cat: 'industry', cost: 520, upkeep: 7,
+ jobs: 32, revenue: 92, power: -14, water: -10, pollution: 6, heat: 1.2,
+ appeal: -4, appealRadius: 3,
+ h: 0.8, color: '#5a3a2c', accent: '#ff7a3d',
+ desc: 'Prints goods and grievances in equal measure.',
+ }),
+ B({
+ id: 'robotics', name: 'Robotics Yard', cat: 'industry', era: 2, cost: 1700, upkeep: 18,
+ jobs: 96, revenue: 320, power: -38, water: -20, pollution: 8, heat: 1.8,
+ appeal: -5, appealRadius: 3,
+ h: 1.3, color: '#66422f', accent: '#ff9147',
+ desc: 'Automated assembly at district scale. Loud, hot, profitable.',
+ }),
+ B({
+ id: 'foundry', name: 'Orbital Foundry', cat: 'industry', era: 3, cost: 6400, upkeep: 58,
+ jobs: 300, revenue: 1150, power: -110, water: -60, pollution: 10, heat: 2.6,
+ appeal: -6, appealRadius: 4,
+ h: 2.2, color: '#6e4632', accent: '#ffa85c',
+ desc: 'Feeds the launch corridor. Nobody wants to live downwind.',
+ }),
+
+ // ── Data ────────────────────────────────────────────────────────────────
+ B({
+ id: 'server', name: 'Server Farm', cat: 'data', era: 2, cost: 2200, upkeep: 22,
+ jobs: 24, revenue: 430, power: -85, water: -30, heat: 3.2, tech: 1,
+ h: 0.9, color: '#2b3350', accent: '#5ea9ff',
+ desc: 'Enormous revenue, enormous thirst, enormous waste heat.',
+ }),
+ B({
+ id: 'ailab', name: 'AI Lab', cat: 'data', era: 3, cost: 4000, upkeep: 38,
+ jobs: 60, revenue: 260, power: -70, water: -24, heat: 1.4, tech: 4,
+ appeal: 2, appealRadius: 3,
+ h: 1.7, color: '#33305e', accent: '#a78bfa',
+ desc: 'Converts electricity into research. The fastest road to a new era.',
+ }),
+
+ // ── Power ───────────────────────────────────────────────────────────────
+ B({
+ id: 'diesel', name: 'Diesel Generator', cat: 'power', cost: 600, upkeep: 14,
+ power: 90, pollution: 12, heat: 3.0, appeal: -6, appealRadius: 4,
+ h: 0.8, color: '#4a3b30', accent: '#ff5a3c',
+ desc: 'Instant power at a filthy price. A crutch, never a plan.',
+ }),
+ B({
+ id: 'solar', name: 'Solar Array', cat: 'power', cost: 900, upkeep: 4,
+ power: 55, heat: -0.1,
+ h: 0.25, color: '#1e2a4a', accent: '#ffd166',
+ desc: 'Silent and clean. Output dips through the night cycle.',
+ }),
+ B({
+ id: 'wind', name: 'Wind Spire', cat: 'power', era: 2, cost: 1400, upkeep: 6,
+ power: 120, appeal: -1, appealRadius: 2,
+ h: 2.8, color: '#38455e', accent: '#d8e6ff',
+ desc: 'Tall, clean, and faintly resented by everyone beneath it.',
+ }),
+ B({
+ id: 'geo', name: 'Geothermal Tap', cat: 'power', era: 2, cost: 2600, upkeep: 16,
+ power: 230, heat: 0.6, needs: 'hill',
+ h: 1.0, color: '#3f3348', accent: '#ff8ab0',
+ desc: 'Must be sunk into rock. Steady baseload with no smoke.',
+ }),
+ B({
+ id: 'fusion', name: 'Fusion Reactor', cat: 'power', era: 3, cost: 9000, upkeep: 60,
+ power: 900, heat: 1.0, appeal: -2, appealRadius: 4,
+ h: 1.6, color: '#2a4a4a', accent: '#5ff2c8',
+ desc: 'One reactor ends the power question for good.',
+ }),
+
+ // ── Water ───────────────────────────────────────────────────────────────
+ B({
+ id: 'pump', name: 'Water Pump', cat: 'water', cost: 380, upkeep: 5,
+ water: 90, power: -6, needs: 'water',
+ h: 0.4, color: '#25415c', accent: '#5ec8ff',
+ desc: 'Must sit on the shoreline. The cheapest litre in the city.',
+ }),
+ B({
+ id: 'desal', name: 'Desalination Plant', cat: 'water', era: 2, cost: 2100, upkeep: 18,
+ water: 340, power: -40, heat: 0.8, needs: 'water',
+ h: 0.9, color: '#204a63', accent: '#7ad9ff',
+ desc: 'Turns the coast into a reservoir, for a price in megawatts.',
+ }),
+ B({
+ id: 'condenser', name: 'Atmospheric Condenser', cat: 'water', era: 3, cost: 3000, upkeep: 20,
+ water: 260, power: -55,
+ h: 1.4, color: '#2b4f66', accent: '#9fe8ff',
+ desc: 'Pulls water from open air. Build it anywhere, run it forever.',
+ }),
+
+ // ── Civic ───────────────────────────────────────────────────────────────
+ B({
+ id: 'clinic', name: 'Med Bay', cat: 'civic', cost: 700, upkeep: 12,
+ jobs: 14, power: -9, water: -8, appeal: 2, appealRadius: 3,
+ service: 'health', serviceRadius: 6,
+ h: 0.7, color: '#42324a', accent: '#ff6f91',
+ desc: 'Keeps citizens alive and, more importantly, keeps them here.',
+ }),
+ B({
+ id: 'academy', name: 'Academy', cat: 'civic', cost: 950, upkeep: 15,
+ jobs: 20, power: -11, water: -9, tech: 1.5, appeal: 2, appealRadius: 3,
+ service: 'education', serviceRadius: 7,
+ h: 0.9, color: '#3a3457', accent: '#c9a84c',
+ desc: 'Slow, steady research and a permanent lift in civic mood.',
+ }),
+ B({
+ id: 'security', name: 'Watch Post', cat: 'civic', cost: 550, upkeep: 10,
+ jobs: 12, power: -6, water: -4, appeal: 1, appealRadius: 2,
+ service: 'safety', serviceRadius: 6,
+ h: 0.6, color: '#3d3646', accent: '#8fa8ff',
+ desc: 'Order on the block. Unloved until the night it is missing.',
+ }),
+
+ // ── Green ───────────────────────────────────────────────────────────────
+ B({
+ id: 'park', name: 'Bio Park', cat: 'green', cost: 180, upkeep: 2,
+ appeal: 6, appealRadius: 4, heat: -0.6, pollution: -1.5,
+ h: 0.14, color: '#1f3a2c', accent: '#5ee08a',
+ desc: 'The cheapest approval in the game. Sprinkle liberally.',
+ }),
+ B({
+ id: 'skygarden', name: 'Sky Garden', cat: 'green', era: 2, cost: 1300, upkeep: 9,
+ appeal: 12, appealRadius: 5, heat: -1.8, pollution: -4, water: -6,
+ h: 1.1, color: '#22463a', accent: '#7dfba8',
+ desc: 'A terraced canopy that cools the district it crowns.',
+ }),
+ B({
+ id: 'recycler', name: 'Recycler', cat: 'green', era: 2, cost: 1500, upkeep: 14,
+ jobs: 18, power: -20, pollution: -12, appeal: -1, appealRadius: 2,
+ h: 0.7, color: '#31432f', accent: '#a8e05e',
+ desc: 'Eats the smog your factories exhale.',
+ }),
+ B({
+ id: 'scrubber', name: 'Carbon Scrubber', cat: 'green', era: 3, cost: 3600, upkeep: 26,
+ power: -60, heat: -4.5, pollution: -6,
+ h: 1.5, color: '#28483f', accent: '#5ff2c8',
+ desc: 'The only structure that meaningfully reverses the Heat Index.',
+ }),
+
+ // ── Transit + spectacle ─────────────────────────────────────────────────
+ B({
+ id: 'stadium', name: 'Pulse Arena', cat: 'transit', era: 2, cost: 3200, upkeep: 30,
+ jobs: 40, revenue: 180, power: -30, water: -18, appeal: 18, appealRadius: 7,
+ h: 1.2, color: '#4a2f56', accent: '#ff5ec4',
+ desc: 'A city-wide mood swing you can build on purpose.',
+ }),
+ B({
+ id: 'transit', name: 'Transit Hub', cat: 'transit', era: 2, cost: 1600, upkeep: 16,
+ jobs: 22, power: -14, appeal: 2, appealRadius: 3, traffic: 0.14,
+ h: 0.8, color: '#33395c', accent: '#8b5cf6',
+ desc: 'Takes commuters off your roads. Congestion relief, city-wide.',
+ }),
+ B({
+ id: 'maglev', name: 'Mag-Lev Loop', cat: 'transit', era: 3, cost: 5000, upkeep: 40,
+ power: -60, appeal: 4, appealRadius: 5, traffic: 0.26,
+ h: 2.0, color: '#39406b', accent: '#b39cff',
+ desc: 'Frictionless transit. The road network stops being your ceiling.',
+ }),
+
+ // ── Landmark ────────────────────────────────────────────────────────────
+ B({
+ id: 'zeropoint', name: 'Zero Point', cat: 'special', era: 3, cost: 14000, upkeep: 80,
+ power: -140, water: -20, appeal: 25, appealRadius: 12, tech: 6, traffic: 0.35,
+ h: 4.4, color: '#3a2f5e', accent: '#ffe9a8',
+ desc: 'The interchange that ends gridlock forever. Finishing it wins the city.',
+ }),
+]);
+
+/** @type {Map} */
+const INDEX = new Map(CATALOG.map((b) => [b.id, b]));
+
+/** @returns {typeof CATALOG[number]|undefined} */
+export const getBuilding = (id) => INDEX.get(id);
+
+/** Player-placeable entries available at or below the given era, in catalog order. */
+export const unlockedAt = (era) => CATALOG.filter((b) => !b.hidden && b.era <= era);
+
+/** Player-placeable entries locked behind a later era. */
+export const lockedAbove = (era) => CATALOG.filter((b) => !b.hidden && b.era > era);
+
+export const CATEGORY_LABEL = Object.freeze({
+ road: 'Roads',
+ residential: 'Residential',
+ commercial: 'Commercial',
+ industry: 'Industry',
+ data: 'Data',
+ power: 'Power',
+ water: 'Water',
+ civic: 'Civic',
+ green: 'Green',
+ transit: 'Transit',
+ special: 'Landmark',
+});
+
+/** Categories that must touch a connected road to function. */
+export const NEEDS_ROAD = Object.freeze(
+ new Set(['residential', 'commercial', 'industry', 'data', 'civic', 'transit', 'special']),
+);
diff --git a/games/gridlock/src/config/directives.js b/games/gridlock/src/config/directives.js
new file mode 100644
index 0000000..e7d50bb
--- /dev/null
+++ b/games/gridlock/src/config/directives.js
@@ -0,0 +1,81 @@
+/**
+ * Directives — the ordered objective queue. Three are active at a time; each
+ * completion pays out and pulls the next one in, so there is always a concrete
+ * next thing to build.
+ *
+ * `test` is instantaneous. `streak` requires the condition to hold for N
+ * consecutive months, which is how the hard ones (stability, climate control)
+ * are expressed.
+ */
+
+export const DIRECTIVES = Object.freeze([
+ {
+ id: 'first-roots', title: 'First Roots', detail: 'Grow the city to 250 residents.',
+ reward: { credits: 1800 },
+ test: (s) => s.population >= 250,
+ progress: (s) => [s.population, 250],
+ },
+ {
+ id: 'keep-the-lights-on', title: 'Keep the Lights On', detail: 'Hold full power and water supply for 6 straight months.',
+ reward: { credits: 2400 },
+ streak: 6,
+ holds: (s, st) => st.power.ratio >= 1 && st.water.ratio >= 1 && s.population > 100,
+ },
+ {
+ id: 'green-belt', title: 'Green Belt', detail: 'Operate 6 green structures.',
+ reward: { credits: 2000, tech: 20 },
+ test: (s, st) => (st.counts.green ?? 0) >= 6,
+ progress: (s, st) => [st.counts.green ?? 0, 6],
+ },
+ {
+ id: 'trade-district', title: 'Trade District', detail: 'Reach ₡700 per month in business revenue.',
+ reward: { credits: 3000 },
+ test: (s, st) => st.business >= 700,
+ progress: (s, st) => [st.business, 700],
+ },
+ {
+ id: 'second-era', title: 'The Solar Age', detail: 'Bank enough research to reach Era 2.',
+ reward: { credits: 3500 },
+ test: (s) => s.era >= 2,
+ progress: (s) => [s.tech, 120],
+ },
+ {
+ id: 'metropolis', title: 'Metropolis', detail: 'Grow the city to 2,000 residents.',
+ reward: { credits: 5000, tech: 30 },
+ test: (s) => s.population >= 2000,
+ progress: (s) => [s.population, 2000],
+ },
+ {
+ id: 'cool-head', title: 'Cool Head', detail: 'Hold the Heat Index under 45 for 8 months at 1,500+ residents.',
+ reward: { credits: 6000, tech: 40 },
+ streak: 8,
+ holds: (s) => s.heat < 45 && s.population >= 1500,
+ },
+ {
+ id: 'free-flowing', title: 'Free Flowing', detail: 'Keep congestion under 50% with 2,500+ residents.',
+ reward: { credits: 6000 },
+ streak: 5,
+ holds: (s, st) => st.congestion < 0.5 && s.population >= 2500,
+ },
+ {
+ id: 'beloved', title: 'Beloved', detail: 'Hold approval above 80 for 6 months.',
+ reward: { credits: 8000, tech: 50 },
+ streak: 6,
+ holds: (s) => s.approval > 80,
+ },
+ {
+ id: 'third-era', title: 'The Fusion Age', detail: 'Bank enough research to reach Era 3.',
+ reward: { credits: 10000 },
+ test: (s) => s.era >= 3,
+ progress: (s) => [s.tech, 640],
+ },
+ {
+ id: 'zero-point', title: 'Zero Point', detail: 'Build and power the Zero Point interchange.',
+ reward: { credits: 0 },
+ victory: true,
+ test: (s, st) => s.grid.tiles.some((t) => t.b === 'zeropoint' && st.live.has(t.i)),
+ },
+]);
+
+export const MAX_ACTIVE_DIRECTIVES = 3;
+export const directiveById = (id) => DIRECTIVES.find((d) => d.id === id);
diff --git a/games/gridlock/src/config/eras.js b/games/gridlock/src/config/eras.js
new file mode 100644
index 0000000..a8f8f9e
--- /dev/null
+++ b/games/gridlock/src/config/eras.js
@@ -0,0 +1,48 @@
+/**
+ * Era progression. Research (tech) is the only gate — population and credits
+ * follow from it, so the player always has one clear thing to chase.
+ */
+
+import { TECH } from './balance.js';
+
+export const ERAS = Object.freeze([
+ {
+ id: 1,
+ name: 'Diesel Age',
+ tagline: 'Smoke, scaffolding, and a grid held together by nerve.',
+ techCost: TECH.eraCost[0],
+ palette: '#ff7a3d',
+ },
+ {
+ id: 2,
+ name: 'Solar Age',
+ tagline: 'The city learns to breathe. Data becomes the second economy.',
+ techCost: TECH.eraCost[1],
+ palette: '#38e1c8',
+ },
+ {
+ id: 3,
+ name: 'Fusion Age',
+ tagline: 'Limitless power, one unfinished spire, and a planet running a fever.',
+ techCost: TECH.eraCost[2],
+ palette: '#a78bfa',
+ },
+]);
+
+export const eraById = (id) => ERAS.find((e) => e.id === id) ?? ERAS[0];
+
+/**
+ * Highest era affordable at the given cumulative research.
+ * @param {number} tech
+ */
+export const eraForTech = (tech) => {
+ let era = ERAS[0].id;
+ for (const e of ERAS) if (tech >= e.techCost) era = e.id;
+ return era;
+};
+
+/** Research still required to reach the next era, or null at max era. */
+export const techToNextEra = (tech) => {
+ const next = ERAS.find((e) => e.techCost > tech);
+ return next ? { era: next, remaining: next.techCost - tech } : null;
+};
diff --git a/games/gridlock/src/config/events.js b/games/gridlock/src/config/events.js
new file mode 100644
index 0000000..5262fdc
--- /dev/null
+++ b/games/gridlock/src/config/events.js
@@ -0,0 +1,236 @@
+/**
+ * The crisis deck.
+ *
+ * Crises are the difference between a city-painter and a game: they pause the
+ * world and force a trade with no clean answer. Every card is data — the
+ * engine in core/events.js interprets the effect kinds and never hard-codes a
+ * specific crisis.
+ *
+ * Effect kinds:
+ * credits {value} — signed, may be a fn(state, stats)
+ * approval {value} — signed points, applied immediately
+ * pop {mul} | {value} — migration shock
+ * tech {value} — research grant
+ * heat {value} — instant heat index shift
+ * modifier {id,label,months,effects} — timed multiplier, see core/stats.js
+ * destroy {count, cat?} — level structures, seeded-random pick
+ * risk {chance, then, otherwise} — a gamble the player opts into
+ */
+
+const mod = (id, label, months, effects) => ({ kind: 'modifier', id, label, months, effects });
+
+export const CRISIS_DECK = Object.freeze([
+ {
+ id: 'grid-surge',
+ title: 'Grid Surge',
+ flavor: 'Load is running ahead of generation. Engineering wants a decision before the transformers make one for them.',
+ weight: 3,
+ // WHY not power.ratio: that value is clamped at 1, so it can never express
+ // "supply is only just keeping up". Compare the raw figures instead.
+ when: (s, st) => (st.counts.power ?? 0) >= 1
+ && st.power.demand > 0
+ && st.power.supply < st.power.demand * 1.15,
+ choices: [
+ {
+ label: 'Shed load city-wide',
+ detail: 'Rolling brownouts for four months. Nobody will thank you.',
+ effects: [
+ mod('surge-shed', 'Rolling Brownouts', 4, { powerSupplyMul: 0.78 }),
+ { kind: 'approval', value: -5 },
+ ],
+ },
+ {
+ label: 'Run the plants hot',
+ detail: 'Hold full output and hope the hardware holds with it.',
+ effects: [
+ {
+ kind: 'risk', chance: 0.45,
+ then: [{ kind: 'destroy', count: 1, cat: 'power' }, { kind: 'approval', value: -8 }],
+ otherwise: [{ kind: 'approval', value: 3 }],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: 'heatwave',
+ title: 'Heatwave',
+ flavor: 'Six straight weeks above record. Water draw is spiking and the grid is carrying every cooling unit in the city.',
+ weight: 3,
+ when: (s) => s.heat > 38,
+ onFire: [mod('heatwave-base', 'Heatwave', 5, { waterDemandMul: 1.35, powerDemandMul: 1.12 })],
+ choices: [
+ {
+ label: 'Ration water',
+ detail: 'Cut consumption by a quarter. Expect anger.',
+ effects: [
+ mod('heatwave-ration', 'Water Rationing', 5, { waterDemandMul: 0.72 }),
+ { kind: 'approval', value: -9 },
+ ],
+ },
+ {
+ label: 'Buy emergency reserves',
+ detail: 'Truck it in at spot price.',
+ cost: (s) => Math.round(1200 + s.population * 3.5),
+ effects: [{ kind: 'approval', value: 2 }],
+ },
+ ],
+ },
+ {
+ id: 'corp-bid',
+ title: 'Corporate Bid',
+ flavor: 'Helion Group wants tax-free siting rights for a compute campus. The cheque is enormous. So is the waste heat.',
+ weight: 2,
+ when: (s) => s.era >= 2,
+ choices: [
+ {
+ label: 'Take the deal',
+ detail: 'Cash now, a decade of thermal load later.',
+ effects: [
+ { kind: 'credits', value: 18000 },
+ mod('corp-heat', 'Helion Campus', 14, { heatAdd: 4.5, upkeepMul: 1.06 }),
+ { kind: 'approval', value: -6 },
+ ],
+ },
+ { label: 'Refuse the siting rights', detail: 'Keep the skyline yours.', effects: [{ kind: 'approval', value: 4 }] },
+ ],
+ },
+ {
+ id: 'influx',
+ title: 'Arrival Wave',
+ flavor: 'Three coastal cities went under this season. Their people are at your perimeter.',
+ weight: 2,
+ when: (s, st) => st.capacity > s.population * 1.05,
+ choices: [
+ {
+ label: 'Open the gates',
+ detail: 'Instant population, instant strain on every service you have.',
+ effects: [{ kind: 'pop', mul: 1.16 }, { kind: 'approval', value: -4 },
+ mod('influx-strain', 'Service Strain', 6, { waterDemandMul: 1.1 })],
+ },
+ { label: 'Seal the perimeter', detail: 'The city stays as it is. So does its conscience.', effects: [{ kind: 'approval', value: -11 }] },
+ ],
+ },
+ {
+ id: 'transit-strike',
+ title: 'Transit Strike',
+ flavor: 'Operators walked at midnight. Every arterial is a parking lot.',
+ weight: 3,
+ when: (s, st) => st.congestion > 0.55,
+ choices: [
+ {
+ label: 'Meet their terms',
+ detail: 'Expensive, immediate, and quietly popular.',
+ cost: (s, st) => Math.round(st.expenses * 6 + 800),
+ effects: [{ kind: 'approval', value: 5 }],
+ },
+ {
+ label: 'Break the strike',
+ detail: 'Trains run tomorrow. The city remembers for years.',
+ effects: [{ kind: 'approval', value: -13 }, mod('strike-fallout', 'Labour Fallout', 5, { revenueMul: 0.86 })],
+ },
+ ],
+ },
+ {
+ id: 'breach',
+ title: 'Data Breach',
+ flavor: 'Someone walked out of your compute district with the resident registry.',
+ weight: 2,
+ when: (s, st) => (st.counts.data ?? 0) >= 1,
+ choices: [
+ { label: 'Pay the ransom', detail: 'Quiet, fast, and never truly over.', cost: 6500, effects: [{ kind: 'approval', value: -2 }] },
+ {
+ label: 'Go public',
+ detail: 'Full disclosure. Take the hit in daylight.',
+ effects: [{ kind: 'approval', value: -9 }, mod('breach-fallout', 'Investor Flight', 6, { revenueMul: 0.82 })],
+ },
+ ],
+ },
+ {
+ id: 'windfall',
+ title: 'Sovereign Grant',
+ flavor: 'Your approval numbers reached the capital. They would like to be associated with success.',
+ weight: 2,
+ when: (s) => s.approval > 72,
+ choices: [
+ {
+ label: 'Accept the grant',
+ detail: 'Enormous capital, permanent oversight overhead.',
+ effects: [{ kind: 'credits', value: 12000 }, mod('grant-admin', 'Federal Oversight', 12, { upkeepMul: 1.12 })],
+ },
+ { label: 'Decline politely', detail: 'Independence has its own value.', effects: [{ kind: 'approval', value: 4 }] },
+ ],
+ },
+ {
+ id: 'tremor',
+ title: 'Ground Tremor',
+ flavor: 'The fault under the ridge moved four centimetres. Structural is calling it a warning shot.',
+ weight: 2,
+ when: (s) => s.month > 24,
+ choices: [
+ { label: 'Emergency retrofit', detail: 'Brace the district before the next one.', cost: 5200, effects: [] },
+ {
+ label: 'Ride it out',
+ detail: 'Spend nothing. Risk a building.',
+ effects: [{
+ kind: 'risk', chance: 0.55,
+ then: [{ kind: 'destroy', count: 1 }, { kind: 'approval', value: -6 }],
+ otherwise: [],
+ }],
+ },
+ ],
+ },
+ {
+ id: 'smog',
+ title: 'Smog Alert',
+ flavor: 'Particulate is off the scale. The clinics are full of people who did nothing but breathe.',
+ weight: 3,
+ when: (s, st) => st.pollution > 24,
+ choices: [
+ {
+ label: 'Idle the factories',
+ detail: 'Clean air, empty ledgers.',
+ effects: [mod('smog-idle', 'Industrial Shutdown', 4, { revenueMul: 0.72 }), { kind: 'approval', value: 6 }],
+ },
+ { label: 'Dispute the readings', detail: 'Production continues. So does the coughing.', effects: [{ kind: 'approval', value: -14 }] },
+ ],
+ },
+ {
+ id: 'breakthrough',
+ title: 'Fusion Breakthrough',
+ flavor: 'A lab team has containment holding past nine minutes. They need funding tonight, not next quarter.',
+ weight: 2,
+ when: (s, st) => (st.counts.data ?? 0) >= 1 && s.era >= 2,
+ choices: [
+ { label: 'Fund it', detail: 'A generational leap in research.', cost: 8000, effects: [{ kind: 'tech', value: 110 }, { kind: 'approval', value: 3 }] },
+ { label: 'Shelve the programme', detail: 'The money stays in the treasury.', effects: [] },
+ ],
+ },
+ {
+ id: 'unrest',
+ title: 'Blackout Riots',
+ flavor: 'Four districts went dark and stayed dark. The crowd outside city hall is not a protest any more.',
+ weight: 4,
+ when: (s) => s.approval < 34,
+ choices: [
+ { label: 'Impose curfew', detail: 'Order tonight, resentment for a year.', effects: [{ kind: 'approval', value: 7 }, mod('curfew', 'Curfew', 6, { growthMul: 0.55 })] },
+ { label: 'Emergency spending package', detail: 'Buy back the city\'s goodwill.', cost: 9000, effects: [{ kind: 'approval', value: 15 }] },
+ ],
+ },
+ {
+ id: 'festival',
+ title: 'The Pulse Festival',
+ flavor: 'Promoters want the whole waterfront for a week. It would be the loudest thing this city has ever done.',
+ weight: 2,
+ when: (s) => s.credits > 6000 && s.approval > 40,
+ choices: [
+ {
+ label: 'Fund the festival',
+ detail: 'A citywide high and a tourism spike.',
+ cost: 4200,
+ effects: [{ kind: 'approval', value: 11 }, mod('festival-boom', 'Festival Boom', 4, { revenueMul: 1.18 })],
+ },
+ { label: 'Deny the permit', detail: 'Quiet streets, quiet ledger.', effects: [{ kind: 'approval', value: -3 }] },
+ ],
+ },
+]);
diff --git a/games/gridlock/src/config/synergy.js b/games/gridlock/src/config/synergy.js
new file mode 100644
index 0000000..f90f3eb
--- /dev/null
+++ b/games/gridlock/src/config/synergy.js
@@ -0,0 +1,77 @@
+/**
+ * Adjacency synergy rules — the placement puzzle at the heart of GRIDLOCK.
+ *
+ * A rule reads: "for each 8-neighbour of `subject` that matches `neighbor`
+ * (a build category) or `terrain`, apply `per`, up to `cap` neighbours."
+ *
+ * Multipliers are additive fractions applied to the subject's own output:
+ * revenueMul +0.08 × 3 neighbours → ×1.24 revenue
+ * appealAdd -3 × 2 neighbours → -6 appeal
+ *
+ * WHY data, not code: a designer can retune the entire spatial meta-game here
+ * without touching the aggregation pass in core/synergy.js.
+ */
+
+export const SYNERGY_RULES = Object.freeze([
+ {
+ id: 'foot-traffic', subject: 'commercial', neighbor: 'residential',
+ per: { revenueMul: 0.08 }, cap: 4, label: 'Foot Traffic',
+ hint: 'Commerce next to housing sells more.',
+ },
+ {
+ id: 'retail-cluster', subject: 'commercial', neighbor: 'commercial',
+ per: { revenueMul: 0.05 }, cap: 2, label: 'Retail Cluster',
+ hint: 'Shops draw crowds to each other.',
+ },
+ {
+ id: 'supply-chain', subject: 'industry', neighbor: 'industry',
+ per: { revenueMul: 0.07 }, cap: 3, label: 'Supply Chain',
+ hint: 'Factories that share a fence share a pipeline.',
+ },
+ {
+ id: 'direct-feed', subject: 'data', neighbor: 'power',
+ per: { revenueMul: 0.09 }, cap: 2, label: 'Direct Feed',
+ hint: 'Server farms wired straight to a plant lose nothing in transit.',
+ },
+ {
+ id: 'coolant-draw', subject: 'data', terrain: 'water',
+ per: { heatMul: -0.18 }, cap: 3, label: 'Coolant Draw',
+ hint: 'Waterfront data centres dump heat into the bay.',
+ },
+ {
+ id: 'green-outlook', subject: 'residential', neighbor: 'green',
+ per: { appealAdd: 2.5 }, cap: 3, label: 'Green Outlook',
+ hint: 'A window onto a park is worth real approval.',
+ },
+ {
+ id: 'downwind', subject: 'residential', neighbor: 'industry',
+ per: { appealAdd: -3.5 }, cap: 3, label: 'Downwind',
+ hint: 'Nobody wants to sleep beside a foundry.',
+ },
+ {
+ id: 'connected', subject: 'residential', neighbor: 'transit',
+ per: { capacityMul: 0.06 }, cap: 2, label: 'Connected',
+ hint: 'Housing beside transit packs in more residents.',
+ },
+ {
+ id: 'canopy', subject: 'green', neighbor: 'green',
+ per: { heatMul: 0.12 }, cap: 3, label: 'Canopy',
+ hint: 'Contiguous greenery cools harder than scattered planters.',
+ },
+ {
+ id: 'campus', subject: 'data', neighbor: 'civic',
+ per: { techMul: 0.15 }, cap: 2, label: 'Campus',
+ hint: 'Labs beside academies research faster.',
+ },
+ {
+ id: 'shoreline', subject: 'residential', terrain: 'water',
+ per: { appealAdd: 1.5 }, cap: 3, label: 'Shoreline',
+ hint: 'A view of the water lifts a whole block.',
+ },
+]);
+
+/** Rules indexed by subject category — the sim only scans what can apply. */
+export const RULES_BY_SUBJECT = SYNERGY_RULES.reduce((acc, rule) => {
+ (acc[rule.subject] ||= []).push(rule);
+ return acc;
+}, /** @type {Record} */ ({}));
diff --git a/games/gridlock/src/core/commands.js b/games/gridlock/src/core/commands.js
new file mode 100644
index 0000000..2c360bc
--- /dev/null
+++ b/games/gridlock/src/core/commands.js
@@ -0,0 +1,78 @@
+/**
+ * Player commands. Each returns a new state — the caller never mutates.
+ * Validation lives here and nowhere else, so the UI, the tests, and any future
+ * scripted agent all get identical rules.
+ */
+
+import { TAX } from '../config/balance.js';
+import { getBuilding } from '../config/buildings.js';
+import { TERRAIN, idx, inBounds, touchesTerrain } from './grid.js';
+import { cloneState, pushLog } from './state.js';
+
+export const REFUND_RATE = 0.35;
+
+/** Structures that may sit on a ridge; everything else needs flat ground. */
+const HILL_OK = new Set(['geo', 'park', 'road', 'wind']);
+
+/** @returns {{ok: boolean, reason?: string}} */
+export const canPlace = (state, x, y, buildingId) => {
+ if (!inBounds(state.grid, x, y)) return { ok: false, reason: 'Outside the map.' };
+ const def = getBuilding(buildingId);
+ if (!def || def.hidden) return { ok: false, reason: 'Unknown structure.' };
+ if (def.era > state.era) return { ok: false, reason: `Locked until Era ${def.era}.` };
+
+ const tile = state.grid.tiles[idx(state.grid, x, y)];
+ if (tile.b !== null) return { ok: false, reason: 'Tile occupied.' };
+ if (tile.t === TERRAIN.WATER) return { ok: false, reason: 'Cannot build on water.' };
+ if (tile.t === TERRAIN.HILL && !HILL_OK.has(def.id)) return { ok: false, reason: 'Ridge is too steep.' };
+
+ if (def.needs === 'water' && !touchesTerrain(state.grid, x, y, TERRAIN.WATER)) {
+ return { ok: false, reason: 'Must touch open water.' };
+ }
+ if (def.needs === 'hill' && tile.t !== TERRAIN.HILL) {
+ return { ok: false, reason: 'Must be sunk into a ridge.' };
+ }
+ if (def.cost > state.credits) return { ok: false, reason: 'Not enough credits.' };
+ return { ok: true };
+};
+
+/** @returns {{state: object, ok: boolean, reason?: string}} */
+export const place = (state, x, y, buildingId) => {
+ const check = canPlace(state, x, y, buildingId);
+ if (!check.ok) return { state, ...check };
+
+ const def = getBuilding(buildingId);
+ const draft = cloneState(state);
+ const tile = draft.grid.tiles[idx(draft.grid, x, y)];
+ tile.b = buildingId;
+ tile.age = 0;
+ draft.credits -= def.cost;
+ if (def.cost >= 2000) pushLog(draft, `${def.name} under construction.`, 'info');
+ return { state: draft, ok: true };
+};
+
+/** @returns {{state: object, ok: boolean, reason?: string}} */
+export const demolish = (state, x, y) => {
+ if (!inBounds(state.grid, x, y)) return { state, ok: false, reason: 'Outside the map.' };
+ const tile = state.grid.tiles[idx(state.grid, x, y)];
+ if (tile.b === null) return { state, ok: false, reason: 'Nothing here.' };
+ if (tile.b === 'nexus') return { state, ok: false, reason: 'The Nexus cannot be removed.' };
+
+ const def = getBuilding(tile.b);
+ const draft = cloneState(state);
+ const target = draft.grid.tiles[idx(draft.grid, x, y)];
+ target.b = null;
+ target.on = false;
+ draft.credits += Math.round((def?.cost ?? 0) * REFUND_RATE);
+ return { state: draft, ok: true };
+};
+
+export const setTax = (state, rate) => ({
+ ...state,
+ taxRate: Math.max(TAX.min, Math.min(TAX.max, Math.round(rate * 100) / 100)),
+});
+
+export const setSpeed = (state, speed) => ({
+ ...state,
+ speed: Math.max(0, Math.min(3, speed | 0)),
+});
diff --git a/games/gridlock/src/core/directives.js b/games/gridlock/src/core/directives.js
new file mode 100644
index 0000000..ad8156e
--- /dev/null
+++ b/games/gridlock/src/core/directives.js
@@ -0,0 +1,69 @@
+/**
+ * Directive queue evaluation. Runs once per month against a draft state.
+ */
+
+import { DIRECTIVES, MAX_ACTIVE_DIRECTIVES, directiveById } from '../config/directives.js';
+import { pushLog } from './state.js';
+
+/** Fill the active slots from the queue, in order, skipping finished ones. */
+export const refillDirectives = (state) => {
+ const { active, done } = state.directives;
+ for (const directive of DIRECTIVES) {
+ if (active.length >= MAX_ACTIVE_DIRECTIVES) break;
+ if (done.includes(directive.id) || active.includes(directive.id)) continue;
+ active.push(directive.id);
+ }
+ return state;
+};
+
+/** True when the directive's condition is met right now. */
+export const isComplete = (directive, state, stats) => {
+ if (directive.test) return Boolean(directive.test(state, stats));
+ if (directive.streak) return (state.directives.progress[directive.id] ?? 0) >= directive.streak;
+ return false;
+};
+
+/** Current/goal pair for the progress bar, or null when not measurable. */
+export const directiveProgress = (directive, state, stats) => {
+ if (directive.streak) {
+ return [state.directives.progress[directive.id] ?? 0, directive.streak];
+ }
+ if (directive.progress) {
+ const [current, goal] = directive.progress(state, stats);
+ return [Math.min(current, goal), goal];
+ }
+ return null;
+};
+
+/**
+ * Advance streaks, pay out completions, and pull in replacements.
+ * Mutates the draft state — called only from the tick.
+ */
+export const evaluateDirectives = (state, stats) => {
+ refillDirectives(state);
+
+ for (const id of [...state.directives.active]) {
+ const directive = directiveById(id);
+ if (!directive) continue;
+
+ if (directive.streak) {
+ const held = directive.holds(state, stats);
+ const current = state.directives.progress[id] ?? 0;
+ state.directives.progress[id] = held ? current + 1 : 0;
+ }
+ if (!isComplete(directive, state, stats)) continue;
+
+ state.directives.active = state.directives.active.filter((a) => a !== id);
+ state.directives.done.push(id);
+ state.credits += directive.reward.credits ?? 0;
+ state.tech += directive.reward.tech ?? 0;
+
+ const reward = directive.reward.credits
+ ? ` +₡${directive.reward.credits.toLocaleString()}`
+ : '';
+ pushLog(state, `Directive complete — ${directive.title}.${reward}`, 'good');
+ if (directive.victory) state.won = true;
+ }
+
+ return refillDirectives(state);
+};
diff --git a/games/gridlock/src/core/economy.js b/games/gridlock/src/core/economy.js
new file mode 100644
index 0000000..d655fc8
--- /dev/null
+++ b/games/gridlock/src/core/economy.js
@@ -0,0 +1,75 @@
+/**
+ * The three feedback loops that make a city feel alive: approval, migration,
+ * and heat. Each is a pure function of (state, stats) so they can be tested
+ * in isolation and reasoned about one at a time.
+ */
+
+import { APPROVAL, CLIMATE, GROWTH, LABOR, TAX } from '../config/balance.js';
+
+const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
+
+/** Pollution normalised against city size — a big city tolerates more smoke. */
+export const pollutionPressure = (stats) =>
+ clamp(stats.pollution / Math.max(14, stats.housingCount * 2.2), 0, 1.4);
+
+/** 0 below the threshold, 1 at the critical index. */
+export const heatPressure = (heat) =>
+ clamp((heat - CLIMATE.threshold) / (CLIMATE.critical - CLIMATE.threshold), 0, 1);
+
+/**
+ * Where approval is heading. Approval itself chases this with inertia, so a
+ * single bad month never collapses a city and a single park never saves one.
+ */
+export const approvalTarget = (state, stats) => {
+ const target = APPROVAL.base
+ + clamp(stats.appeal, -APPROVAL.appealCap, APPROVAL.appealCap)
+ - stats.unemployment * APPROVAL.unemploymentWeight
+ - pollutionPressure(stats) * APPROVAL.pollutionWeight
+ - (1 - stats.power.ratio) * APPROVAL.powerWeight
+ - (1 - stats.water.ratio) * APPROVAL.waterWeight
+ - stats.gridlock * APPROVAL.congestionWeight
+ + (stats.coverage - 0.5) * APPROVAL.coverageWeight
+ - heatPressure(state.heat) * APPROVAL.heatWeight
+ - (state.taxRate - TAX.neutral) * TAX.approvalSlope
+ + stats.mods.approvalAdd;
+ return clamp(target, 0, 100);
+};
+
+export const nextApproval = (state, stats) => {
+ const target = approvalTarget(state, stats);
+ return clamp(state.approval + (target - state.approval) * APPROVAL.inertia, 0, 100);
+};
+
+/**
+ * Migration. Housing sets the ceiling, approval sets how full it gets, and
+ * jobs cap the whole thing — a city with no work sheds people no matter how
+ * pretty it is.
+ */
+export const nextPopulation = (state, stats) => {
+ const desired = stats.capacity * (GROWTH.floorShare + (1 - GROWTH.floorShare) * (state.approval / 100));
+ const jobCeiling = (stats.jobs / LABOR.participation) * LABOR.slack;
+ const target = Math.min(desired, jobCeiling);
+ let pop = state.population + (target - state.population) * GROWTH.rate * stats.mods.growthMul;
+
+ // Utility failure drives people out faster than approval alone would.
+ const outage = Math.max(1 - stats.power.ratio, 1 - stats.water.ratio);
+ if (outage > 0) pop -= pop * GROWTH.exodusRate * outage;
+
+ return Math.max(0, pop);
+};
+
+export const nextHeat = (state, stats) => {
+ const delta = stats.heatLoad * CLIMATE.gain - CLIMATE.decay * (state.heat / 40);
+ return clamp(state.heat + delta, 0, CLIMATE.max);
+};
+
+/** Grade shown on the HUD. Purely cosmetic, but players chase it. */
+export const cityGrade = (approval, heat) => {
+ const score = approval - heatPressure(heat) * 25;
+ if (score >= 88) return { label: 'UTOPIAN', tone: 'good' };
+ if (score >= 74) return { label: 'THRIVING', tone: 'good' };
+ if (score >= 58) return { label: 'STABLE', tone: 'ok' };
+ if (score >= 42) return { label: 'STRAINED', tone: 'warn' };
+ if (score >= 25) return { label: 'FAILING', tone: 'bad' };
+ return { label: 'COLLAPSING', tone: 'bad' };
+};
diff --git a/games/gridlock/src/core/events.js b/games/gridlock/src/core/events.js
new file mode 100644
index 0000000..1e2ea41
--- /dev/null
+++ b/games/gridlock/src/core/events.js
@@ -0,0 +1,129 @@
+/**
+ * Crisis engine — draws cards from the deck and interprets their effects.
+ *
+ * Every random decision routes through state.rollCount so a save replays
+ * identically: the roll is a function of (seed, rollCount), never of wall
+ * clock or Math.random.
+ */
+
+import { EVENTS } from '../config/balance.js';
+import { CRISIS_DECK } from '../config/events.js';
+import { getBuilding } from '../config/buildings.js';
+import { makeRng, weightedPick } from './rng.js';
+import { cloneState, pushLog } from './state.js';
+
+/** Advance the deterministic stream and return the next value in [0,1). */
+export const nextRoll = (state) => {
+ state.rollCount += 1;
+ return makeRng(state.seed + state.rollCount * 7919)();
+};
+
+const byId = new Map(CRISIS_DECK.map((c) => [c.id, c]));
+export const getCrisis = (id) => byId.get(id);
+
+/**
+ * Decide whether a crisis fires this month. Mutates the draft state's roll
+ * counter and returns the drawn card, or null.
+ */
+export const drawCrisis = (state, stats) => {
+ if (state.crisis.active) return null;
+ if (state.month < EVENTS.graceMonths || state.month < state.crisis.cooldownUntil) return null;
+ if (nextRoll(state) > EVENTS.chance) return null;
+
+ const eligible = CRISIS_DECK.filter((card) => {
+ try {
+ return card.when(state, stats);
+ } catch {
+ return false; // A malformed condition must never take the game down.
+ }
+ });
+ if (eligible.length === 0) return null;
+
+ // Unseen cards are three times as likely — the deck stays surprising longer.
+ const weightOf = (card) => card.weight * (state.crisis.seen.includes(card.id) ? 1 : 3);
+ return weightedPick(eligible, weightOf, nextRoll(state));
+};
+
+const destroyOne = (state, cat) => {
+ const targets = state.grid.tiles.filter((t) => {
+ if (t.b === null || t.b === 'nexus') return false;
+ return cat ? getBuilding(t.b)?.cat === cat : true;
+ });
+ if (targets.length === 0) return null;
+ const victim = targets[Math.floor(nextRoll(state) * targets.length) % targets.length];
+ const name = getBuilding(victim.b)?.name ?? 'structure';
+ victim.b = null;
+ victim.on = false;
+ return name;
+};
+
+const applyEffects = (state, stats, effects) => {
+ for (const effect of effects ?? []) {
+ switch (effect.kind) {
+ case 'credits':
+ state.credits += typeof effect.value === 'function' ? effect.value(state, stats) : effect.value;
+ break;
+ case 'approval':
+ state.approval = Math.max(0, Math.min(100, state.approval + effect.value));
+ break;
+ case 'pop':
+ state.population = effect.mul ? state.population * effect.mul : state.population + effect.value;
+ break;
+ case 'tech':
+ state.tech += effect.value;
+ break;
+ case 'heat':
+ state.heat = Math.max(0, Math.min(100, state.heat + effect.value));
+ break;
+ case 'modifier':
+ state.modifiers = [
+ ...state.modifiers.filter((m) => m.id !== effect.id),
+ { id: effect.id, label: effect.label, months: effect.months, effects: effect.effects },
+ ];
+ break;
+ case 'destroy': {
+ for (let n = 0; n < (effect.count ?? 1); n++) {
+ const lost = destroyOne(state, effect.cat);
+ if (lost) pushLog(state, `${lost} lost.`, 'bad');
+ }
+ break;
+ }
+ case 'risk': {
+ const hit = nextRoll(state) < effect.chance;
+ applyEffects(state, stats, hit ? effect.then : effect.otherwise);
+ break;
+ }
+ default:
+ break;
+ }
+ }
+};
+
+/**
+ * Resolve the active crisis with the chosen option.
+ * @returns {{state: object, ok: boolean, reason?: string}}
+ */
+export const resolveCrisis = (state, stats, choiceIndex) => {
+ const card = state.crisis.active ? getCrisis(state.crisis.active) : null;
+ if (!card) return { state, ok: false, reason: 'No active crisis.' };
+ const choice = card.choices[choiceIndex];
+ if (!choice) return { state, ok: false, reason: 'Unknown option.' };
+
+ const cost = typeof choice.cost === 'function' ? choice.cost(state, stats) : (choice.cost ?? 0);
+ if (cost > state.credits) return { state, ok: false, reason: 'Not enough credits.' };
+
+ const draft = cloneState(state);
+ draft.credits -= cost;
+ applyEffects(draft, stats, choice.effects);
+ draft.crisis = {
+ active: null,
+ cooldownUntil: draft.month + EVENTS.cooldown,
+ seen: draft.crisis.seen.includes(card.id) ? draft.crisis.seen : [...draft.crisis.seen, card.id],
+ };
+ pushLog(draft, `${card.title}: ${choice.label}.`, 'event');
+ return { state: draft, ok: true };
+};
+
+/** Cost of an option right now, for the modal to render and disable buttons. */
+export const choiceCost = (choice, state, stats) =>
+ typeof choice.cost === 'function' ? choice.cost(state, stats) : (choice.cost ?? 0);
diff --git a/games/gridlock/src/core/fields.js b/games/gridlock/src/core/fields.js
new file mode 100644
index 0000000..75d6517
--- /dev/null
+++ b/games/gridlock/src/core/fields.js
@@ -0,0 +1,67 @@
+/**
+ * Spatial fields: desirability and service coverage.
+ *
+ * Both are radial and linearly falling off, computed once per tick and reused
+ * by the economy, the renderer overlays, and the tile inspector.
+ */
+
+import { getBuilding } from '../config/buildings.js';
+import { SERVICE_KINDS } from '../config/balance.js';
+import { forEachInRadius, TERRAIN } from './grid.js';
+import { tileSynergy } from './synergy.js';
+
+/**
+ * @param {object} grid
+ * @param {Set} live tiles that are road-connected and utility-fed
+ * @returns {{appeal: Float32Array, services: Record}}
+ */
+export const computeFields = (grid, live) => {
+ const size = grid.w * grid.h;
+ const appeal = new Float32Array(size);
+ const services = Object.fromEntries(SERVICE_KINDS.map((k) => [k, new Float32Array(size)]));
+
+ for (const tile of grid.tiles) {
+ // Open water is pleasant to look at even where nothing is built.
+ if (tile.t === TERRAIN.WATER) appeal[tile.i] += 1.5;
+ if (tile.b === null) continue;
+ const def = getBuilding(tile.b);
+ if (!def || !live.has(tile.i)) continue;
+
+ if (def.appeal && def.appealRadius) {
+ const bonus = def.appeal + tileSynergy(grid, tile).appealAdd;
+ forEachInRadius(grid, tile.x, tile.y, def.appealRadius, (t, dist) => {
+ appeal[t.i] += bonus * (1 - dist / (def.appealRadius + 1));
+ });
+ }
+ if (def.service) {
+ const field = services[def.service];
+ forEachInRadius(grid, tile.x, tile.y, def.serviceRadius, (t, dist) => {
+ field[t.i] = Math.max(field[t.i], 1 - (dist / (def.serviceRadius + 1)) * 0.5);
+ });
+ }
+ }
+ return { appeal, services };
+};
+
+/**
+ * Mean service coverage across housing, 0–1. Empty cities read as fully
+ * covered so a brand-new map is not punished for having no citizens yet.
+ */
+export const housingCoverage = (grid, fields, housingTiles) => {
+ if (housingTiles.length === 0) return 1;
+ let total = 0;
+ for (const tile of housingTiles) {
+ let sum = 0;
+ for (const kind of SERVICE_KINDS) sum += fields.services[kind][tile.i];
+ total += sum / SERVICE_KINDS.length;
+ }
+ return total / housingTiles.length;
+};
+
+/** Mean desirability across housing, used for approval and growth. */
+export const housingAppeal = (grid, fields, housingTiles) => {
+ if (housingTiles.length === 0) return 0;
+ let total = 0;
+ for (const tile of housingTiles) total += fields.appeal[tile.i];
+ return total / housingTiles.length;
+};
diff --git a/games/gridlock/src/core/grid.js b/games/gridlock/src/core/grid.js
new file mode 100644
index 0000000..59c0bfe
--- /dev/null
+++ b/games/gridlock/src/core/grid.js
@@ -0,0 +1,90 @@
+/**
+ * Tile grid: terrain generation, indexing, and neighbourhood queries.
+ * Knows nothing about economics — it is pure geometry.
+ */
+
+import { GRID } from '../config/balance.js';
+import { noise2, hash2 } from './rng.js';
+
+export const TERRAIN = Object.freeze({ LAND: 0, WATER: 1, HILL: 2 });
+
+export const idx = (grid, x, y) => y * grid.w + x;
+export const inBounds = (grid, x, y) => x >= 0 && y >= 0 && x < grid.w && y < grid.h;
+export const tileAt = (grid, x, y) => (inBounds(grid, x, y) ? grid.tiles[idx(grid, x, y)] : null);
+
+const NEIGHBORS_4 = [[1, 0], [-1, 0], [0, 1], [0, -1]];
+const NEIGHBORS_8 = [...NEIGHBORS_4, [1, 1], [1, -1], [-1, 1], [-1, -1]];
+
+/** Orthogonal neighbours — used for road networks and terrain requirements. */
+export const neighbors4 = (grid, x, y) =>
+ NEIGHBORS_4.map(([dx, dy]) => tileAt(grid, x + dx, y + dy)).filter(Boolean);
+
+/** Full ring — used for adjacency synergy. */
+export const neighbors8 = (grid, x, y) =>
+ NEIGHBORS_8.map(([dx, dy]) => tileAt(grid, x + dx, y + dy)).filter(Boolean);
+
+const makeTile = (i, w, terrain, seed) => ({
+ i,
+ x: i % w,
+ y: Math.floor(i / w),
+ t: terrain,
+ b: null,
+ age: 0,
+ on: false,
+ variant: hash2(i % w, Math.floor(i / w), seed + 77),
+});
+
+/**
+ * Generate a bay-and-ridge map. The centre 6×6 is forced to buildable land so
+ * the opening move is never blocked by terrain.
+ */
+export const createGrid = (seed, w = GRID.width, h = GRID.height) => {
+ const grid = { w, h, tiles: new Array(w * h) };
+ const cx = (w - 1) / 2;
+ const cy = (h - 1) / 2;
+
+ for (let i = 0; i < w * h; i++) {
+ const x = i % w;
+ const y = Math.floor(i / w);
+ const coast = noise2(x, y, 9, seed) * 0.75 + noise2(x, y, 3.5, seed + 1) * 0.25;
+ // Push water toward the south-east corner so the bay reads as a coastline.
+ const bay = (x / w) * 0.28 + (y / h) * 0.34;
+ const ridge = noise2(x, y, 6, seed + 400);
+
+ let terrain = TERRAIN.LAND;
+ if (coast + bay > 0.86) terrain = TERRAIN.WATER;
+ else if (ridge > 0.78) terrain = TERRAIN.HILL;
+
+ const nearCentre = Math.abs(x - cx) < 3 && Math.abs(y - cy) < 3;
+ grid.tiles[i] = makeTile(i, w, nearCentre ? TERRAIN.LAND : terrain, seed);
+ }
+ return grid;
+};
+
+/** Shallow-copies every tile so a simulation pass never mutates prior state. */
+export const cloneGrid = (grid) => ({
+ w: grid.w,
+ h: grid.h,
+ tiles: grid.tiles.map((t) => ({ ...t })),
+});
+
+/** True when any orthogonal neighbour has the given terrain type. */
+export const touchesTerrain = (grid, x, y, terrain) =>
+ neighbors4(grid, x, y).some((t) => t.t === terrain);
+
+/** Every built tile, in index order. */
+export const builtTiles = (grid) => grid.tiles.filter((t) => t.b !== null);
+
+/** Iterate tiles inside a square radius without allocating an array. */
+export const forEachInRadius = (grid, x, y, radius, fn) => {
+ const x0 = Math.max(0, x - radius);
+ const x1 = Math.min(grid.w - 1, x + radius);
+ const y0 = Math.max(0, y - radius);
+ const y1 = Math.min(grid.h - 1, y + radius);
+ for (let ty = y0; ty <= y1; ty++) {
+ for (let tx = x0; tx <= x1; tx++) {
+ const dist = Math.hypot(tx - x, ty - y);
+ if (dist <= radius) fn(grid.tiles[idx(grid, tx, ty)], dist);
+ }
+ }
+};
diff --git a/games/gridlock/src/core/network.js b/games/gridlock/src/core/network.js
new file mode 100644
index 0000000..8185929
--- /dev/null
+++ b/games/gridlock/src/core/network.js
@@ -0,0 +1,78 @@
+/**
+ * Road connectivity.
+ *
+ * A structure is live only when it touches a road that traces back to the
+ * Nexus. This is what makes roads a real decision instead of decoration.
+ */
+
+import { NEEDS_ROAD, getBuilding } from '../config/buildings.js';
+import { idx, inBounds } from './grid.js';
+
+const STEPS = [[1, 0], [-1, 0], [0, 1], [0, -1]];
+
+/**
+ * @returns {{roads: Set, connected: Set, roadCount: number}}
+ * `roads` — road tiles wired to the Nexus.
+ * `connected` — every tile whose structure is legally serviced.
+ */
+export const computeNetwork = (grid) => {
+ const roads = new Set();
+ const connected = new Set();
+ const origin = grid.tiles.find((t) => t.b === 'nexus');
+ if (!origin) return { roads, connected, roadCount: 0 };
+
+ connected.add(origin.i);
+
+ // Flood the road graph outward from the tiles orthogonal to the Nexus.
+ const queue = [];
+ for (const [dx, dy] of STEPS) {
+ const nx = origin.x + dx;
+ const ny = origin.y + dy;
+ if (!inBounds(grid, nx, ny)) continue;
+ const t = grid.tiles[idx(grid, nx, ny)];
+ if (t.b === 'road' && !roads.has(t.i)) {
+ roads.add(t.i);
+ queue.push(t);
+ }
+ }
+ while (queue.length) {
+ const tile = queue.pop();
+ for (const [dx, dy] of STEPS) {
+ const nx = tile.x + dx;
+ const ny = tile.y + dy;
+ if (!inBounds(grid, nx, ny)) continue;
+ const next = grid.tiles[idx(grid, nx, ny)];
+ if (next.b === 'road' && !roads.has(next.i)) {
+ roads.add(next.i);
+ queue.push(next);
+ }
+ }
+ }
+
+ for (const i of roads) connected.add(i);
+
+ // Any structure orthogonally touching the live road graph is serviced.
+ // Structures outside NEEDS_ROAD (power, water, parks) run standalone.
+ for (const tile of grid.tiles) {
+ if (tile.b === null || connected.has(tile.i)) continue;
+ const def = getBuilding(tile.b);
+ if (!def) continue;
+ if (!NEEDS_ROAD.has(def.cat)) {
+ connected.add(tile.i);
+ continue;
+ }
+ for (const [dx, dy] of STEPS) {
+ const nx = tile.x + dx;
+ const ny = tile.y + dy;
+ if (inBounds(grid, nx, ny) && roads.has(idx(grid, nx, ny))) {
+ connected.add(tile.i);
+ break;
+ }
+ }
+ }
+
+ let roadCount = 0;
+ for (const tile of grid.tiles) if (tile.b === 'road') roadCount++;
+
+ return { roads, connected, roadCount };
+};
diff --git a/games/gridlock/src/core/rationing.js b/games/gridlock/src/core/rationing.js
new file mode 100644
index 0000000..8f86abe
--- /dev/null
+++ b/games/gridlock/src/core/rationing.js
@@ -0,0 +1,42 @@
+/**
+ * Deterministic utility rationing.
+ *
+ * When supply falls short, the shortfall must land somewhere specific and
+ * repeatable — never on a random building. Consumers are served in a fixed
+ * priority order, then by tile index, so the same shortfall always browns out
+ * the same blocks and the player can reason about it.
+ */
+
+/** Lower rank is served first. Factories go dark before hospitals do. */
+export const POWER_PRIORITY = Object.freeze({
+ water: 0, civic: 1, special: 2, residential: 3, transit: 4,
+ green: 5, commercial: 6, data: 7, industry: 8, road: 9, power: 9,
+});
+
+export const WATER_PRIORITY = Object.freeze({
+ residential: 0, civic: 1, special: 2, green: 3, commercial: 4,
+ data: 5, industry: 6, transit: 7, road: 9, power: 9, water: 9,
+});
+
+/**
+ * @param {{i: number, cat: string, draw: number}[]} consumers
+ * @param {number} supply
+ * @param {Record} priority
+ * @returns {{served: Set, demand: number, ratio: number}}
+ */
+export const ration = (consumers, supply, priority) => {
+ const demand = consumers.reduce((sum, c) => sum + c.draw, 0);
+ const served = new Set();
+ if (demand === 0) return { served, demand: 0, ratio: 1 };
+
+ const queue = [...consumers].sort(
+ (a, b) => (priority[a.cat] ?? 9) - (priority[b.cat] ?? 9) || a.i - b.i,
+ );
+ let budget = supply;
+ for (const consumer of queue) {
+ if (consumer.draw > budget) continue; // Skip, don't stop: small loads still fit.
+ budget -= consumer.draw;
+ served.add(consumer.i);
+ }
+ return { served, demand, ratio: Math.min(1, supply / demand) };
+};
diff --git a/games/gridlock/src/core/rng.js b/games/gridlock/src/core/rng.js
new file mode 100644
index 0000000..5d2d479
--- /dev/null
+++ b/games/gridlock/src/core/rng.js
@@ -0,0 +1,54 @@
+/**
+ * Deterministic randomness.
+ *
+ * INVARIANT: the simulation never calls Math.random. Every stochastic decision
+ * draws from a seeded stream stored in state, so a save file replays exactly.
+ */
+
+/** mulberry32 — small, fast, good enough for terrain and event draws. */
+export const makeRng = (seed) => {
+ let a = seed >>> 0;
+ return () => {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = a;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+};
+
+/** Stable 2-D hash in [0,1). Used for terrain and per-tile render variance. */
+export const hash2 = (x, y, seed = 0) => {
+ let h = Math.imul(x | 0, 374761393) ^ Math.imul(y | 0, 668265263) ^ Math.imul(seed | 0, 2246822519);
+ h = Math.imul(h ^ (h >>> 13), 1274126177);
+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
+};
+
+const smooth = (t) => t * t * (3 - 2 * t);
+
+/** Value noise over the tile plane at the given cell size. */
+export const noise2 = (x, y, scale, seed) => {
+ const px = x / scale;
+ const py = y / scale;
+ const x0 = Math.floor(px);
+ const y0 = Math.floor(py);
+ const fx = smooth(px - x0);
+ const fy = smooth(py - y0);
+ const a = hash2(x0, y0, seed);
+ const b = hash2(x0 + 1, y0, seed);
+ const c = hash2(x0, y0 + 1, seed);
+ const d = hash2(x0 + 1, y0 + 1, seed);
+ return (a * (1 - fx) + b * fx) * (1 - fy) + (c * (1 - fx) + d * fx) * fy;
+};
+
+/** Pick one weighted entry. `weightOf` must return a non-negative number. */
+export const weightedPick = (items, weightOf, roll) => {
+ const total = items.reduce((sum, item) => sum + Math.max(0, weightOf(item)), 0);
+ if (total <= 0) return null;
+ let cursor = roll * total;
+ for (const item of items) {
+ cursor -= Math.max(0, weightOf(item));
+ if (cursor <= 0) return item;
+ }
+ return items[items.length - 1];
+};
diff --git a/games/gridlock/src/core/simulate.js b/games/gridlock/src/core/simulate.js
new file mode 100644
index 0000000..4d6658c
--- /dev/null
+++ b/games/gridlock/src/core/simulate.js
@@ -0,0 +1,101 @@
+/**
+ * The monthly tick — the only place time moves.
+ *
+ * Pure: tickMonth(state) → new state. Nothing here reads the clock, the DOM,
+ * or Math.random, which is what makes the whole simulation testable and a save
+ * file replayable.
+ */
+
+import { eraForTech, eraById } from '../config/eras.js';
+import { computeStats } from './stats.js';
+import { nextApproval, nextHeat, nextPopulation } from './economy.js';
+import { evaluateDirectives } from './directives.js';
+import { drawCrisis } from './events.js';
+import { cloneState, pushHistory, pushLog } from './state.js';
+
+export const BANKRUPT_FLOOR = -2000;
+
+/** Expire timed crisis modifiers, logging the ones that lapse. */
+const ageModifiers = (draft) => {
+ const survivors = [];
+ for (const mod of draft.modifiers) {
+ const months = mod.months - 1;
+ if (months > 0) survivors.push({ ...mod, months });
+ else pushLog(draft, `${mod.label} has lifted.`, 'info');
+ }
+ draft.modifiers = survivors;
+};
+
+/** Mirror the live set onto tiles so the renderer needs no simulation logic. */
+const syncTiles = (draft, stats) => {
+ for (const tile of draft.grid.tiles) {
+ if (tile.b === null) continue;
+ tile.on = stats.live.has(tile.i);
+ tile.age += 1;
+ }
+};
+
+const advanceEra = (draft) => {
+ const era = eraForTech(draft.tech);
+ if (era <= draft.era) return;
+ draft.era = era;
+ const info = eraById(era);
+ pushLog(draft, `Era ${era} — ${info.name}. New structures unlocked.`, 'good');
+};
+
+const recordHistory = (draft, stats) => {
+ pushHistory(draft.history.population, Math.round(draft.population));
+ pushHistory(draft.history.credits, Math.round(draft.credits));
+ pushHistory(draft.history.approval, Math.round(draft.approval));
+ pushHistory(draft.history.heat, Math.round(draft.heat));
+ pushHistory(draft.history.net, Math.round(stats.net));
+};
+
+/**
+ * @param {object} state
+ * @returns {object} the next state, with `stats` holding this month's snapshot
+ */
+export const tickMonth = (state) => {
+ const stats = computeStats(state);
+ const draft = cloneState(state);
+
+ draft.month += 1;
+ draft.credits += stats.net;
+ draft.population = nextPopulation(state, stats);
+ draft.approval = nextApproval(state, stats);
+ draft.heat = nextHeat(state, stats);
+ draft.tech += stats.techGain;
+
+ advanceEra(draft);
+ ageModifiers(draft);
+ syncTiles(draft, stats);
+ evaluateDirectives(draft, stats);
+
+ const card = drawCrisis(draft, stats);
+ if (card) {
+ draft.crisis = { ...draft.crisis, active: card.id };
+ draft.speed = 0; // A crisis stops the world until the player answers it.
+ for (const effect of card.onFire ?? []) {
+ draft.modifiers = [
+ ...draft.modifiers.filter((m) => m.id !== effect.id),
+ { id: effect.id, label: effect.label, months: effect.months, effects: effect.effects },
+ ];
+ }
+ pushLog(draft, `${card.title} — a decision is required.`, 'event');
+ }
+
+ if (draft.credits < BANKRUPT_FLOOR && !draft.bankrupt) {
+ draft.bankrupt = true;
+ draft.speed = 0;
+ pushLog(draft, 'The treasury is empty. The city is in receivership.', 'bad');
+ } else if (draft.credits >= 0 && draft.bankrupt) {
+ draft.bankrupt = false;
+ }
+
+ recordHistory(draft, stats);
+ draft.stats = stats;
+ return draft;
+};
+
+/** Recompute the stats snapshot without advancing time (used after edits). */
+export const refreshStats = (state) => ({ ...state, stats: computeStats(state) });
diff --git a/games/gridlock/src/core/state.js b/games/gridlock/src/core/state.js
new file mode 100644
index 0000000..64a6f09
--- /dev/null
+++ b/games/gridlock/src/core/state.js
@@ -0,0 +1,95 @@
+/**
+ * State construction and cloning.
+ *
+ * INVARIANT: state is plain, serialisable data. No class instances, no
+ * functions, no DOM references — so a save file is `JSON.stringify(state)` and
+ * a replay is exact.
+ */
+
+import { GRID, START } from '../config/balance.js';
+import { createGrid, cloneGrid, idx } from './grid.js';
+
+export const LOG_LIMIT = 40;
+export const HISTORY_LIMIT = 72;
+
+export const createInitialState = (seed = (Date.now() & 0xffffff)) => {
+ const grid = createGrid(seed);
+ const cx = Math.floor(GRID.width / 2);
+ const cy = Math.floor(GRID.height / 2);
+ const nexus = grid.tiles[idx(grid, cx, cy)];
+ nexus.b = 'nexus';
+ nexus.on = true;
+
+ // Seed a short road stub so the first zone has somewhere legal to sit.
+ for (const [dx, dy] of [[1, 0], [2, 0], [-1, 0], [-2, 0], [0, 1], [0, -1]]) {
+ grid.tiles[idx(grid, cx + dx, cy + dy)].b = 'road';
+ }
+
+ const state = {
+ version: 1,
+ seed,
+ month: 0,
+ credits: START.credits,
+ population: 0,
+ approval: START.approval,
+ heat: START.heat,
+ taxRate: START.taxRate,
+ tech: 0,
+ era: 1,
+ speed: 1,
+ won: false,
+ bankrupt: false,
+ grid,
+ stats: null,
+ crisis: { active: null, cooldownUntil: 0, seen: [] },
+ modifiers: [],
+ directives: { active: [], done: [], progress: {} },
+ history: { population: [], credits: [], approval: [], heat: [], net: [] },
+ log: [],
+ rollCount: 0,
+ };
+ return pushLog(state, 'The Nexus is online. Build outward.', 'good');
+};
+
+/**
+ * Structural clone. Cheap enough to run per command and per tick (1024 shallow
+ * tile copies ≈ tens of microseconds) and it removes every aliasing bug class.
+ */
+export const cloneState = (state) => ({
+ ...state,
+ grid: cloneGrid(state.grid),
+ crisis: { ...state.crisis, seen: [...state.crisis.seen] },
+ modifiers: state.modifiers.map((m) => ({ ...m })),
+ directives: {
+ active: [...state.directives.active],
+ done: [...state.directives.done],
+ progress: { ...state.directives.progress },
+ },
+ history: {
+ population: [...state.history.population],
+ credits: [...state.history.credits],
+ approval: [...state.history.approval],
+ heat: [...state.history.heat],
+ net: [...state.history.net],
+ },
+ log: [...state.log],
+});
+
+/** Append a dated line to the city log, newest first, bounded. */
+export const pushLog = (state, text, kind = 'info') => {
+ state.log = [{ month: state.month, text, kind }, ...state.log].slice(0, LOG_LIMIT);
+ return state;
+};
+
+/** Append to a bounded history series. */
+export const pushHistory = (series, value) => {
+ series.push(value);
+ if (series.length > HISTORY_LIMIT) series.shift();
+ return series;
+};
+
+/** Month index → in-world date. Month 0 is Jan 2087. */
+export const formatDate = (month) => {
+ const names = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
+ return `${names[month % 12]} ${2087 + Math.floor(month / 12)}`;
+};
diff --git a/games/gridlock/src/core/stats.js b/games/gridlock/src/core/stats.js
new file mode 100644
index 0000000..fd55710
--- /dev/null
+++ b/games/gridlock/src/core/stats.js
@@ -0,0 +1,142 @@
+/**
+ * The aggregation pass: one grid scan per month producing every number the
+ * rest of the game reads. Pure — takes state, returns a stats snapshot.
+ *
+ * Resolution order matters and flows one way:
+ * roads → power → water → live structures → output → traffic
+ * Water plants that lost power cannot pump; that dependency is expressed by
+ * ordering, not by special-casing.
+ */
+
+import { CLIMATE, LABOR, TRAFFIC } from '../config/balance.js';
+import { getBuilding } from '../config/buildings.js';
+import { computeNetwork } from './network.js';
+import { ration, POWER_PRIORITY, WATER_PRIORITY } from './rationing.js';
+import { computeFields, housingAppeal, housingCoverage } from './fields.js';
+import { tileSynergy } from './synergy.js';
+
+/** Collapse active crisis modifiers into one multiplier set. */
+export const aggregateModifiers = (modifiers) => {
+ const acc = {
+ powerSupplyMul: 1, powerDemandMul: 1, waterSupplyMul: 1, waterDemandMul: 1,
+ revenueMul: 1, upkeepMul: 1, growthMul: 1, approvalAdd: 0, heatAdd: 0,
+ };
+ for (const mod of modifiers) {
+ for (const [key, value] of Object.entries(mod.effects ?? {})) {
+ if (key === 'approvalAdd' || key === 'heatAdd') acc[key] += value;
+ else if (key in acc) acc[key] *= value;
+ }
+ }
+ return acc;
+};
+
+const emptyBreakdown = () => ({ tax: 0, commercial: 0, industry: 0, data: 0, civic: 0 });
+
+export const computeStats = (state) => {
+ const { grid } = state;
+ const mods = aggregateModifiers(state.modifiers);
+ const network = computeNetwork(grid);
+ const built = grid.tiles.filter((t) => t.b !== null);
+
+ // ── Power ────────────────────────────────────────────────────────────────
+ const coolingMul = 1 + Math.max(0, state.heat - CLIMATE.threshold) * CLIMATE.coolingLoad;
+ let powerSupply = 0;
+ const powerConsumers = [];
+ for (const tile of built) {
+ const def = getBuilding(tile.b);
+ if (!def || !network.connected.has(tile.i)) continue;
+ if (def.power > 0) powerSupply += def.power;
+ else if (def.power < 0) {
+ powerConsumers.push({
+ i: tile.i, cat: def.cat, draw: -def.power * coolingMul * mods.powerDemandMul,
+ });
+ }
+ }
+ powerSupply *= mods.powerSupplyMul;
+ const power = ration(powerConsumers, powerSupply, POWER_PRIORITY);
+ const isPowered = (tile, def) =>
+ network.connected.has(tile.i) && (def.power >= 0 || power.served.has(tile.i));
+
+ // ── Water (depends on power) ─────────────────────────────────────────────
+ let waterSupply = 0;
+ const waterConsumers = [];
+ for (const tile of built) {
+ const def = getBuilding(tile.b);
+ if (!def || !isPowered(tile, def)) continue;
+ if (def.water > 0) waterSupply += def.water;
+ else if (def.water < 0) {
+ waterConsumers.push({ i: tile.i, cat: def.cat, draw: -def.water * mods.waterDemandMul });
+ }
+ }
+ waterSupply *= mods.waterSupplyMul;
+ const water = ration(waterConsumers, waterSupply, WATER_PRIORITY);
+
+ // ── Live structures ──────────────────────────────────────────────────────
+ const live = new Set();
+ const housing = [];
+ let stranded = 0;
+ for (const tile of built) {
+ const def = getBuilding(tile.b);
+ if (!def) continue;
+ const ok = isPowered(tile, def) && (def.water >= 0 || water.served.has(tile.i));
+ if (ok) live.add(tile.i);
+ // Cut off from the road graph — the failure players most often miss.
+ else if (def.cat !== 'road' && !network.connected.has(tile.i)) stranded += 1;
+ if (def.cat === 'residential') housing.push(tile);
+ }
+
+ const fields = computeFields(grid, live);
+
+ // ── Output ───────────────────────────────────────────────────────────────
+ let capacity = 0, jobs = 0, pollution = 0, heatLoad = 0, techGain = 0;
+ let upkeep = 0, trafficRelief = 0;
+ const gross = emptyBreakdown();
+ const counts = {};
+
+ for (const tile of built) {
+ const def = getBuilding(tile.b);
+ if (!def) continue;
+ counts[def.cat] = (counts[def.cat] ?? 0) + 1;
+ upkeep += def.upkeep; // Infrastructure costs money whether or not it runs.
+ if (!live.has(tile.i)) continue;
+
+ const syn = tileSynergy(grid, tile);
+ capacity += def.residents * syn.capacityMul;
+ jobs += def.jobs;
+ pollution += def.pollution;
+ heatLoad += def.heat * syn.heatMul;
+ techGain += def.tech * syn.techMul;
+ trafficRelief += def.traffic;
+ if (def.revenue) gross[def.cat] = (gross[def.cat] ?? 0) + def.revenue * syn.revenueMul;
+ }
+
+ // ── Labour + traffic ─────────────────────────────────────────────────────
+ const workforce = state.population * LABOR.participation;
+ const jobsFilled = Math.min(workforce, jobs);
+ const employment = workforce > 0 ? jobsFilled / workforce : 1;
+ const staffing = jobs > 0 ? jobsFilled / jobs : 1;
+
+ const roadCapacity = Math.max(1, network.roadCount * TRAFFIC.roadCapacity);
+ const congestion = (jobsFilled / roadCapacity) * Math.max(0.2, 1 - trafficRelief);
+ const gridlock = Math.max(0, Math.min(1, (congestion - TRAFFIC.tolerance) / (1 - TRAFFIC.tolerance)));
+
+ // ── Money ────────────────────────────────────────────────────────────────
+ const business = (gross.commercial + gross.industry + gross.data + gross.civic) * staffing;
+ const taxIncome = state.population * state.taxRate * 19.5;
+ const revenue = (taxIncome + business * (1 - gridlock * 0.3)) * mods.revenueMul;
+ const expenses = upkeep * mods.upkeepMul;
+
+ return {
+ network, fields, live, powered: power.served, watered: water.served,
+ power: { supply: powerSupply, demand: power.demand, ratio: power.ratio },
+ water: { supply: waterSupply, demand: water.demand, ratio: water.ratio },
+ capacity, jobs, jobsFilled, workforce, employment, staffing,
+ unemployment: Math.max(0, 1 - employment),
+ congestion, gridlock, roadCapacity,
+ pollution, heatLoad: heatLoad + mods.heatAdd, techGain,
+ appeal: housingAppeal(grid, fields, housing),
+ coverage: housingCoverage(grid, fields, housing),
+ revenue, expenses, net: revenue - expenses, taxIncome, business, gross, upkeep,
+ counts, housingCount: housing.length, stranded, mods,
+ };
+};
diff --git a/games/gridlock/src/core/synergy.js b/games/gridlock/src/core/synergy.js
new file mode 100644
index 0000000..abd9a3a
--- /dev/null
+++ b/games/gridlock/src/core/synergy.js
@@ -0,0 +1,60 @@
+/**
+ * Adjacency scoring. Turns the map from a canvas into a puzzle: the same
+ * building is worth materially more or less depending on what it touches.
+ */
+
+import { getBuilding } from '../config/buildings.js';
+import { RULES_BY_SUBJECT } from '../config/synergy.js';
+import { neighbors8, TERRAIN } from './grid.js';
+
+export const NEUTRAL = Object.freeze({
+ revenueMul: 1, capacityMul: 1, heatMul: 1, techMul: 1, appealAdd: 0, hits: [],
+});
+
+const TERRAIN_OF = { water: TERRAIN.WATER, hill: TERRAIN.HILL };
+
+const matches = (rule, neighbor) => {
+ if (rule.neighbor) {
+ const def = neighbor.b ? getBuilding(neighbor.b) : null;
+ return Boolean(def) && def.cat === rule.neighbor;
+ }
+ if (rule.terrain) return neighbor.t === TERRAIN_OF[rule.terrain];
+ return false;
+};
+
+/**
+ * Multipliers earned by one tile from its eight neighbours.
+ * @returns {typeof NEUTRAL}
+ */
+export const tileSynergy = (grid, tile) => {
+ const def = tile.b ? getBuilding(tile.b) : null;
+ if (!def) return NEUTRAL;
+ const rules = RULES_BY_SUBJECT[def.cat];
+ if (!rules) return NEUTRAL;
+
+ const ring = neighbors8(grid, tile.x, tile.y);
+ const out = { revenueMul: 1, capacityMul: 1, heatMul: 1, techMul: 1, appealAdd: 0, hits: [] };
+
+ for (const rule of rules) {
+ let count = 0;
+ for (const neighbor of ring) if (matches(rule, neighbor)) count++;
+ count = Math.min(count, rule.cap);
+ if (count === 0) continue;
+
+ const { revenueMul = 0, capacityMul = 0, heatMul = 0, techMul = 0, appealAdd = 0 } = rule.per;
+ out.revenueMul += revenueMul * count;
+ out.capacityMul += capacityMul * count;
+ out.heatMul += heatMul * count;
+ out.techMul += techMul * count;
+ out.appealAdd += appealAdd * count;
+ out.hits.push({ id: rule.id, label: rule.label, count, cap: rule.cap, hint: rule.hint });
+ }
+ return out;
+};
+
+/**
+ * What a candidate building *would* score at (x, y), for the placement preview.
+ * Evaluates against a temporary tile so nothing is mutated.
+ */
+export const previewSynergy = (grid, x, y, buildingId) =>
+ tileSynergy(grid, { x, y, b: buildingId, t: TERRAIN.LAND, i: -1 });
diff --git a/games/gridlock/src/io/audio.js b/games/gridlock/src/io/audio.js
new file mode 100644
index 0000000..011d1a4
--- /dev/null
+++ b/games/gridlock/src/io/audio.js
@@ -0,0 +1,64 @@
+/**
+ * Synthesised UI audio — no asset files, a few hundred bytes of oscillator.
+ *
+ * The context is created lazily on the first gesture because browsers refuse
+ * to start audio before one, and every call is wrapped: a blocked or missing
+ * AudioContext must never break a click handler.
+ */
+
+const VOICES = {
+ place: { freq: 520, type: 'triangle', ms: 90, gain: 0.16 },
+ bulldoze: { freq: 180, type: 'sawtooth', ms: 130, gain: 0.13 },
+ deny: { freq: 120, type: 'square', ms: 150, gain: 0.1 },
+ tick: { freq: 880, type: 'sine', ms: 40, gain: 0.05 },
+ good: { freq: 700, type: 'sine', ms: 220, gain: 0.14, slide: 1.5 },
+ crisis: { freq: 260, type: 'sawtooth', ms: 420, gain: 0.16, slide: 0.6 },
+};
+
+export const createAudio = () => {
+ let ctx = null;
+ let muted = false;
+
+ const ensure = () => {
+ if (ctx) return ctx;
+ const Ctor = window.AudioContext || window.webkitAudioContext;
+ if (!Ctor) return null;
+ try {
+ ctx = new Ctor();
+ } catch {
+ ctx = null;
+ }
+ return ctx;
+ };
+
+ return {
+ play(name) {
+ if (muted) return;
+ const voice = VOICES[name];
+ const audio = ensure();
+ if (!voice || !audio) return;
+ try {
+ if (audio.state === 'suspended') audio.resume();
+ const osc = audio.createOscillator();
+ const gain = audio.createGain();
+ const now = audio.currentTime;
+ const end = now + voice.ms / 1000;
+ osc.type = voice.type;
+ osc.frequency.setValueAtTime(voice.freq, now);
+ if (voice.slide) osc.frequency.exponentialRampToValueAtTime(voice.freq * voice.slide, end);
+ gain.gain.setValueAtTime(voice.gain, now);
+ gain.gain.exponentialRampToValueAtTime(0.0001, end);
+ osc.connect(gain).connect(audio.destination);
+ osc.start(now);
+ osc.stop(end);
+ } catch {
+ // Audio is a nicety; failure is silent by design.
+ }
+ },
+ toggleMute() {
+ muted = !muted;
+ return muted;
+ },
+ isMuted: () => muted,
+ };
+};
diff --git a/games/gridlock/src/io/input.js b/games/gridlock/src/io/input.js
new file mode 100644
index 0000000..e08e119
--- /dev/null
+++ b/games/gridlock/src/io/input.js
@@ -0,0 +1,97 @@
+/**
+ * Pointer and keyboard handling.
+ *
+ * Two gestures matter: drag-to-build (roads are unusable without it) and
+ * zoom-toward-cursor (panning to find the spot you were already pointing at is
+ * the fastest way to make a builder feel cheap).
+ */
+
+import { clampCamera, pickTile } from '../render/iso.js';
+
+export const attachInput = (canvas, cam, view, getState, handlers) => {
+ let painting = false;
+ let panning = false;
+ let lastPan = { x: 0, y: 0 };
+ let lastPainted = -1;
+
+ const localPoint = (event) => {
+ const rect = canvas.getBoundingClientRect();
+ return { px: event.clientX - rect.left, py: event.clientY - rect.top };
+ };
+
+ const tileUnder = (event) => {
+ const { px, py } = localPoint(event);
+ return pickTile(px, py, cam, view, getState().grid);
+ };
+
+ canvas.addEventListener('pointerdown', (event) => {
+ canvas.setPointerCapture(event.pointerId);
+ const tile = tileUnder(event);
+ const wantsPan = event.button === 1 || event.button === 2 || !handlers.hasTool();
+
+ if (wantsPan) {
+ panning = true;
+ lastPan = { x: event.clientX, y: event.clientY };
+ canvas.style.cursor = 'grabbing';
+ return;
+ }
+ painting = true;
+ lastPainted = -1;
+ if (tile) {
+ lastPainted = tile.y * getState().grid.w + tile.x;
+ handlers.onPaint(tile);
+ }
+ });
+
+ canvas.addEventListener('pointermove', (event) => {
+ if (panning) {
+ cam.panX += (event.clientX - lastPan.x) / cam.zoom;
+ cam.panY += (event.clientY - lastPan.y) / cam.zoom;
+ lastPan = { x: event.clientX, y: event.clientY };
+ clampCamera(cam, getState().grid);
+ return;
+ }
+ const tile = tileUnder(event);
+ handlers.onHover(tile);
+ if (!painting || !tile) return;
+ const key = tile.y * getState().grid.w + tile.x;
+ if (key === lastPainted) return; // One action per tile per drag.
+ lastPainted = key;
+ handlers.onPaint(tile);
+ });
+
+ const endGesture = (event) => {
+ if (canvas.hasPointerCapture?.(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
+ painting = false;
+ panning = false;
+ canvas.style.cursor = '';
+ };
+ canvas.addEventListener('pointerup', endGesture);
+ canvas.addEventListener('pointercancel', endGesture);
+ canvas.addEventListener('pointerleave', () => handlers.onHover(null));
+ canvas.addEventListener('contextmenu', (event) => event.preventDefault());
+
+ canvas.addEventListener('wheel', (event) => {
+ event.preventDefault();
+ const { px, py } = localPoint(event);
+ const z0 = cam.zoom;
+ cam.zoom *= event.deltaY < 0 ? 1.12 : 1 / 1.12;
+ clampCamera(cam, getState().grid);
+ // Hold the world point under the cursor still:
+ // world = (p - centre)/zoom - pan ⇒ pan' = pan + (p - centre)(1/z' - 1/z)
+ const invDelta = 1 / cam.zoom - 1 / z0;
+ cam.panX += (px - view.width / 2) * invDelta;
+ cam.panY += (py - view.height / 2) * invDelta;
+ clampCamera(cam, getState().grid);
+ }, { passive: false });
+
+ window.addEventListener('keydown', (event) => {
+ if (event.target instanceof HTMLInputElement) return;
+ handlers.onKey(event);
+ });
+
+ return {
+ isPanning: () => panning,
+ detach: () => canvas.replaceWith(canvas.cloneNode(true)),
+ };
+};
diff --git a/games/gridlock/src/io/persist.js b/games/gridlock/src/io/persist.js
new file mode 100644
index 0000000..b55b5e6
--- /dev/null
+++ b/games/gridlock/src/io/persist.js
@@ -0,0 +1,58 @@
+/**
+ * Save/load. State is plain JSON by construction, so persistence is a
+ * stringify with the derived stats snapshot stripped — derived data is always
+ * recomputed, never trusted from disk.
+ *
+ * Every path is guarded: a browser with storage disabled, a full quota, or a
+ * corrupted payload must degrade to "no save", never to a broken game.
+ */
+
+import { SAVE_KEY, SAVE_VERSION } from '../config/balance.js';
+
+const storage = () => {
+ try {
+ const probe = '__gridlock__';
+ window.localStorage.setItem(probe, '1');
+ window.localStorage.removeItem(probe);
+ return window.localStorage;
+ } catch {
+ return null; // Private mode, blocked cookies, or no storage at all.
+ }
+};
+
+export const saveGame = (state) => {
+ const store = storage();
+ if (!store) return { ok: false, reason: 'Storage unavailable in this browser.' };
+ try {
+ const payload = JSON.stringify({ v: SAVE_VERSION, savedAt: Date.now(), state: { ...state, stats: null } });
+ store.setItem(SAVE_KEY, payload);
+ return { ok: true, bytes: payload.length };
+ } catch (error) {
+ return { ok: false, reason: error?.name === 'QuotaExceededError' ? 'Save file too large.' : 'Save failed.' };
+ }
+};
+
+export const hasSave = () => {
+ const store = storage();
+ return Boolean(store && store.getItem(SAVE_KEY));
+};
+
+export const loadGame = () => {
+ const store = storage();
+ if (!store) return { ok: false, reason: 'Storage unavailable.' };
+ const raw = store.getItem(SAVE_KEY);
+ if (!raw) return { ok: false, reason: 'No saved city.' };
+ try {
+ const parsed = JSON.parse(raw);
+ if (parsed.v !== SAVE_VERSION) return { ok: false, reason: 'Save is from an older build.' };
+ if (!parsed.state?.grid?.tiles?.length) return { ok: false, reason: 'Save file is corrupt.' };
+ return { ok: true, state: parsed.state, savedAt: parsed.savedAt };
+ } catch {
+ return { ok: false, reason: 'Save file is corrupt.' };
+ }
+};
+
+export const clearSave = () => {
+ const store = storage();
+ if (store) store.removeItem(SAVE_KEY);
+};
diff --git a/games/gridlock/src/main.js b/games/gridlock/src/main.js
new file mode 100644
index 0000000..d72b6bc
--- /dev/null
+++ b/games/gridlock/src/main.js
@@ -0,0 +1,307 @@
+/**
+ * Application shell: owns the state reference, the frame loop, and the wiring
+ * between input, simulation, and view. Everything else is pure or presentational.
+ *
+ * State moves in exactly one direction:
+ * input → command/tick (pure, returns new state) → renderer + UI read it
+ */
+
+import { SPEED_MS } from './config/balance.js';
+import { canPlace, demolish, place, setSpeed, setTax } from './core/commands.js';
+import { getCrisis, resolveCrisis } from './core/events.js';
+import { createInitialState } from './core/state.js';
+import { refreshStats, tickMonth } from './core/simulate.js';
+import { createCamera } from './render/iso.js';
+import { createEffects } from './render/effects.js';
+import { createRenderer } from './render/renderer.js';
+import { OVERLAYS, nextOverlay } from './render/overlays.js';
+import { attachInput } from './io/input.js';
+import { createAudio } from './io/audio.js';
+import { hasSave, loadGame, saveGame } from './io/persist.js';
+import { createTelemetry } from './telemetry/telemetry.js';
+import { $, el, percent, setClass, setText } from './ui/dom.js';
+import { BULLDOZE, createBuildBar } from './ui/build.js';
+import { createHud } from './ui/hud.js';
+import { createInspector } from './ui/inspector.js';
+import { createModal } from './ui/modal.js';
+import { createPanels } from './ui/panels.js';
+import { createToaster } from './ui/toast.js';
+import { BANKRUPT, HELP, INTRO, VICTORY } from './ui/copy.js';
+
+const MAX_CATCHUP_TICKS = 4;
+
+const boot = () => {
+ const canvas = $('#stage');
+ const effects = createEffects();
+ const renderer = createRenderer(canvas, effects);
+ const telemetry = createTelemetry();
+ const audio = createAudio();
+ const toaster = createToaster($('#toasts'));
+ const modal = createModal($('#modal'));
+ const hud = createHud(document);
+ const panels = createPanels(document);
+ const inspector = createInspector($('#inspector'));
+
+ let state = refreshStats(createInitialState());
+ // WHY one mutable camera object rather than reassignment: the input handler
+ // captures this reference for the life of the page.
+ const cam = createCamera(state.grid);
+ const resetCamera = (grid) => Object.assign(cam, createCamera(grid));
+ let hover = null;
+ let tool = null;
+ let hoverDef = null;
+ let overlay = 'none';
+ let accumulator = 0;
+ let lastFrame = performance.now();
+ let announcedVictory = false;
+ let announcedBankruptcy = false;
+
+ // ── View sync ──────────────────────────────────────────────────────────
+ const legalHere = () =>
+ !hover || !tool || tool === BULLDOZE || canPlace(state, hover.x, hover.y, tool).ok;
+
+ const refreshInspector = () => inspector.show(state, hover, tool, hoverDef);
+
+ const refreshAll = () => {
+ hud.update(state);
+ panels.update(state);
+ build.update(state);
+ refreshInspector();
+ setText($('#tax-value'), percent(state.taxRate));
+ for (const btn of document.querySelectorAll('#speed-group [data-speed]')) {
+ setClass(btn, 'active', Number(btn.dataset.speed) === state.speed);
+ }
+ };
+
+ const commit = (next) => {
+ state = refreshStats(next);
+ refreshAll();
+ };
+
+ // ── Commands ───────────────────────────────────────────────────────────
+ const paint = (tile) => {
+ if (!tile || modal.isOpen()) return;
+ const result = tool === BULLDOZE
+ ? demolish(state, tile.x, tile.y)
+ : place(state, tile.x, tile.y, tool);
+
+ if (!result.ok) {
+ toaster.push(result.reason ?? 'Cannot build there.', 'bad');
+ audio.play('deny');
+ return;
+ }
+ audio.play(tool === BULLDOZE ? 'bulldoze' : 'place');
+ commit(result.state);
+ };
+
+ const build = createBuildBar($('#build-list'), {
+ onSelect: (id) => {
+ tool = id;
+ canvas.style.cursor = id ? 'crosshair' : 'grab';
+ refreshInspector();
+ },
+ onHoverDef: (def) => {
+ hoverDef = def;
+ refreshInspector();
+ },
+ });
+
+ // ── Simulation ─────────────────────────────────────────────────────────
+ const advance = () => {
+ const started = performance.now();
+ state = tickMonth(state);
+ telemetry.tick(performance.now() - started);
+ effects.emit(state, state.stats);
+ refreshAll();
+ checkInterrupts();
+ };
+
+ const checkInterrupts = () => {
+ if (state.crisis.active && !modal.isOpen()) {
+ const crisis = getCrisis(state.crisis.active);
+ if (crisis) {
+ audio.play('crisis');
+ modal.showCrisis(crisis, state, state.stats, (index) => {
+ const result = resolveCrisis(state, state.stats, index);
+ if (!result.ok) return toaster.push(result.reason ?? 'Cannot do that.', 'bad');
+ modal.hide();
+ audio.play('good');
+ commit(setSpeed(result.state, 1));
+ return undefined;
+ });
+ return;
+ }
+ }
+ if (state.won && !announcedVictory) {
+ announcedVictory = true;
+ audio.play('good');
+ modal.showMessage({ ...VICTORY, actions: [{ label: 'Keep building', primary: true, onClick: modal.hide }] });
+ return;
+ }
+ if (state.bankrupt && !announcedBankruptcy) {
+ announcedBankruptcy = true;
+ modal.showMessage({ ...BANKRUPT, kind: 'crisis', actions: [{ label: 'Understood', primary: true, onClick: modal.hide }] });
+ }
+ if (!state.bankrupt) announcedBankruptcy = false;
+ };
+
+ // ── Frame loop ─────────────────────────────────────────────────────────
+ const frame = (now) => {
+ const dt = Math.min(now - lastFrame, 250); // Tab-switch guard.
+ lastFrame = now;
+ telemetry.frame(dt);
+
+ if (!modal.isOpen() && state.speed > 0) {
+ accumulator += dt;
+ const step = SPEED_MS[state.speed];
+ let ticks = 0;
+ while (accumulator >= step && ticks < MAX_CATCHUP_TICKS) {
+ accumulator -= step;
+ ticks += 1;
+ advance();
+ }
+ if (ticks === MAX_CATCHUP_TICKS) accumulator = 0; // Never spiral.
+ }
+
+ effects.update(dt);
+ const drawMs = renderer.draw(state, {
+ cam, hover, tool, overlay, time: now, canPlaceHere: legalHere(),
+ });
+ telemetry.draw(drawMs);
+ renderTelemetry();
+ requestAnimationFrame(frame);
+ };
+
+ // ── Chrome wiring ──────────────────────────────────────────────────────
+ const overlayGroup = $('#overlay-group');
+ for (const view of OVERLAYS) {
+ const btn = el('button', 'btn tiny', view.label);
+ btn.dataset.overlay = view.id;
+ btn.title = view.hint;
+ btn.addEventListener('click', () => setOverlay(view.id));
+ overlayGroup.appendChild(btn);
+ }
+ const setOverlay = (id) => {
+ overlay = id;
+ for (const btn of overlayGroup.querySelectorAll('[data-overlay]')) {
+ setClass(btn, 'active', btn.dataset.overlay === id);
+ }
+ };
+ setOverlay('none');
+
+ for (const btn of document.querySelectorAll('#speed-group [data-speed]')) {
+ btn.addEventListener('click', () => commit(setSpeed(state, Number(btn.dataset.speed))));
+ }
+ $('#tax-up').addEventListener('click', () => commit(setTax(state, state.taxRate + 0.01)));
+ $('#tax-down').addEventListener('click', () => commit(setTax(state, state.taxRate - 0.01)));
+
+ $('#btn-save').addEventListener('click', () => {
+ const result = saveGame(state);
+ toaster.push(result.ok ? 'City saved to this browser.' : result.reason, result.ok ? 'good' : 'bad');
+ });
+ $('#btn-load').addEventListener('click', () => loadCity());
+ $('#btn-new').addEventListener('click', () => newCity());
+ $('#btn-mute').addEventListener('click', (event) => {
+ const muted = audio.toggleMute();
+ event.currentTarget.textContent = muted ? '♪̸' : '♪';
+ });
+ $('#btn-help').addEventListener('click', () => showHelp());
+
+ const loadCity = () => {
+ const result = loadGame();
+ if (!result.ok) return toaster.push(result.reason, 'bad');
+ resetCamera(result.state.grid);
+ announcedVictory = result.state.won;
+ commit(result.state);
+ modal.hide();
+ toaster.push('City restored.', 'good');
+ return undefined;
+ };
+
+ const newCity = () => {
+ state = refreshStats(createInitialState());
+ resetCamera(state.grid);
+ announcedVictory = false;
+ announcedBankruptcy = false;
+ build.select(null);
+ refreshAll();
+ modal.hide();
+ };
+
+ const showHelp = () => modal.showMessage({
+ ...HELP,
+ actions: [{ label: 'Back to the city', primary: true, onClick: modal.hide }],
+ });
+
+ const showIntro = () => modal.showMessage({
+ ...INTRO,
+ actions: [
+ { label: 'Found the city', primary: true, onClick: modal.hide },
+ ...(hasSave() ? [{ label: 'Load saved city', onClick: loadCity }] : []),
+ { label: 'How to play', onClick: showHelp },
+ ],
+ });
+
+ // ── Keyboard ───────────────────────────────────────────────────────────
+ const onKey = (event) => {
+ const key = event.key.toLowerCase();
+ if (key === 'escape') {
+ if (modal.isOpen() && !state.crisis.active) modal.hide();
+ else build.select(null);
+ return;
+ }
+ if (modal.isOpen()) return;
+ if (key === ' ') {
+ event.preventDefault();
+ commit(setSpeed(state, state.speed === 0 ? 1 : 0));
+ } else if (['1', '2', '3'].includes(key)) commit(setSpeed(state, Number(key)));
+ else if (key === 'x') build.select(BULLDOZE);
+ else if (key === 'o') setOverlay(nextOverlay(overlay));
+ else if (key === 's') $('#btn-save').click();
+ else if (key === 'h') showHelp();
+ else if (key === '`') $('#telemetry').classList.toggle('hidden');
+ };
+
+ const telemetryNode = $('#telemetry');
+ const renderTelemetry = () => {
+ if (telemetryNode.classList.contains('hidden')) return;
+ const snap = telemetry.snapshot();
+ telemetryNode.textContent = [
+ `HEALTH alive=${snap.health.alive} frames=${snap.health.frames}`,
+ `PRESSURE fps=${snap.pressure.fps.toFixed(0)} draw=${snap.pressure.drawMs.toFixed(2)}ms tick=${snap.pressure.tickMs.toFixed(2)}ms`,
+ `EFFICIENCY heap=${snap.efficiency.heapMb ? `${snap.efficiency.heapMb.toFixed(1)}MB` : 'n/a'} drops=${percent(snap.efficiency.dropRate, 1)}`,
+ `WORLD month=${state.month} tiles=${state.grid.tiles.length} fx=${effects.count()}`,
+ ].join('\n');
+ };
+
+ attachInput(canvas, cam, renderer.view, () => state, {
+ hasTool: () => tool !== null,
+ onHover: (tile) => {
+ hover = tile;
+ refreshInspector();
+ },
+ onPaint: paint,
+ onKey,
+ });
+
+ // Opt-in inspection hook for automated smoke tests and balance tuning.
+ // Read-only by contract: it exposes state, it does not accept commands.
+ if (new URLSearchParams(window.location.search).has('debug')) {
+ window.__gridlock = {
+ state: () => state,
+ camera: () => cam,
+ telemetry: () => telemetry.snapshot(),
+ modalOpen: () => modal.isOpen(),
+ };
+ $('#telemetry').classList.remove('hidden');
+ }
+
+ window.addEventListener('resize', () => renderer.resize());
+ renderer.resize();
+ refreshAll();
+ showIntro();
+ requestAnimationFrame(frame);
+};
+
+if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
+else boot();
diff --git a/games/gridlock/src/render/effects.js b/games/gridlock/src/render/effects.js
new file mode 100644
index 0000000..0fb3953
--- /dev/null
+++ b/games/gridlock/src/render/effects.js
@@ -0,0 +1,85 @@
+/**
+ * Particles and traffic. Pure eye-candy, hard-capped so it can never become a
+ * performance problem: the pool is bounded and old particles are recycled.
+ */
+
+import { getBuilding } from '../config/buildings.js';
+import { LEVEL_H } from './iso.js';
+import { alpha } from './theme.js';
+
+const MAX_PARTICLES = 180;
+
+export const createEffects = () => {
+ const pool = [];
+
+ const spawn = (tile, def) => {
+ if (pool.length >= MAX_PARTICLES) return;
+ const smoke = def.pollution > 2 || def.heat > 1.5;
+ pool.push({
+ x: tile.x, y: tile.y,
+ life: 0,
+ ttl: smoke ? 2600 : 1600,
+ rise: def.h * LEVEL_H + 6,
+ drift: (Math.random() - 0.5) * 10,
+ color: smoke ? '#8a7f9c' : def.accent,
+ size: smoke ? 4.5 : 2,
+ });
+ };
+
+ return {
+ /** Emit from live emitters once per simulated month. */
+ emit(state, stats) {
+ if (!stats) return;
+ for (const tile of state.grid.tiles) {
+ if (tile.b === null || !stats.live.has(tile.i)) continue;
+ const def = getBuilding(tile.b);
+ if (!def) continue;
+ if (def.pollution > 2 || def.heat > 1.5 || def.cat === 'special') spawn(tile, def);
+ }
+ },
+
+ update(dtMs) {
+ for (let i = pool.length - 1; i >= 0; i--) {
+ pool[i].life += dtMs;
+ if (pool[i].life >= pool[i].ttl) pool.splice(i, 1);
+ }
+ },
+
+ /** @param {(x:number,y:number)=>{sx:number,sy:number}} project */
+ draw(ctx, project, zoom) {
+ for (const p of pool) {
+ const t = p.life / p.ttl;
+ const { sx, sy } = project(p.x, p.y);
+ ctx.fillStyle = alpha(p.color, (1 - t) * 0.5);
+ ctx.beginPath();
+ ctx.arc(
+ sx + p.drift * t * zoom,
+ sy - (p.rise + t * 34) * zoom,
+ (p.size + t * 3) * zoom,
+ 0, Math.PI * 2,
+ );
+ ctx.fill();
+ }
+ },
+
+ count: () => pool.length,
+ };
+};
+
+/**
+ * Commuter lights running along the road graph. Density and colour follow
+ * congestion, so a jammed city visibly clots.
+ */
+export const drawTraffic = (ctx, tile, sx, sy, zoom, time, congestion) => {
+ if (zoom < 0.55) return;
+ const density = Math.min(2, 1 + Math.floor(congestion * 2));
+ const jam = Math.min(1, congestion);
+ const color = jam > 0.75 ? '#ff5470' : jam > 0.45 ? '#ffb347' : '#7ef7e2';
+ const speed = 1 - jam * 0.7;
+ for (let i = 0; i < density; i++) {
+ const phase = ((time * 0.00035 * speed) + (tile.i * 0.37) + i * 0.5) % 1;
+ const ox = (phase - 0.5) * 30 * zoom;
+ ctx.fillStyle = alpha(color, 0.75);
+ ctx.fillRect(sx + ox, sy - 1.5 * zoom, 2.4 * zoom, 2 * zoom);
+ }
+};
diff --git a/games/gridlock/src/render/iso.js b/games/gridlock/src/render/iso.js
new file mode 100644
index 0000000..6dfac29
--- /dev/null
+++ b/games/gridlock/src/render/iso.js
@@ -0,0 +1,66 @@
+/**
+ * Isometric projection. Pure maths, no canvas — so the inverse mapping used by
+ * the mouse can be unit-tested without a browser.
+ */
+
+export const TILE_W = 64;
+export const TILE_H = 32;
+/** Vertical pixels per unit of building height at zoom 1. */
+export const LEVEL_H = 30;
+
+export const createCamera = (grid) => ({
+ // Centre the map under the viewport: tile (w/2, h/2) sits at screen centre.
+ panX: 0,
+ panY: -((grid.w + grid.h) * TILE_H) / 4,
+ zoom: 1,
+ minZoom: 0.45,
+ maxZoom: 2.4,
+});
+
+/** Tile coordinates → unscaled world pixels (origin at tile 0,0 top corner). */
+export const tileToWorld = (x, y) => ({
+ wx: (x - y) * (TILE_W / 2),
+ wy: (x + y) * (TILE_H / 2),
+});
+
+/** Tile coordinates → canvas pixels. */
+export const tileToScreen = (x, y, cam, view) => {
+ const { wx, wy } = tileToWorld(x, y);
+ return {
+ sx: view.width / 2 + (wx + cam.panX) * cam.zoom,
+ sy: view.height / 2 + (wy + cam.panY) * cam.zoom,
+ };
+};
+
+/** Canvas pixels → fractional tile coordinates. Inverse of tileToScreen. */
+export const screenToTile = (sx, sy, cam, view) => {
+ const wx = (sx - view.width / 2) / cam.zoom - cam.panX;
+ const wy = (sy - view.height / 2) / cam.zoom - cam.panY;
+ const hx = wx / (TILE_W / 2);
+ const hy = wy / (TILE_H / 2);
+ return { x: (hx + hy) / 2, y: (hy - hx) / 2 };
+};
+
+/**
+ * Nearest tile under a screen point, or null when off-grid.
+ * WHY round, not floor: tileToScreen puts tile centres on integer coordinates,
+ * so the unit square around an integer maps exactly onto that tile's diamond.
+ */
+export const pickTile = (sx, sy, cam, view, grid) => {
+ const { x, y } = screenToTile(sx, sy, cam, view);
+ const tx = Math.round(x);
+ const ty = Math.round(y);
+ if (tx < 0 || ty < 0 || tx >= grid.w || ty >= grid.h) return null;
+ return { x: tx, y: ty };
+};
+
+/** Painter's-algorithm order: back to front, so near tiles overdraw far ones. */
+export const depthOf = (x, y) => x + y;
+
+export const clampCamera = (cam, grid) => {
+ const span = (grid.w + grid.h) * (TILE_H / 2) + 400;
+ cam.panX = Math.max(-span, Math.min(span, cam.panX));
+ cam.panY = Math.max(-span, Math.min(span, cam.panY));
+ cam.zoom = Math.max(cam.minZoom, Math.min(cam.maxZoom, cam.zoom));
+ return cam;
+};
diff --git a/games/gridlock/src/render/overlays.js b/games/gridlock/src/render/overlays.js
new file mode 100644
index 0000000..975324e
--- /dev/null
+++ b/games/gridlock/src/render/overlays.js
@@ -0,0 +1,72 @@
+/**
+ * Data views. The map is the dashboard: each overlay washes the ground with a
+ * single legible signal instead of hiding it in a side panel.
+ */
+
+import { SERVICE_KINDS } from '../config/balance.js';
+import { getBuilding } from '../config/buildings.js';
+
+export const OVERLAYS = Object.freeze([
+ { id: 'none', label: 'City', hint: 'The skyline as it stands.' },
+ { id: 'appeal', label: 'Desirability', hint: 'Where citizens want to live.' },
+ { id: 'utilities', label: 'Utilities', hint: 'What is powered, watered, and wired.' },
+ { id: 'services', label: 'Services', hint: 'Clinic, academy, and watch coverage.' },
+ { id: 'emissions', label: 'Emissions', hint: 'Who is heating the planet.' },
+]);
+
+const ramp = (t, negative, positive) => {
+ const clamped = Math.max(-1, Math.min(1, t));
+ return clamped >= 0
+ ? `rgba(${positive},${0.08 + clamped * 0.42})`
+ : `rgba(${negative},${0.08 + -clamped * 0.42})`;
+};
+
+const GOOD = '94,224,138';
+const BAD = '255,84,112';
+const COOL = '94,169,255';
+const WARM = '255,140,60';
+
+/**
+ * @returns {string|null} an rgba wash for this tile, or null for no tint
+ */
+export const overlayWash = (mode, stats, tile) => {
+ if (!stats || mode === 'none') return null;
+
+ switch (mode) {
+ case 'appeal':
+ return ramp(stats.fields.appeal[tile.i] / 12, BAD, GOOD);
+
+ case 'utilities': {
+ if (tile.b === null) return null;
+ const def = getBuilding(tile.b);
+ if (!def) return null;
+ if (stats.live.has(tile.i)) return `rgba(${GOOD},0.3)`;
+ if (stats.network.connected.has(tile.i)) return `rgba(${WARM},0.36)`;
+ return `rgba(${BAD},0.4)`;
+ }
+
+ case 'services': {
+ let sum = 0;
+ for (const kind of SERVICE_KINDS) sum += stats.fields.services[kind][tile.i];
+ const coverage = sum / SERVICE_KINDS.length;
+ return coverage <= 0.02 ? `rgba(${BAD},0.2)` : `rgba(${COOL},${0.1 + coverage * 0.4})`;
+ }
+
+ case 'emissions': {
+ if (tile.b === null) return null;
+ const def = getBuilding(tile.b);
+ if (!def) return null;
+ const load = def.heat + def.pollution * 0.3;
+ if (Math.abs(load) < 0.05) return null;
+ return ramp(-load / 8, WARM, GOOD);
+ }
+
+ default:
+ return null;
+ }
+};
+
+export const nextOverlay = (current) => {
+ const i = OVERLAYS.findIndex((o) => o.id === current);
+ return OVERLAYS[(i + 1) % OVERLAYS.length].id;
+};
diff --git a/games/gridlock/src/render/renderer.js b/games/gridlock/src/render/renderer.js
new file mode 100644
index 0000000..486ab6e
--- /dev/null
+++ b/games/gridlock/src/render/renderer.js
@@ -0,0 +1,145 @@
+/**
+ * Canvas draw loop. Painter's algorithm over the tile diagonal, with viewport
+ * culling so map size costs nothing when zoomed in.
+ *
+ * The renderer reads state and never writes it.
+ */
+
+import { getBuilding } from '../config/buildings.js';
+import { LEVEL_H, TILE_H, tileToScreen } from './iso.js';
+import { PALETTE, ambient, alpha, dayPhase, heatCast, nightFactor } from './theme.js';
+import { drawCursor, drawGround, drawHalo } from './terrain.js';
+import { drawOutage, drawStructure } from './structures.js';
+import { drawTraffic } from './effects.js';
+import { overlayWash } from './overlays.js';
+
+const CULL_MARGIN = 160;
+
+/** Bitmask of orthogonal neighbours a road should visually connect to. */
+const LINKS = ['road', 'nexus', 'transit', 'maglev'];
+const connectionMask = (grid, x, y) => {
+ const steps = [[1, 0], [-1, 0], [0, 1], [0, -1]];
+ let mask = 0;
+ steps.forEach(([dx, dy], bit) => {
+ const nx = x + dx;
+ const ny = y + dy;
+ if (nx < 0 || ny < 0 || nx >= grid.w || ny >= grid.h) return;
+ if (LINKS.includes(grid.tiles[ny * grid.w + nx].b)) mask |= 1 << bit;
+ });
+ return mask;
+};
+
+export const createRenderer = (canvas, effects) => {
+ const ctx = canvas.getContext('2d');
+ const view = { width: 0, height: 0, dpr: 1 };
+
+ const resize = () => {
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
+ const rect = canvas.getBoundingClientRect();
+ view.width = rect.width;
+ view.height = rect.height;
+ view.dpr = dpr;
+ canvas.width = Math.round(rect.width * dpr);
+ canvas.height = Math.round(rect.height * dpr);
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ };
+
+ const drawBackdrop = (night) => {
+ const sky = ctx.createLinearGradient(0, 0, 0, view.height);
+ sky.addColorStop(0, night > 0.5 ? '#0a0716' : '#161334');
+ sky.addColorStop(1, PALETTE.void);
+ ctx.fillStyle = sky;
+ ctx.fillRect(0, 0, view.width, view.height);
+ };
+
+ const draw = (state, opts) => {
+ const started = performance.now();
+ const { cam, hover, tool, overlay, time } = opts;
+ const { grid, stats } = state;
+ const night = nightFactor(dayPhase(time));
+ const air = ambient(night);
+ const glow = air.windowGlow;
+
+ resizeIfNeeded();
+ drawBackdrop(night);
+
+ const project = (x, y) => tileToScreen(x, y, cam, view);
+ const congestion = stats ? stats.congestion : 0;
+
+ for (let d = 0; d <= grid.w + grid.h - 2; d++) {
+ const xStart = Math.max(0, d - grid.h + 1);
+ const xEnd = Math.min(grid.w - 1, d);
+ for (let x = xStart; x <= xEnd; x++) {
+ const y = d - x;
+ const tile = grid.tiles[y * grid.w + x];
+ const { sx, sy } = project(x, y);
+ if (sx < -CULL_MARGIN || sx > view.width + CULL_MARGIN) continue;
+ if (sy < -CULL_MARGIN * 3 || sy > view.height + CULL_MARGIN) continue;
+
+ drawGround(ctx, tile, { sx, sy, zoom: cam.zoom, night, time }, overlayWash(overlay, stats, tile));
+
+ if (tile.b === null) continue;
+ const def = getBuilding(tile.b);
+ if (!def) continue;
+ const live = tile.on || stats === null;
+
+ const roadMask = def.id === 'road' ? connectionMask(grid, x, y) : 0;
+ drawStructure(ctx, def, { sx, sy, zoom: cam.zoom, glow, time, seed: tile.i, live, roadMask });
+ if (def.id === 'road') {
+ drawTraffic(ctx, tile, sx, sy, cam.zoom, time, congestion);
+ continue;
+ }
+ if (!live) drawOutage(ctx, sx, sy, cam.zoom, def.h * LEVEL_H * cam.zoom);
+ }
+ }
+
+ effects.draw(ctx, project, cam.zoom);
+ drawPointer(state, opts, project);
+
+ // Time-of-day wash and, above the heat threshold, a red planetary cast.
+ ctx.fillStyle = alpha(air.tint, air.strength * 0.55);
+ ctx.fillRect(0, 0, view.width, view.height);
+ const cast = heatCast(state.heat);
+ if (cast > 0) {
+ ctx.fillStyle = `rgba(255,90,40,${cast})`;
+ ctx.fillRect(0, 0, view.width, view.height);
+ }
+
+ return performance.now() - started;
+ };
+
+ /** Cursor diamond plus dashed halos on the neighbours a placement would use. */
+ const drawPointer = (state, { hover, tool, canPlaceHere, cam }, project) => {
+ if (!hover) return;
+ const { sx, sy } = project(hover.x, hover.y);
+ drawCursor(ctx, sx, sy, cam.zoom, canPlaceHere);
+ if (!tool || tool === 'bulldoze') return;
+ for (let dy = -1; dy <= 1; dy++) {
+ for (let dx = -1; dx <= 1; dx++) {
+ if (dx === 0 && dy === 0) continue;
+ const nx = hover.x + dx;
+ const ny = hover.y + dy;
+ if (nx < 0 || ny < 0 || nx >= state.grid.w || ny >= state.grid.h) continue;
+ if (state.grid.tiles[ny * state.grid.w + nx].b === null) continue;
+ const p = project(nx, ny);
+ drawHalo(ctx, p.sx, p.sy, cam.zoom, 'rgba(201,168,76,0.35)');
+ }
+ }
+ };
+
+ let lastW = 0;
+ let lastH = 0;
+ const resizeIfNeeded = () => {
+ const rect = canvas.getBoundingClientRect();
+ if (rect.width !== lastW || rect.height !== lastH) {
+ lastW = rect.width;
+ lastH = rect.height;
+ resize();
+ }
+ };
+
+ return { draw, resize, view, ctx };
+};
+
+/** Screen height in pixels of a structure, used for hit-testing tooltips. */
+export const structureHeight = (def, zoom) => (def?.h ?? 0) * LEVEL_H * zoom + TILE_H * zoom;
diff --git a/games/gridlock/src/render/structures.js b/games/gridlock/src/render/structures.js
new file mode 100644
index 0000000..61bd18f
--- /dev/null
+++ b/games/gridlock/src/render/structures.js
@@ -0,0 +1,207 @@
+/**
+ * Structure rendering. Every building is drawn procedurally from its catalog
+ * entry — no sprite sheet, no asset pipeline, and adding a building to the
+ * catalog makes it appear on the map with no art task attached.
+ */
+
+import { TILE_W, TILE_H, LEVEL_H } from './iso.js';
+import { alpha, mix, shade } from './theme.js';
+import { diamondPath } from './terrain.js';
+
+const facePaths = (ctx, sx, sy, halfW, halfH, hPx) => {
+ ctx.beginPath(); // left
+ ctx.moveTo(sx - halfW, sy - hPx);
+ ctx.lineTo(sx, sy + halfH - hPx);
+ ctx.lineTo(sx, sy + halfH);
+ ctx.lineTo(sx - halfW, sy);
+ ctx.closePath();
+};
+
+const rightPath = (ctx, sx, sy, halfW, halfH, hPx) => {
+ ctx.beginPath();
+ ctx.moveTo(sx + halfW, sy - hPx);
+ ctx.lineTo(sx, sy + halfH - hPx);
+ ctx.lineTo(sx, sy + halfH);
+ ctx.lineTo(sx + halfW, sy);
+ ctx.closePath();
+};
+
+/** Lit windows climbing both visible faces. The city's night-time signature. */
+const drawWindows = (ctx, sx, sy, halfW, halfH, hPx, accent, glow, seed) => {
+ if (glow <= 0.02 || hPx < 16) return;
+ const rows = Math.max(1, Math.floor(hPx / 12));
+ const cols = 3;
+ ctx.save();
+ for (let r = 0; r < rows; r++) {
+ for (let c = 0; c < cols; c++) {
+ // Deterministic per-window flicker keyed off the tile seed.
+ const on = ((seed * 977 + r * 31 + c * 7) % 10) > 3;
+ if (!on) continue;
+ const t = (c + 0.5) / cols;
+ const yOff = hPx - (r + 0.7) * 12;
+ ctx.fillStyle = alpha(accent, 0.15 + glow * 0.75);
+ ctx.fillRect(sx - halfW * (1 - t) - 2, sy - yOff + halfH * (1 - t) * 0.5 - 2, 3, 4);
+ ctx.fillRect(sx + halfW * (1 - t) - 1, sy - yOff + halfH * (1 - t) * 0.5 - 2, 3, 4);
+ }
+ }
+ ctx.restore();
+};
+
+const drawBox = (ctx, sx, sy, zoom, hPx, base, accent, glow, seed) => {
+ const halfW = (TILE_W / 2) * zoom * 0.82;
+ const halfH = (TILE_H / 2) * zoom * 0.82;
+
+ ctx.fillStyle = shade(base, -0.32);
+ facePaths(ctx, sx, sy, halfW, halfH, hPx);
+ ctx.fill();
+
+ ctx.fillStyle = shade(base, -0.14);
+ rightPath(ctx, sx, sy, halfW, halfH, hPx);
+ ctx.fill();
+
+ ctx.fillStyle = shade(base, 0.14);
+ diamondPath(ctx, sx, sy - hPx, zoom * 0.82);
+ ctx.fill();
+
+ drawWindows(ctx, sx, sy, halfW, halfH, hPx, accent, glow, seed);
+
+ // Roof line picks up the accent so silhouettes stay readable at low zoom.
+ ctx.strokeStyle = alpha(accent, 0.25 + glow * 0.5);
+ ctx.lineWidth = 1;
+ diamondPath(ctx, sx, sy - hPx, zoom * 0.82);
+ ctx.stroke();
+};
+
+/** Edge-midpoint offsets keyed by the road connection bitmask. */
+const ROAD_DIRS = [
+ [0.5, 0.5], // +x
+ [-0.5, -0.5], // -x
+ [-0.5, 0.5], // +y
+ [0.5, -0.5], // -y
+];
+
+/**
+ * Roads draw as asphalt with a neon spine running to each connected
+ * neighbour, so junctions and dead ends are visible at a glance.
+ * @param {number} mask bit i set when neighbour i (see ROAD_DIRS) is road
+ */
+const drawRoad = (ctx, sx, sy, zoom, base, accent, glow, mask) => {
+ const halfW = (TILE_W / 2) * zoom;
+ const halfH = (TILE_H / 2) * zoom;
+
+ ctx.fillStyle = shade(base, 0.16);
+ diamondPath(ctx, sx, sy, zoom, 0.5);
+ ctx.fill();
+
+ ctx.strokeStyle = alpha(accent, 0.35 + glow * 0.5);
+ ctx.lineWidth = Math.max(1, 2 * zoom);
+ ctx.lineCap = 'round';
+ let drawn = 0;
+ for (let dir = 0; dir < 4; dir++) {
+ if ((mask & (1 << dir)) === 0) continue;
+ drawn += 1;
+ const [fx, fy] = ROAD_DIRS[dir];
+ ctx.beginPath();
+ ctx.moveTo(sx, sy);
+ ctx.lineTo(sx + fx * halfW * 2, sy + fy * halfH * 2);
+ ctx.stroke();
+ }
+ if (drawn === 0) {
+ ctx.beginPath();
+ ctx.arc(sx, sy, 2.4 * zoom, 0, Math.PI * 2);
+ ctx.fillStyle = alpha(accent, 0.5);
+ ctx.fill();
+ }
+};
+
+/** Solar reads as a tilted panel array rather than an empty outline. */
+const drawPanels = (ctx, sx, sy, zoom, base, accent, glow) => {
+ ctx.fillStyle = shade(base, 0.1);
+ diamondPath(ctx, sx, sy, zoom * 0.92);
+ ctx.fill();
+ const halfW = (TILE_W / 2) * zoom * 0.92;
+ const halfH = (TILE_H / 2) * zoom * 0.92;
+ ctx.strokeStyle = alpha(accent, 0.5 + glow * 0.4);
+ ctx.lineWidth = Math.max(1, 1.4 * zoom);
+ // Rows run along the +x axis, offset along the +y axis so they lie flat.
+ for (const t of [-0.42, 0, 0.42]) {
+ ctx.beginPath();
+ ctx.moveTo(sx - halfW * 0.5 - t * halfW, sy - halfH * 0.5 + t * halfH);
+ ctx.lineTo(sx + halfW * 0.5 - t * halfW, sy + halfH * 0.5 + t * halfH);
+ ctx.stroke();
+ }
+};
+
+const drawWind = (ctx, sx, sy, zoom, hPx, base, accent, time) => {
+ ctx.strokeStyle = shade(base, 0.25);
+ ctx.lineWidth = 2.5 * zoom;
+ ctx.beginPath();
+ ctx.moveTo(sx, sy);
+ ctx.lineTo(sx, sy - hPx);
+ ctx.stroke();
+ const spin = time / 260;
+ ctx.strokeStyle = accent;
+ ctx.lineWidth = 1.6 * zoom;
+ for (let i = 0; i < 3; i++) {
+ const a = spin + (i * Math.PI * 2) / 3;
+ ctx.beginPath();
+ ctx.moveTo(sx, sy - hPx);
+ ctx.lineTo(sx + Math.cos(a) * 12 * zoom, sy - hPx + Math.sin(a) * 8 * zoom);
+ ctx.stroke();
+ }
+};
+
+const drawGreen = (ctx, sx, sy, zoom, base, accent, seed) => {
+ ctx.fillStyle = shade(base, -0.05);
+ diamondPath(ctx, sx, sy, zoom * 0.92);
+ ctx.fill();
+ for (let i = 0; i < 3; i++) {
+ const ox = ((seed * (i + 3) * 37) % 20) - 10;
+ const oy = ((seed * (i + 5) * 17) % 10) - 5;
+ ctx.fillStyle = alpha(accent, 0.55);
+ ctx.beginPath();
+ ctx.ellipse(sx + ox * zoom * 0.5, sy + oy * zoom * 0.4, 5 * zoom, 3.4 * zoom, 0, 0, Math.PI * 2);
+ ctx.fill();
+ }
+};
+
+/**
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {object} def catalog entry
+ * @param {{sx, sy, zoom, glow, time, seed, live}} view
+ */
+export const drawStructure = (ctx, def, view) => {
+ const { sx, sy, zoom, time, seed, live } = view;
+ const glow = live ? view.glow : 0;
+ const base = live ? def.color : mix(def.color, '#15121f', 0.55);
+ const accent = live ? def.accent : '#6b6480';
+ const hPx = def.h * LEVEL_H * zoom;
+
+ if (def.id === 'road') return drawRoad(ctx, sx, sy, zoom, base, accent, glow, view.roadMask ?? 0);
+ if (def.id === 'solar') return drawPanels(ctx, sx, sy, zoom, base, accent, glow);
+ if (def.cat === 'green' && def.h < 0.5) return drawGreen(ctx, sx, sy, zoom, base, accent, seed);
+ if (def.id === 'wind') return drawWind(ctx, sx, sy, zoom, hPx, base, accent, time);
+
+ drawBox(ctx, sx, sy, zoom, hPx, base, accent, glow, seed);
+
+ // Landmarks get a beacon so they read from across the map.
+ if (def.cat === 'special' && live) {
+ const pulse = 0.5 + Math.sin(time / 420) * 0.5;
+ ctx.fillStyle = alpha(def.accent, 0.35 + pulse * 0.5);
+ ctx.beginPath();
+ ctx.arc(sx, sy - hPx - 6 * zoom, (3 + pulse * 2) * zoom, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ return undefined;
+};
+
+/** Small red bolt over structures that are cut off from power or water. */
+export const drawOutage = (ctx, sx, sy, zoom, hPx) => {
+ if (zoom < 0.6) return;
+ ctx.save();
+ ctx.fillStyle = '#ff5470';
+ ctx.font = `${Math.round(13 * zoom)}px "DM Mono", monospace`;
+ ctx.textAlign = 'center';
+ ctx.fillText('!', sx, sy - hPx - 6 * zoom);
+ ctx.restore();
+};
diff --git a/games/gridlock/src/render/terrain.js b/games/gridlock/src/render/terrain.js
new file mode 100644
index 0000000..502438f
--- /dev/null
+++ b/games/gridlock/src/render/terrain.js
@@ -0,0 +1,76 @@
+/**
+ * Ground rendering: one diamond per tile, plus the overlay wash used by the
+ * data views (appeal, power, heat).
+ */
+
+import { TILE_W, TILE_H } from './iso.js';
+import { PALETTE, mix, shade } from './theme.js';
+import { TERRAIN } from '../core/grid.js';
+
+/** Trace the tile diamond centred on (sx, sy). */
+export const diamondPath = (ctx, sx, sy, zoom, inset = 0) => {
+ const halfW = (TILE_W / 2) * zoom - inset;
+ const halfH = (TILE_H / 2) * zoom - inset * 0.5;
+ ctx.beginPath();
+ ctx.moveTo(sx, sy - halfH);
+ ctx.lineTo(sx + halfW, sy);
+ ctx.lineTo(sx, sy + halfH);
+ ctx.lineTo(sx - halfW, sy);
+ ctx.closePath();
+};
+
+const groundColor = (tile, night, time) => {
+ if (tile.t === TERRAIN.WATER) {
+ const wave = Math.sin(time / 900 + tile.x * 0.7 + tile.y * 0.5) * 0.5 + 0.5;
+ return mix(PALETTE.water, PALETTE.waterLit, 0.25 + wave * 0.35 * (1 - night * 0.5));
+ }
+ if (tile.t === TERRAIN.HILL) return shade(PALETTE.hill, -0.05 + tile.variant * 0.12);
+ return mix(PALETTE.ground, PALETTE.groundAlt, tile.variant);
+};
+
+/**
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {object} tile
+ * @param {{sx:number, sy:number, zoom:number, night:number, time:number}} ctxInfo
+ * @param {string|null} overlay rgba wash from an active data view
+ */
+export const drawGround = (ctx, tile, { sx, sy, zoom, night, time }, overlay) => {
+ ctx.fillStyle = shade(groundColor(tile, night, time), -night * 0.22);
+ diamondPath(ctx, sx, sy, zoom);
+ ctx.fill();
+
+ if (overlay) {
+ ctx.fillStyle = overlay;
+ ctx.fill();
+ }
+
+ if (zoom > 0.7) {
+ ctx.strokeStyle = PALETTE.grid;
+ ctx.lineWidth = 1;
+ ctx.stroke();
+ }
+};
+
+/** Cursor ring under the pointer — green when legal, red when not. */
+export const drawCursor = (ctx, sx, sy, zoom, ok) => {
+ const color = ok ? '#5ee08a' : '#ff5470';
+ ctx.save();
+ ctx.lineWidth = 2;
+ ctx.strokeStyle = color;
+ ctx.fillStyle = `${color}22`;
+ diamondPath(ctx, sx, sy, zoom, 1);
+ ctx.fill();
+ ctx.stroke();
+ ctx.restore();
+};
+
+/** Highlight ring used to show synergy neighbours during placement. */
+export const drawHalo = (ctx, sx, sy, zoom, color) => {
+ ctx.save();
+ ctx.lineWidth = 1.5;
+ ctx.strokeStyle = color;
+ ctx.setLineDash([4, 4]);
+ diamondPath(ctx, sx, sy, zoom, 3);
+ ctx.stroke();
+ ctx.restore();
+};
diff --git a/games/gridlock/src/render/theme.js b/games/gridlock/src/render/theme.js
new file mode 100644
index 0000000..fb9f2cc
--- /dev/null
+++ b/games/gridlock/src/render/theme.js
@@ -0,0 +1,64 @@
+/**
+ * Palette and lighting. Every colour the renderer uses resolves here so the
+ * whole city can be re-lit — day, night, or overlay mode — from one place.
+ */
+
+export const PALETTE = Object.freeze({
+ void: '#08060f',
+ ground: '#1b1830',
+ groundAlt: '#221d3a',
+ hill: '#2d2743',
+ water: '#0e2038',
+ waterLit: '#1b3f63',
+ grid: 'rgba(139,92,246,0.16)',
+ gold: '#c9a84c',
+ violet: '#8b5cf6',
+ mint: '#38e1c8',
+ ember: '#ff7a3d',
+ rose: '#ff5ec4',
+ ink: '#f2ecff',
+});
+
+const hex = (h) => {
+ const v = parseInt(h.slice(1), 16);
+ return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
+};
+
+const toHex = (r, g, b) =>
+ `#${[r, g, b].map((c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, '0')).join('')}`;
+
+/** Linear blend between two hex colours. */
+export const mix = (a, b, t) => {
+ const [ar, ag, ab] = hex(a);
+ const [br, bg, bb] = hex(b);
+ return toHex(ar + (br - ar) * t, ag + (bg - ag) * t, ab + (bb - ab) * t);
+};
+
+/** Positive amount lightens, negative darkens. */
+export const shade = (color, amount) =>
+ amount >= 0 ? mix(color, '#ffffff', amount) : mix(color, '#000000', -amount);
+
+export const alpha = (color, a) => {
+ const [r, g, b] = hex(color);
+ return `rgba(${r},${g},${b},${a})`;
+};
+
+/** Real-time day cycle, 96 seconds per full day. 0 = midnight, 0.5 = noon. */
+export const DAY_MS = 96000;
+export const dayPhase = (nowMs) => (nowMs % DAY_MS) / DAY_MS;
+
+/** 0 in full daylight, 1 in deep night. Smooth, so dusk reads as dusk. */
+export const nightFactor = (phase) => {
+ const daylight = Math.sin(phase * Math.PI * 2 - Math.PI / 2) * 0.5 + 0.5;
+ return 1 - Math.max(0, Math.min(1, (daylight - 0.15) / 0.6));
+};
+
+/** Ambient wash applied over the whole map for the current time of day. */
+export const ambient = (night) => ({
+ tint: mix('#3d4f8a', '#0a0718', night),
+ strength: 0.16 + night * 0.34,
+ windowGlow: Math.max(0, night - 0.15),
+});
+
+/** Heat-stressed cities take on a visible red cast. */
+export const heatCast = (heat) => Math.max(0, Math.min(0.4, (heat - 45) / 140));
diff --git a/games/gridlock/src/telemetry/telemetry.js b/games/gridlock/src/telemetry/telemetry.js
new file mode 100644
index 0000000..f2acd4c
--- /dev/null
+++ b/games/gridlock/src/telemetry/telemetry.js
@@ -0,0 +1,54 @@
+/**
+ * Runtime self-report. Per the engine contract every non-trivial module must
+ * answer three questions at runtime: am I alive, how hard am I working, and
+ * what am I leaking.
+ *
+ * Zero cost when nothing reads it — the probes are two counters and an EMA.
+ */
+
+const EMA = (prev, next, alpha = 0.12) => (prev === 0 ? next : prev * (1 - alpha) + next * alpha);
+
+export const createTelemetry = () => {
+ const t = {
+ frames: 0, ticks: 0, drops: 0,
+ frameMs: 0, drawMs: 0, tickMs: 0,
+ lastFrameAt: 0, startedAt: Date.now(),
+ };
+
+ return {
+ /** Called once per animation frame with the frame delta. */
+ frame(deltaMs) {
+ t.frames += 1;
+ t.frameMs = EMA(t.frameMs, deltaMs);
+ if (deltaMs > 50) t.drops += 1; // Below ~20fps counts as a dropped frame.
+ t.lastFrameAt = Date.now();
+ },
+ draw(ms) { t.drawMs = EMA(t.drawMs, ms); },
+ tick(ms) { t.ticks += 1; t.tickMs = EMA(t.tickMs, ms); },
+
+ /** HEALTH — is the loop alive and recent? */
+ health() {
+ const silentMs = Date.now() - t.lastFrameAt;
+ return { alive: silentMs < 2000, silentMs, upMs: Date.now() - t.startedAt, frames: t.frames };
+ },
+ /** PRESSURE — how hard is it working? */
+ pressure() {
+ return {
+ fps: t.frameMs > 0 ? 1000 / t.frameMs : 0,
+ drawMs: t.drawMs,
+ tickMs: t.tickMs,
+ ticks: t.ticks,
+ };
+ },
+ /** EFFICIENCY — what is it leaking? */
+ efficiency() {
+ const heap = typeof performance !== 'undefined' && performance.memory
+ ? performance.memory.usedJSHeapSize / 1048576
+ : null;
+ return { heapMb: heap, dropRate: t.frames ? t.drops / t.frames : 0 };
+ },
+ snapshot() {
+ return { health: this.health(), pressure: this.pressure(), efficiency: this.efficiency() };
+ },
+ };
+};
diff --git a/games/gridlock/src/ui/build.js b/games/gridlock/src/ui/build.js
new file mode 100644
index 0000000..a8c1546
--- /dev/null
+++ b/games/gridlock/src/ui/build.js
@@ -0,0 +1,87 @@
+/**
+ * Build palette. Renders the full catalog once, then only toggles state —
+ * locked, unaffordable, selected — so switching tools never rebuilds the DOM.
+ */
+
+import { CATALOG, CATEGORY_LABEL } from '../config/buildings.js';
+import { clear, credits, el, setClass } from './dom.js';
+
+export const BULLDOZE = 'bulldoze';
+
+const statLine = (def) => {
+ const parts = [];
+ if (def.residents) parts.push(`${def.residents} res`);
+ if (def.jobs) parts.push(`${def.jobs} jobs`);
+ if (def.revenue) parts.push(`₡${def.revenue}/mo`);
+ if (def.power > 0) parts.push(`+${def.power}MW`);
+ if (def.power < 0) parts.push(`${def.power}MW`);
+ if (def.water > 0) parts.push(`+${def.water}L`);
+ if (def.water < 0) parts.push(`${def.water}L`);
+ if (def.tech) parts.push(`${def.tech} res/mo`);
+ return parts.join(' · ');
+};
+
+export const createBuildBar = (root, { onSelect, onHoverDef }) => {
+ const buttons = new Map();
+ let tool = null;
+
+ const bulldozeBtn = el('button', 'build-btn bulldoze');
+ bulldozeBtn.appendChild(el('span', 'bb-name', 'Bulldoze'));
+ bulldozeBtn.appendChild(el('span', 'bb-cost', '35% back'));
+ bulldozeBtn.addEventListener('click', () => select(tool === BULLDOZE ? null : BULLDOZE));
+ bulldozeBtn.title = 'Demolish a structure and recover part of its cost. [X]';
+
+ const render = () => {
+ clear(root);
+ const group = el('div', 'build-group');
+ group.appendChild(el('h3', 'build-cat', 'Tools'));
+ group.appendChild(bulldozeBtn);
+ root.appendChild(group);
+
+ let current = null;
+ let container = null;
+ for (const def of CATALOG) {
+ if (def.hidden) continue;
+ if (def.cat !== current) {
+ current = def.cat;
+ container = el('div', 'build-group');
+ container.appendChild(el('h3', 'build-cat', CATEGORY_LABEL[def.cat] ?? def.cat));
+ root.appendChild(container);
+ }
+ const btn = el('button', 'build-btn');
+ btn.appendChild(el('span', 'bb-name', def.name));
+ btn.appendChild(el('span', 'bb-cost', credits(def.cost)));
+ btn.appendChild(el('span', 'bb-stats', statLine(def)));
+ btn.style.setProperty('--accent', def.accent);
+ btn.addEventListener('click', () => select(tool === def.id ? null : def.id));
+ btn.addEventListener('pointerenter', () => onHoverDef?.(def));
+ btn.addEventListener('pointerleave', () => onHoverDef?.(null));
+ container.appendChild(btn);
+ buttons.set(def.id, btn);
+ }
+ };
+
+ const select = (id) => {
+ tool = id;
+ for (const [key, btn] of buttons) setClass(btn, 'selected', key === id);
+ setClass(bulldozeBtn, 'selected', id === BULLDOZE);
+ onSelect(id);
+ };
+
+ /** Reflect era locks and affordability against the current state. */
+ const update = (state) => {
+ for (const def of CATALOG) {
+ const btn = buttons.get(def.id);
+ if (!btn) continue;
+ const locked = def.era > state.era;
+ const poor = def.cost > state.credits;
+ setClass(btn, 'locked', locked);
+ setClass(btn, 'poor', !locked && poor);
+ btn.disabled = locked;
+ btn.title = locked ? `Unlocks in Era ${def.era}` : `${def.desc}`;
+ }
+ };
+
+ render();
+ return { update, select, getTool: () => tool };
+};
diff --git a/games/gridlock/src/ui/copy.js b/games/gridlock/src/ui/copy.js
new file mode 100644
index 0000000..db876f7
--- /dev/null
+++ b/games/gridlock/src/ui/copy.js
@@ -0,0 +1,45 @@
+/**
+ * Long-form player-facing text. Kept out of the logic modules so copy edits
+ * never touch code.
+ */
+
+export const INTRO = {
+ eyebrow: 'City Directive 001',
+ title: 'GRIDLOCK',
+ body: [
+ 'The coast moved inland, the old capital drowned, and what is left of the region has handed you a bay, a road stub, and thirty thousand credits.',
+ 'Zone it. Power it. Cool it. Every watt, every litre and every commuter runs through decisions you make on this map — and the planet is keeping score in the Heat Index.',
+ 'Build the Zero Point interchange to win. Go bankrupt, and the city goes into receivership without you.',
+ ].join('\n\n'),
+};
+
+export const HELP = {
+ eyebrow: 'Field Manual',
+ title: 'How to run a city',
+ body: [
+ 'BUILDING — pick a structure on the left, then click or drag across the map. Drag is how you lay roads. Right-drag or drag with no tool selected to pan; the wheel zooms toward the cursor.',
+ 'THE THREE CHAINS — a structure works only if it touches a road wired back to the Nexus, draws power, and draws water. Power failures cascade: unpowered pumps stop making water. Watch the Utilities view.',
+ 'ADJACENCY — placement is the puzzle. Commerce beside housing earns more, housing beside industry loses approval, data centres on the waterfront run cooler. The inspector shows exactly what a tile would score before you commit.',
+ 'HEAT — industry, generators, and server farms raise the Heat Index. Past 55 it raises cooling demand and costs approval; past 88 the city is in crisis. Parks, sky gardens, and carbon scrubbers pull it back.',
+ 'RESEARCH — academies and labs bank research, and research is the only thing that advances the era. New eras unlock everything worth building.',
+ 'KEYS — [Space] pause · [1][2][3] speed · [X] bulldoze · [O] cycle map view · [S] save · [H] this screen · [Esc] drop tool.',
+ ].join('\n\n'),
+};
+
+export const VICTORY = {
+ eyebrow: 'Zero Point online',
+ title: 'The city moves',
+ body: [
+ 'The interchange lit at 04:12 and the arterials cleared for the first time since the founding. Nothing about this city is stuck any more.',
+ 'You can keep building — there is no cap on what this becomes.',
+ ].join('\n\n'),
+};
+
+export const BANKRUPT = {
+ eyebrow: 'Receivership',
+ title: 'The treasury is empty',
+ body: [
+ 'Creditors have taken the ledger. You keep your desk, for now, but every month below zero digs the hole deeper.',
+ 'Raise the tax rate, bulldoze what you cannot afford to run, and get business revenue moving again.',
+ ].join('\n\n'),
+};
diff --git a/games/gridlock/src/ui/dom.js b/games/gridlock/src/ui/dom.js
new file mode 100644
index 0000000..cbd24f8
--- /dev/null
+++ b/games/gridlock/src/ui/dom.js
@@ -0,0 +1,68 @@
+/**
+ * DOM and formatting helpers. Every UI module builds nodes through these, so
+ * text formatting is consistent and nothing anywhere concatenates HTML from
+ * user-controlled strings.
+ */
+
+export const $ = (selector, root = document) => root.querySelector(selector);
+
+export const el = (tag, className, text) => {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text !== undefined) node.textContent = String(text);
+ return node;
+};
+
+export const clear = (node) => {
+ while (node.firstChild) node.removeChild(node.firstChild);
+ return node;
+};
+
+/** 1234 → "1.2k", 1234567 → "1.2M". Keeps the HUD from reflowing. */
+export const compact = (value) => {
+ const n = Math.round(value);
+ const abs = Math.abs(n);
+ if (abs >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
+ if (abs >= 10000) return `${(n / 1000).toFixed(1)}k`;
+ return n.toLocaleString('en-US');
+};
+
+export const credits = (value) => `₡${compact(value)}`;
+
+export const signed = (value) => `${value >= 0 ? '+' : '−'}₡${compact(Math.abs(value))}`;
+
+export const percent = (value, digits = 0) => `${(value * 100).toFixed(digits)}%`;
+
+/** Set text only when it changed — avoids per-frame layout thrash. */
+export const setText = (node, text) => {
+ const next = String(text);
+ if (node && node.textContent !== next) node.textContent = next;
+};
+
+export const setClass = (node, className, on) => {
+ if (node) node.classList.toggle(className, Boolean(on));
+};
+
+/** Inline SVG sparkline from a numeric series. */
+export const sparkline = (series, width = 132, height = 30, color = '#c9a84c') => {
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
+ svg.setAttribute('class', 'spark');
+ if (series.length < 2) return svg;
+
+ const min = Math.min(...series);
+ const max = Math.max(...series);
+ const span = max - min || 1;
+ const step = width / (series.length - 1);
+ const points = series
+ .map((v, i) => `${(i * step).toFixed(1)},${(height - ((v - min) / span) * (height - 4) - 2).toFixed(1)}`)
+ .join(' ');
+
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
+ line.setAttribute('points', points);
+ line.setAttribute('fill', 'none');
+ line.setAttribute('stroke', color);
+ line.setAttribute('stroke-width', '1.5');
+ svg.appendChild(line);
+ return svg;
+};
diff --git a/games/gridlock/src/ui/hud.js b/games/gridlock/src/ui/hud.js
new file mode 100644
index 0000000..d66c9e9
--- /dev/null
+++ b/games/gridlock/src/ui/hud.js
@@ -0,0 +1,118 @@
+/**
+ * Top status bar. Binds once to the static markup in index.html and then only
+ * writes values — no per-frame DOM construction.
+ */
+
+import { SPEED_LABEL, CLIMATE } from '../config/balance.js';
+import { eraById, techToNextEra } from '../config/eras.js';
+import { cityGrade } from '../core/economy.js';
+import { formatDate } from '../core/state.js';
+import { $, clear, compact, credits, el, percent, setClass, setText, signed } from './dom.js';
+
+/**
+ * The failures worth interrupting the player for. Ordered by how quickly they
+ * kill a city — a district cut off from the road graph produces nothing at all,
+ * which is the mistake players most often stare straight past.
+ */
+const alertsFor = (state, stats) => {
+ const out = [];
+ if (stats.stranded > 0) out.push({ text: `${stats.stranded} CUT OFF`, tone: 'bad', title: 'Structures with no road back to the Nexus. They produce nothing.' });
+ if (stats.power.ratio < 1) out.push({ text: 'BROWNOUT', tone: 'bad', title: 'Power demand exceeds supply. Low-priority blocks are dark.' });
+ if (stats.water.ratio < 1) out.push({ text: 'WATER SHORT', tone: 'bad', title: 'Water demand exceeds supply.' });
+ if (stats.gridlock > 0.3) out.push({ text: 'GRIDLOCK', tone: 'warn', title: 'Congestion is eating business revenue. Build roads or transit.' });
+ if (stats.net < 0) out.push({ text: 'DEFICIT', tone: 'warn', title: 'The city spends more than it earns.' });
+ if (state.heat >= CLIMATE.threshold) out.push({ text: 'OVERHEATING', tone: 'warn', title: 'The Heat Index is past the threshold. Cooling costs are rising.' });
+ return out;
+};
+
+const meter = (node, ratio, tone) => {
+ if (!node) return;
+ const fill = node.firstElementChild;
+ if (fill) fill.style.width = `${Math.max(0, Math.min(1, ratio)) * 100}%`;
+ node.dataset.tone = tone;
+};
+
+const utilityTone = (ratio) => (ratio >= 1 ? 'good' : ratio >= 0.85 ? 'warn' : 'bad');
+
+export const createHud = (root) => {
+ const nodes = {
+ date: $('#hud-date', root),
+ credits: $('#hud-credits', root),
+ net: $('#hud-net', root),
+ pop: $('#hud-pop', root),
+ popNote: $('#hud-pop-note', root),
+ approval: $('#hud-approval', root),
+ approvalBar: $('#hud-approval-bar', root),
+ power: $('#hud-power', root),
+ powerBar: $('#hud-power-bar', root),
+ water: $('#hud-water', root),
+ waterBar: $('#hud-water-bar', root),
+ heat: $('#hud-heat', root),
+ heatBar: $('#hud-heat-bar', root),
+ era: $('#hud-era', root),
+ eraNote: $('#hud-era-note', root),
+ grade: $('#hud-grade', root),
+ speed: $('#hud-speed', root),
+ alerts: $('#hud-alerts', root),
+ };
+
+ let lastAlertKey = null;
+ const renderAlerts = (state, stats) => {
+ const alerts = alertsFor(state, stats);
+ const key = alerts.map((a) => a.text).join('|');
+ if (key === lastAlertKey) return; // Rebuild only when the set changes.
+ lastAlertKey = key;
+ const host = clear(nodes.alerts);
+ for (const alert of alerts) {
+ const chip = el('span', 'alert-chip', alert.text);
+ chip.dataset.tone = alert.tone;
+ chip.title = alert.title;
+ host.appendChild(chip);
+ }
+ };
+
+ const update = (state) => {
+ const stats = state.stats;
+ setText(nodes.date, formatDate(state.month));
+ setText(nodes.credits, credits(state.credits));
+ setClass(nodes.credits, 'negative', state.credits < 0);
+ setText(nodes.pop, compact(state.population));
+ setText(nodes.speed, SPEED_LABEL[state.speed]);
+
+ const grade = cityGrade(state.approval, state.heat);
+ setText(nodes.grade, grade.label);
+ if (nodes.grade) nodes.grade.dataset.tone = grade.tone;
+
+ const era = eraById(state.era);
+ setText(nodes.era, `ERA ${state.era} · ${era.name.toUpperCase()}`);
+ const next = techToNextEra(state.tech);
+ setText(nodes.eraNote, next
+ ? `${Math.round(next.remaining)} research to ${next.era.name}`
+ : 'Final era reached');
+
+ setText(nodes.approval, `${Math.round(state.approval)}%`);
+ meter(nodes.approvalBar, state.approval / 100,
+ state.approval > 65 ? 'good' : state.approval > 40 ? 'warn' : 'bad');
+
+ setText(nodes.heat, Math.round(state.heat));
+ meter(nodes.heatBar, state.heat / CLIMATE.max,
+ state.heat < CLIMATE.threshold ? 'good' : state.heat < CLIMATE.critical ? 'warn' : 'bad');
+
+ if (!stats) return;
+ setText(nodes.net, `${signed(stats.net)}/mo`);
+ setClass(nodes.net, 'negative', stats.net < 0);
+ setText(nodes.popNote, `${compact(stats.jobsFilled)} working · ${percent(stats.unemployment)} idle`);
+
+ setText(nodes.power, `${compact(stats.power.supply)}/${compact(stats.power.demand)}`);
+ meter(nodes.powerBar, stats.power.demand ? stats.power.supply / stats.power.demand : 1,
+ utilityTone(stats.power.ratio));
+
+ setText(nodes.water, `${compact(stats.water.supply)}/${compact(stats.water.demand)}`);
+ meter(nodes.waterBar, stats.water.demand ? stats.water.supply / stats.water.demand : 1,
+ utilityTone(stats.water.ratio));
+
+ renderAlerts(state, stats);
+ };
+
+ return { update };
+};
diff --git a/games/gridlock/src/ui/inspector.js b/games/gridlock/src/ui/inspector.js
new file mode 100644
index 0000000..c9ac676
--- /dev/null
+++ b/games/gridlock/src/ui/inspector.js
@@ -0,0 +1,109 @@
+/**
+ * Context panel. Answers exactly one question at a time: "what happens if I
+ * click here?" — or, with no tool held, "what is this thing doing?"
+ */
+
+import { getBuilding } from '../config/buildings.js';
+import { canPlace } from '../core/commands.js';
+import { idx, TERRAIN } from '../core/grid.js';
+import { previewSynergy, tileSynergy } from '../core/synergy.js';
+import { clear, credits, el } from './dom.js';
+import { BULLDOZE } from './build.js';
+
+const TERRAIN_NAME = { [TERRAIN.LAND]: 'Open ground', [TERRAIN.WATER]: 'Open water', [TERRAIN.HILL]: 'Ridge' };
+
+const synergyList = (syn) => {
+ const wrap = el('div', 'insp-syn');
+ if (syn.hits.length === 0) {
+ wrap.appendChild(el('p', 'insp-dim', 'No adjacency bonuses here.'));
+ return wrap;
+ }
+ for (const hit of syn.hits) {
+ const row = el('div', 'syn-row');
+ const positive = !hit.label.includes('Downwind');
+ row.dataset.tone = positive ? 'good' : 'bad';
+ row.appendChild(el('span', 'syn-label', hit.label));
+ row.appendChild(el('span', 'syn-count', `×${hit.count}${hit.count >= hit.cap ? ' max' : ''}`));
+ row.appendChild(el('span', 'syn-hint', hit.hint));
+ wrap.appendChild(row);
+ }
+ return wrap;
+};
+
+const multipliers = (syn) => {
+ const out = [];
+ if (syn.revenueMul !== 1) out.push(`revenue ×${syn.revenueMul.toFixed(2)}`);
+ if (syn.capacityMul !== 1) out.push(`housing ×${syn.capacityMul.toFixed(2)}`);
+ if (syn.heatMul !== 1) out.push(`heat ×${syn.heatMul.toFixed(2)}`);
+ if (syn.techMul !== 1) out.push(`research ×${syn.techMul.toFixed(2)}`);
+ if (syn.appealAdd) out.push(`appeal ${syn.appealAdd > 0 ? '+' : ''}${syn.appealAdd.toFixed(1)}`);
+ return out.join(' · ');
+};
+
+const statusOf = (state, tile) => {
+ const stats = state.stats;
+ if (!stats) return { label: 'Starting up', tone: 'warn' };
+ if (stats.live.has(tile.i)) return { label: 'Operating', tone: 'good' };
+ if (!stats.network.connected.has(tile.i)) return { label: 'No road connection', tone: 'bad' };
+ if (!stats.powered.has(tile.i)) return { label: 'No power', tone: 'bad' };
+ return { label: 'No water', tone: 'bad' };
+};
+
+export const createInspector = (root) => {
+ const show = (state, hover, tool, hoverDef) => {
+ clear(root);
+
+ // Palette hover wins: the player is asking about a building, not a tile.
+ const def = hoverDef ?? (tool && tool !== BULLDOZE ? getBuilding(tool) : null);
+
+ if (def && hover) {
+ const check = canPlace(state, hover.x, hover.y, def.id);
+ root.appendChild(el('h2', 'insp-title', def.name));
+ root.appendChild(el('p', 'insp-desc', def.desc));
+ const verdict = el('p', check.ok ? 'insp-ok' : 'insp-bad',
+ check.ok ? `Build here — ${credits(def.cost)}` : check.reason);
+ root.appendChild(verdict);
+ root.appendChild(synergyList(previewSynergy(state.grid, hover.x, hover.y, def.id)));
+ return;
+ }
+ if (def) {
+ root.appendChild(el('h2', 'insp-title', def.name));
+ root.appendChild(el('p', 'insp-desc', def.desc));
+ root.appendChild(el('p', 'insp-dim', `${credits(def.cost)} · upkeep ₡${def.upkeep}/mo`));
+ return;
+ }
+ if (!hover) {
+ root.appendChild(el('h2', 'insp-title', 'The Nexus City'));
+ root.appendChild(el('p', 'insp-desc',
+ 'Pick a structure on the left, then drag on the map to build. Right-drag pans, wheel zooms.'));
+ return;
+ }
+
+ const tile = state.grid.tiles[idx(state.grid, hover.x, hover.y)];
+ if (tile.b === null) {
+ root.appendChild(el('h2', 'insp-title', TERRAIN_NAME[tile.t] ?? 'Ground'));
+ root.appendChild(el('p', 'insp-dim', `Tile ${hover.x},${hover.y}`));
+ if (state.stats) {
+ const appeal = state.stats.fields.appeal[tile.i];
+ root.appendChild(el('p', 'insp-desc', `Desirability ${appeal >= 0 ? '+' : ''}${appeal.toFixed(1)}`));
+ }
+ return;
+ }
+
+ const built = getBuilding(tile.b);
+ const status = statusOf(state, tile);
+ root.appendChild(el('h2', 'insp-title', built?.name ?? 'Structure'));
+ const badge = el('p', 'insp-status', status.label);
+ badge.dataset.tone = status.tone;
+ root.appendChild(badge);
+ root.appendChild(el('p', 'insp-desc', built?.desc ?? ''));
+
+ const syn = tileSynergy(state.grid, tile);
+ const mults = multipliers(syn);
+ if (mults) root.appendChild(el('p', 'insp-mult', mults));
+ root.appendChild(synergyList(syn));
+ root.appendChild(el('p', 'insp-dim', `Upkeep ₡${built?.upkeep ?? 0}/mo · standing ${tile.age} months`));
+ };
+
+ return { show };
+};
diff --git a/games/gridlock/src/ui/modal.js b/games/gridlock/src/ui/modal.js
new file mode 100644
index 0000000..da9f357
--- /dev/null
+++ b/games/gridlock/src/ui/modal.js
@@ -0,0 +1,75 @@
+/**
+ * Modal layer: crisis cards, the opening brief, and end states.
+ * One node, one visibility flag, no stacking — a modal is always the only
+ * thing the player can act on.
+ */
+
+import { choiceCost } from '../core/events.js';
+import { clear, credits, el } from './dom.js';
+
+export const createModal = (root) => {
+ const card = el('div', 'modal-card');
+ root.appendChild(card);
+
+ const hide = () => {
+ root.classList.remove('open');
+ clear(card);
+ };
+
+ const open = () => root.classList.add('open');
+
+ /**
+ * @param {object} crisis deck entry
+ * @param {(index:number)=>void} onChoose
+ */
+ const showCrisis = (crisis, state, stats, onChoose) => {
+ clear(card);
+ card.dataset.kind = 'crisis';
+ card.appendChild(el('span', 'modal-eyebrow', 'Crisis'));
+ card.appendChild(el('h2', 'modal-title', crisis.title));
+ card.appendChild(el('p', 'modal-body', crisis.flavor));
+
+ const options = el('div', 'modal-choices');
+ crisis.choices.forEach((choice, index) => {
+ const cost = choiceCost(choice, state, stats);
+ const affordable = cost <= state.credits;
+ const btn = el('button', 'choice');
+ btn.appendChild(el('span', 'choice-label', choice.label));
+ btn.appendChild(el('span', 'choice-detail', choice.detail));
+ btn.appendChild(el('span', 'choice-cost', cost > 0 ? `Costs ${credits(cost)}` : 'No direct cost'));
+ btn.disabled = !affordable;
+ if (!affordable) btn.title = 'The treasury cannot cover this.';
+ btn.addEventListener('click', () => onChoose(index));
+ options.appendChild(btn);
+ });
+ card.appendChild(options);
+ open();
+ };
+
+ /**
+ * @param {{eyebrow?:string, title:string, body:string, kind?:string,
+ * actions:{label:string, onClick:()=>void, primary?:boolean}[]}} spec
+ */
+ const showMessage = (spec) => {
+ clear(card);
+ card.dataset.kind = spec.kind ?? 'info';
+ if (spec.eyebrow) card.appendChild(el('span', 'modal-eyebrow', spec.eyebrow));
+ card.appendChild(el('h2', 'modal-title', spec.title));
+ for (const paragraph of spec.body.split('\n\n')) {
+ card.appendChild(el('p', 'modal-body', paragraph));
+ }
+ const actions = el('div', 'modal-actions');
+ for (const action of spec.actions) {
+ const btn = el('button', action.primary ? 'btn primary' : 'btn');
+ btn.textContent = action.label;
+ btn.addEventListener('click', action.onClick);
+ actions.appendChild(btn);
+ }
+ card.appendChild(actions);
+ open();
+ };
+
+ const isOpen = () => root.classList.contains('open');
+
+ return { showCrisis, showMessage, hide, isOpen };
+};
diff --git a/games/gridlock/src/ui/panels.js b/games/gridlock/src/ui/panels.js
new file mode 100644
index 0000000..5ed4d2b
--- /dev/null
+++ b/games/gridlock/src/ui/panels.js
@@ -0,0 +1,108 @@
+/**
+ * Right-hand column: directives, active crisis modifiers, trend lines, and the
+ * city log. Rebuilt on month boundaries only — never per frame.
+ */
+
+import { directiveById } from '../config/directives.js';
+import { directiveProgress } from '../core/directives.js';
+import { formatDate } from '../core/state.js';
+import { $, clear, compact, el, sparkline } from './dom.js';
+
+const progressBar = (current, goal) => {
+ const bar = el('div', 'dir-bar');
+ const fill = el('i');
+ fill.style.width = `${Math.max(0, Math.min(1, current / goal)) * 100}%`;
+ bar.appendChild(fill);
+ return bar;
+};
+
+export const createPanels = (root) => {
+ const nodes = {
+ directives: $('#panel-directives', root),
+ modifiers: $('#panel-modifiers', root),
+ trends: $('#panel-trends', root),
+ log: $('#panel-log', root),
+ };
+
+ const renderDirectives = (state, stats) => {
+ const host = clear(nodes.directives);
+ for (const id of state.directives.active) {
+ const directive = directiveById(id);
+ if (!directive) continue;
+ const card = el('div', 'dir-card');
+ card.appendChild(el('h4', 'dir-title', directive.title));
+ card.appendChild(el('p', 'dir-detail', directive.detail));
+ const progress = directiveProgress(directive, state, stats);
+ if (progress) {
+ card.appendChild(progressBar(progress[0], progress[1]));
+ card.appendChild(el('span', 'dir-count',
+ directive.streak
+ ? `${progress[0]}/${progress[1]} months`
+ : `${compact(progress[0])} / ${compact(progress[1])}`));
+ }
+ if (directive.reward.credits) {
+ card.appendChild(el('span', 'dir-reward', `Reward ₡${directive.reward.credits.toLocaleString()}`));
+ }
+ host.appendChild(card);
+ }
+ if (state.directives.active.length === 0) {
+ host.appendChild(el('p', 'insp-dim', 'Every directive complete. The city is yours.'));
+ }
+ };
+
+ const renderModifiers = (state) => {
+ const host = clear(nodes.modifiers);
+ if (state.modifiers.length === 0) {
+ host.appendChild(el('p', 'insp-dim', 'No active pressures.'));
+ return;
+ }
+ for (const mod of state.modifiers) {
+ const row = el('div', 'mod-row');
+ row.appendChild(el('span', 'mod-label', mod.label));
+ row.appendChild(el('span', 'mod-months', `${mod.months}mo`));
+ host.appendChild(row);
+ }
+ };
+
+ const renderTrends = (state) => {
+ const host = clear(nodes.trends);
+ const series = [
+ { label: 'Population', data: state.history.population, color: '#8b5cf6' },
+ { label: 'Treasury', data: state.history.credits, color: '#c9a84c' },
+ { label: 'Approval', data: state.history.approval, color: '#38e1c8' },
+ { label: 'Heat Index', data: state.history.heat, color: '#ff7a3d' },
+ ];
+ for (const s of series) {
+ const row = el('div', 'trend-row');
+ const head = el('div', 'trend-head');
+ head.appendChild(el('span', 'trend-label', s.label));
+ head.appendChild(el('span', 'trend-value', s.data.length ? compact(s.data[s.data.length - 1]) : '—'));
+ row.appendChild(head);
+ row.appendChild(sparkline(s.data, 150, 26, s.color));
+ host.appendChild(row);
+ }
+ };
+
+ const renderLog = (state) => {
+ const host = clear(nodes.log);
+ for (const entry of state.log.slice(0, 14)) {
+ const row = el('div', 'log-row');
+ row.dataset.tone = entry.kind;
+ row.appendChild(el('span', 'log-date', formatDate(entry.month)));
+ row.appendChild(el('span', 'log-text', entry.text));
+ host.appendChild(row);
+ }
+ if (state.log.length === 0) host.appendChild(el('p', 'insp-dim', 'Nothing to report.'));
+ };
+
+ const update = (state) => {
+ const stats = state.stats;
+ if (!stats) return;
+ renderDirectives(state, stats);
+ renderModifiers(state);
+ renderTrends(state);
+ renderLog(state);
+ };
+
+ return { update };
+};
diff --git a/games/gridlock/src/ui/toast.js b/games/gridlock/src/ui/toast.js
new file mode 100644
index 0000000..e9c34fc
--- /dev/null
+++ b/games/gridlock/src/ui/toast.js
@@ -0,0 +1,34 @@
+/**
+ * Transient feedback for rejected actions and small wins. Bounded queue:
+ * spamming a blocked tile can never fill the screen with toasts.
+ */
+
+import { el } from './dom.js';
+
+const MAX_VISIBLE = 3;
+const LIFETIME_MS = 2200;
+
+export const createToaster = (root) => {
+ let lastMessage = '';
+ let lastAt = 0;
+
+ const push = (message, tone = 'info') => {
+ const now = Date.now();
+ // Collapse repeats: dragging across illegal tiles emits the same refusal.
+ if (message === lastMessage && now - lastAt < LIFETIME_MS) return;
+ lastMessage = message;
+ lastAt = now;
+
+ const node = el('div', 'toast', message);
+ node.dataset.tone = tone;
+ root.appendChild(node);
+ while (root.childElementCount > MAX_VISIBLE) root.removeChild(root.firstChild);
+
+ setTimeout(() => {
+ node.classList.add('leaving');
+ setTimeout(() => node.remove(), 260);
+ }, LIFETIME_MS);
+ };
+
+ return { push };
+};
diff --git a/games/gridlock/styles.css b/games/gridlock/styles.css
new file mode 100644
index 0000000..0232f7e
--- /dev/null
+++ b/games/gridlock/styles.css
@@ -0,0 +1,263 @@
+/* GRIDLOCK — dark-luxury obsidian shell. Cinzel for display, DM Mono for data.
+ Layout is a fixed frame over a full-bleed canvas: the map is the page. */
+
+:root {
+ --void: #08060f;
+ --panel: rgba(14, 11, 24, 0.86);
+ --panel-solid: #0e0b18;
+ --edge: rgba(139, 92, 246, 0.2);
+ --edge-strong: rgba(139, 92, 246, 0.42);
+ --gold: #c9a84c;
+ --gold-bright: #f0d489;
+ --violet: #8b5cf6;
+ --mint: #38e1c8;
+ --ember: #ff7a3d;
+ --rose: #ff5ec4;
+ --ink: #efe9ff;
+ --dim: #8b83a6;
+ --good: #5ee08a;
+ --warn: #ffb347;
+ --bad: #ff5470;
+ --panel-w: 264px;
+}
+
+* { margin: 0; padding: 0; box-sizing: border-box; }
+
+html, body {
+ width: 100%; height: 100%; overflow: hidden;
+ background: var(--void); color: var(--ink);
+ font-family: 'DM Mono', ui-monospace, monospace;
+ font-size: 13px; -webkit-font-smoothing: antialiased;
+}
+
+#app { position: relative; width: 100%; height: 100%; }
+#stage { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: crosshair; }
+
+/* ── HUD ──────────────────────────────────────────────────────────────── */
+.hud {
+ position: absolute; top: 0; left: 0; right: 0; z-index: 5;
+ display: flex; align-items: stretch; gap: 2px;
+ background: linear-gradient(180deg, rgba(8, 6, 15, 0.96), rgba(8, 6, 15, 0.72));
+ border-bottom: 1px solid var(--edge);
+ backdrop-filter: blur(10px);
+ padding: 8px 12px;
+}
+
+.hud-brand { display: flex; flex-direction: column; justify-content: center; padding-right: 18px; min-width: 168px; }
+.brand-mark {
+ font-family: 'Cinzel', serif; font-weight: 900; font-size: 20px; letter-spacing: 0.16em;
+ background: linear-gradient(120deg, var(--gold) 10%, var(--gold-bright) 45%, var(--violet) 100%);
+ -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
+}
+.brand-date { color: var(--dim); font-size: 11px; letter-spacing: 0.22em; }
+.brand-grade { font-size: 10px; letter-spacing: 0.24em; margin-top: 2px; }
+
+.hud-stat {
+ display: flex; flex-direction: column; justify-content: center; gap: 2px;
+ padding: 2px 16px; border-left: 1px solid rgba(139, 92, 246, 0.12); min-width: 118px;
+}
+.hud-stat.wide { min-width: 200px; }
+.hs-label { font-size: 9px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--dim); }
+.hs-value { font-size: 16px; font-weight: 500; color: var(--ink); letter-spacing: 0.02em; }
+.hs-value.negative { color: var(--bad); }
+.hs-note { font-size: 10px; color: var(--dim); letter-spacing: 0.06em; }
+.hs-note.negative { color: var(--bad); }
+
+.meter { height: 4px; width: 100%; background: rgba(255, 255, 255, 0.08); border-radius: 2px; overflow: hidden; }
+.meter i { display: block; height: 100%; background: var(--good); transition: width 0.35s ease; }
+.meter[data-tone='warn'] i { background: var(--warn); }
+.meter[data-tone='bad'] i { background: var(--bad); }
+[data-tone='good'] { color: var(--good); }
+[data-tone='warn'] { color: var(--warn); }
+[data-tone='bad'] { color: var(--bad); }
+[data-tone='ok'] { color: var(--mint); }
+
+/* ── Side panels ──────────────────────────────────────────────────────── */
+.panel {
+ position: absolute; top: 74px; bottom: 52px; z-index: 4; width: var(--panel-w);
+ background: var(--panel); backdrop-filter: blur(12px);
+ border: 1px solid var(--edge); display: flex; flex-direction: column;
+ overflow: hidden;
+}
+.panel.left { left: 0; border-left: none; }
+.panel.right { right: 0; border-right: none; overflow-y: auto; }
+
+.panel-head {
+ font-family: 'Cinzel', serif; font-size: 12px; letter-spacing: 0.24em; text-transform: uppercase;
+ color: var(--gold); padding: 12px 14px 8px; border-bottom: 1px solid var(--edge);
+ display: flex; flex-direction: column; gap: 3px;
+}
+.panel-hint { font-family: 'DM Mono', monospace; font-size: 9px; letter-spacing: 0.08em; color: var(--dim); text-transform: none; }
+
+.build-list { overflow-y: auto; padding: 6px 8px 18px; flex: 1; }
+.build-group { margin-bottom: 10px; }
+.build-cat {
+ font-size: 9px; letter-spacing: 0.24em; text-transform: uppercase; color: var(--dim);
+ padding: 8px 6px 5px; border-bottom: 1px dashed rgba(139, 92, 246, 0.14); margin-bottom: 5px;
+}
+
+.build-btn {
+ --accent: var(--violet);
+ display: grid; grid-template-columns: 1fr auto; gap: 1px 8px; width: 100%;
+ text-align: left; cursor: pointer; color: var(--ink);
+ background: rgba(255, 255, 255, 0.028); border: 1px solid transparent;
+ border-left: 2px solid var(--accent); border-radius: 3px;
+ padding: 6px 8px; margin-bottom: 4px; font-family: inherit; font-size: 12px;
+ transition: background 0.12s ease, border-color 0.12s ease, transform 0.08s ease;
+}
+.build-btn:hover:not(:disabled) { background: rgba(139, 92, 246, 0.14); transform: translateX(2px); }
+.build-btn.selected { background: rgba(201, 168, 76, 0.16); border-color: var(--gold); }
+.build-btn.poor { opacity: 0.55; }
+.build-btn.locked { opacity: 0.3; cursor: not-allowed; filter: grayscale(1); }
+.bb-name { font-weight: 500; letter-spacing: 0.03em; }
+.bb-cost { color: var(--gold); font-size: 11px; }
+.bb-stats { grid-column: 1 / -1; font-size: 9.5px; color: var(--dim); letter-spacing: 0.02em; }
+.build-btn.bulldoze { --accent: var(--bad); }
+
+/* ── Inspector + right column ─────────────────────────────────────────── */
+.inspector { padding: 12px 14px; border-bottom: 1px solid var(--edge); min-height: 150px; }
+.insp-title { font-family: 'Cinzel', serif; font-size: 15px; letter-spacing: 0.08em; color: var(--gold-bright); margin-bottom: 4px; }
+.insp-desc { font-size: 11px; line-height: 1.55; color: #cfc6e6; margin-bottom: 6px; }
+.insp-dim { font-size: 10px; color: var(--dim); line-height: 1.5; }
+.insp-status { font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; margin-bottom: 6px; }
+.insp-ok { font-size: 11px; color: var(--good); margin-bottom: 6px; }
+.insp-bad { font-size: 11px; color: var(--bad); margin-bottom: 6px; }
+.insp-mult { font-size: 10.5px; color: var(--mint); margin-bottom: 6px; letter-spacing: 0.04em; }
+
+.insp-syn { display: flex; flex-direction: column; gap: 4px; }
+.syn-row {
+ display: grid; grid-template-columns: 1fr auto; gap: 0 8px;
+ padding: 5px 7px; border-radius: 3px; background: rgba(94, 224, 138, 0.08);
+ border-left: 2px solid var(--good); font-size: 10.5px;
+}
+.syn-row[data-tone='bad'] { background: rgba(255, 84, 112, 0.09); border-left-color: var(--bad); }
+.syn-label { font-weight: 500; }
+.syn-count { color: var(--dim); }
+.syn-hint { grid-column: 1 / -1; color: var(--dim); font-size: 9.5px; line-height: 1.4; }
+
+.panel-section { padding: 10px 14px; border-bottom: 1px solid rgba(139, 92, 246, 0.1); }
+.panel-section h3 {
+ font-family: 'Cinzel', serif; font-size: 10px; letter-spacing: 0.26em; text-transform: uppercase;
+ color: var(--gold); margin-bottom: 8px;
+}
+
+.dir-card { padding: 7px 0 9px; border-bottom: 1px dashed rgba(139, 92, 246, 0.14); }
+.dir-card:last-child { border-bottom: none; }
+.dir-title { font-size: 12px; color: var(--ink); letter-spacing: 0.04em; margin-bottom: 2px; }
+.dir-detail { font-size: 10px; color: var(--dim); line-height: 1.45; margin-bottom: 5px; }
+.dir-bar { height: 3px; background: rgba(255, 255, 255, 0.08); border-radius: 2px; overflow: hidden; }
+.dir-bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--violet), var(--gold)); }
+.dir-count { font-size: 9.5px; color: var(--dim); }
+.dir-reward { display: block; font-size: 9.5px; color: var(--gold); margin-top: 2px; }
+
+.mod-row { display: flex; justify-content: space-between; font-size: 10.5px; padding: 3px 0; color: var(--warn); }
+.mod-months { color: var(--dim); }
+
+.trend-row { margin-bottom: 8px; }
+.trend-head { display: flex; justify-content: space-between; font-size: 10px; color: var(--dim); }
+.trend-value { color: var(--ink); }
+.spark { width: 100%; height: 26px; display: block; }
+
+.log-row { display: grid; grid-template-columns: 62px 1fr; gap: 6px; font-size: 10px; padding: 3px 0; line-height: 1.4; }
+.log-date { color: var(--dim); letter-spacing: 0.06em; }
+.log-row[data-tone='good'] .log-text { color: var(--good); }
+.log-row[data-tone='bad'] .log-text { color: var(--bad); }
+.log-row[data-tone='event'] .log-text { color: var(--gold); }
+.log-text { color: #cfc6e6; }
+
+/* ── Controls ─────────────────────────────────────────────────────────── */
+.controls {
+ position: absolute; left: 0; right: 0; bottom: 0; z-index: 5; height: 52px;
+ display: flex; align-items: center; gap: 18px; padding: 0 14px;
+ background: linear-gradient(0deg, rgba(8, 6, 15, 0.97), rgba(8, 6, 15, 0.7));
+ border-top: 1px solid var(--edge); backdrop-filter: blur(10px);
+}
+.ctrl-group { display: flex; align-items: center; gap: 5px; }
+.ctrl-group.right-align { margin-left: auto; }
+.ctrl-label { font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--dim); margin-right: 4px; }
+.ctrl-label b { color: var(--gold); letter-spacing: 0.06em; }
+
+.btn {
+ font-family: inherit; font-size: 11px; letter-spacing: 0.1em; color: var(--ink);
+ background: rgba(255, 255, 255, 0.05); border: 1px solid var(--edge);
+ border-radius: 3px; padding: 6px 12px; cursor: pointer; transition: all 0.12s ease;
+}
+.btn:hover:not(:disabled) { background: rgba(139, 92, 246, 0.2); border-color: var(--edge-strong); }
+.btn:disabled { opacity: 0.35; cursor: not-allowed; }
+.btn.tiny { padding: 5px 9px; font-size: 10px; }
+.btn.active { background: rgba(201, 168, 76, 0.2); border-color: var(--gold); color: var(--gold-bright); }
+.btn.primary { background: linear-gradient(120deg, var(--gold), #a8862f); color: #14100a; border-color: transparent; font-weight: 500; }
+.btn.primary:hover { filter: brightness(1.12); }
+
+/* ── Modal ────────────────────────────────────────────────────────────── */
+.modal {
+ position: absolute; inset: 0; z-index: 20; display: none;
+ align-items: center; justify-content: center;
+ background: rgba(4, 3, 9, 0.78); backdrop-filter: blur(6px);
+}
+.modal.open { display: flex; }
+.modal-card {
+ width: min(620px, 92vw); max-height: 86vh; overflow-y: auto;
+ background: linear-gradient(160deg, #17122a 0%, var(--panel-solid) 60%);
+ border: 1px solid var(--edge-strong); border-radius: 6px;
+ padding: 30px 34px; box-shadow: 0 30px 90px rgba(0, 0, 0, 0.7), 0 0 60px rgba(139, 92, 246, 0.12);
+ animation: rise 0.24s ease;
+}
+@keyframes rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
+.modal-card[data-kind='crisis'] { border-color: rgba(255, 84, 112, 0.5); }
+.modal-eyebrow { font-size: 10px; letter-spacing: 0.34em; text-transform: uppercase; color: var(--ember); }
+.modal-title { font-family: 'Cinzel', serif; font-size: 30px; letter-spacing: 0.06em; margin: 6px 0 12px; color: var(--gold-bright); }
+.modal-body { font-size: 12.5px; line-height: 1.75; color: #cfc6e6; margin-bottom: 12px; }
+.modal-choices { display: flex; flex-direction: column; gap: 10px; margin-top: 18px; }
+.choice {
+ display: flex; flex-direction: column; gap: 3px; text-align: left; cursor: pointer;
+ background: rgba(255, 255, 255, 0.04); border: 1px solid var(--edge);
+ border-left: 3px solid var(--gold); border-radius: 4px; padding: 12px 14px;
+ color: var(--ink); font-family: inherit; transition: all 0.12s ease;
+}
+.choice:hover:not(:disabled) { background: rgba(139, 92, 246, 0.18); transform: translateX(3px); }
+.choice:disabled { opacity: 0.4; cursor: not-allowed; }
+.choice-label { font-size: 13px; font-weight: 500; letter-spacing: 0.04em; }
+.choice-detail { font-size: 11px; color: var(--dim); line-height: 1.5; }
+.choice-cost { font-size: 10px; color: var(--gold); letter-spacing: 0.08em; }
+.modal-actions { display: flex; gap: 10px; margin-top: 22px; flex-wrap: wrap; }
+
+/* ── Toasts + telemetry ───────────────────────────────────────────────── */
+.toasts { position: absolute; bottom: 66px; left: 50%; transform: translateX(-50%); z-index: 15; display: flex; flex-direction: column; gap: 6px; align-items: center; pointer-events: none; }
+.toast {
+ background: rgba(14, 11, 24, 0.94); border: 1px solid var(--edge); border-left: 3px solid var(--bad);
+ border-radius: 3px; padding: 7px 14px; font-size: 11px; letter-spacing: 0.04em;
+ animation: rise 0.16s ease; white-space: nowrap;
+}
+.toast[data-tone='good'] { border-left-color: var(--good); }
+.toast[data-tone='info'] { border-left-color: var(--violet); }
+.toast.leaving { opacity: 0; transition: opacity 0.25s ease; }
+
+.telemetry {
+ position: absolute; right: calc(var(--panel-w) + 12px); top: 84px; z-index: 12;
+ background: rgba(8, 6, 15, 0.9); border: 1px solid var(--edge); border-radius: 3px;
+ padding: 8px 10px; font-size: 10px; line-height: 1.6; color: var(--mint); pointer-events: none;
+}
+.telemetry.hidden { display: none; }
+
+/* ── Narrow screens ───────────────────────────────────────────────────── */
+@media (max-width: 1180px) {
+ :root { --panel-w: 218px; }
+ .hud-stat { min-width: 96px; padding: 2px 10px; }
+ .hs-value { font-size: 14px; }
+}
+@media (max-width: 900px) {
+ .panel.right { display: none; }
+ .hud { overflow-x: auto; }
+}
+
+/* ── HUD alerts ───────────────────────────────────────────────────────── */
+.hud-alerts { display: flex; align-items: center; gap: 6px; padding-left: 16px; flex-wrap: wrap; max-width: 260px; }
+.alert-chip {
+ font-size: 9px; letter-spacing: 0.16em; padding: 3px 7px; border-radius: 2px;
+ border: 1px solid currentColor; white-space: nowrap;
+ animation: pulse 1.8s ease-in-out infinite;
+}
+.alert-chip[data-tone='bad'] { color: var(--bad); background: rgba(255, 84, 112, 0.1); }
+.alert-chip[data-tone='warn'] { color: var(--warn); background: rgba(255, 179, 71, 0.1); animation: none; }
+@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
diff --git a/games/gridlock/tests/balance.test.mjs b/games/gridlock/tests/balance.test.mjs
new file mode 100644
index 0000000..b8fdda2
--- /dev/null
+++ b/games/gridlock/tests/balance.test.mjs
@@ -0,0 +1,158 @@
+/**
+ * Balance regression. Not unit tests — these build a fixed district and assert
+ * the shape of the curve it produces over a decade. If a tuning change makes a
+ * well-built city stall, starve, or go broke, this is what catches it.
+ *
+ * The fixtures are hand-placed rather than played by a scripted agent: the
+ * point is to pin the economy, not to benchmark an AI.
+ */
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { place } from '../src/core/commands.js';
+import { tickMonth } from '../src/core/simulate.js';
+import { TERRAIN } from '../src/core/grid.js';
+import { newCity, centre, flatten, tile } from './helpers.mjs';
+
+/** Build the given [dx, dy, id] offsets, asserting every one lands. */
+const build = (state, entries) => {
+ const { x, y } = centre(state);
+ let next = state;
+ for (const [dx, dy, id] of entries) {
+ const result = place(next, x + dx, y + dy, id);
+ assert.equal(result.ok, true, `fixture failed at ${dx},${dy} (${id}): ${result.reason}`);
+ next = result.state;
+ }
+ return next;
+};
+
+const run = (state, months) => {
+ let next = state;
+ for (let i = 0; i < months; i++) {
+ next = tickMonth(next);
+ // Fixtures are about the economy, so crises are held off rather than
+ // resolved — the crisis engine has its own tests.
+ next = { ...next, crisis: { ...next.crisis, active: null, cooldownUntil: next.month + 24 } };
+ }
+ return next;
+};
+
+/**
+ * A balanced era-1 district: twelve habitat blocks, six market rows, services,
+ * four solar arrays, and two shoreline pumps, all on a road grid.
+ */
+const district = () => {
+ const state = flatten(newCity(60000));
+ const { x, y } = centre(state);
+ // Carve a small bay to the south so the pumps have a shoreline.
+ for (let d = -2; d <= 2; d++) tile(state, x + d, y + 7).t = TERRAIN.WATER;
+
+ // Two arterials plus two cross streets. Deduped, and tiles the founding
+ // stub already paved are skipped so the fixture never fights itself.
+ const wanted = new Map();
+ for (let d = -5; d <= 5; d++) {
+ wanted.set(`${d},0`, [d, 0]);
+ wanted.set(`0,${d}`, [0, d]);
+ }
+ for (let d = -4; d <= 4; d++) {
+ wanted.set(`${d},3`, [d, 3]);
+ wanted.set(`${d},-3`, [d, -3]);
+ }
+ const roads = [...wanted.values()]
+ .filter(([dx, dy]) => tile(state, x + dx, y + dy).b === null)
+ .map(([dx, dy]) => [dx, dy, 'road']);
+
+ return build(state, [
+ ...roads,
+ // Housing along both cross streets.
+ [-4, 2, 'hab'], [-3, 2, 'hab'], [-2, 2, 'hab'], [-1, 2, 'hab'],
+ [1, 2, 'hab'], [2, 2, 'hab'], [3, 2, 'hab'], [4, 2, 'hab'],
+ [-4, -2, 'hab'], [-3, -2, 'hab'], [3, -2, 'hab'], [4, -2, 'hab'],
+ // Commerce facing the housing, which is where Foot Traffic pays.
+ [-2, -2, 'market'], [-1, -2, 'market'], [1, -2, 'market'], [2, -2, 'market'],
+ [-4, 4, 'market'], [-3, 4, 'market'],
+ // Services and greenery.
+ [1, 4, 'clinic'], [2, 4, 'academy'], [3, 4, 'security'], [4, 4, 'park'],
+ // Utilities, off the road grid.
+ [-7, 0, 'solar'], [-7, 1, 'solar'], [-8, 0, 'solar'], [-8, 1, 'solar'],
+ [-1, 6, 'pump'], [1, 6, 'pump'],
+ ]);
+};
+
+test('the fixture district starts fully serviced', () => {
+ const state = run(district(), 1);
+ assert.equal(state.stats.power.ratio, 1, 'power must be in surplus');
+ assert.equal(state.stats.water.ratio, 1, 'water must be in surplus');
+ assert.equal(state.stats.stranded, 0, 'every structure must reach the Nexus');
+});
+
+test('a balanced district fills up over a decade', () => {
+ const state = run(district(), 120);
+ const capacity = state.stats.capacity;
+ assert.ok(capacity > 300, `expected real housing capacity, got ${Math.round(capacity)}`);
+ // Migration targets a share of capacity scaled by approval — a healthy city
+ // should settle in the upper half of what it built.
+ assert.ok(
+ state.population > capacity * 0.6,
+ `population ${Math.round(state.population)} against capacity ${Math.round(capacity)}`,
+ );
+ assert.ok(state.approval > 55, `approval settled at ${Math.round(state.approval)}`);
+});
+
+test('a balanced district pays for itself', () => {
+ const start = district();
+ const state = run(start, 120);
+ assert.equal(state.bankrupt, false);
+ assert.ok(state.stats.net > 0, `net was ${Math.round(state.stats.net)}/mo at year 10`);
+ assert.ok(state.credits > start.credits, 'a working city should accumulate reserves');
+});
+
+test('academies bank enough research to reach the next era', () => {
+ const state = run(district(), 120);
+ assert.ok(state.tech > 100, `only ${Math.round(state.tech)} research in ten years`);
+});
+
+test('a clean district does not overheat', () => {
+ const state = run(district(), 120);
+ assert.ok(state.heat < 30, `heat drifted to ${Math.round(state.heat)} with no dirty industry`);
+});
+
+test('swapping in dirty industry drives the heat index up', () => {
+ let state = district();
+ // Fabricators face the southern street; generators sit off the grid entirely.
+ state = build(state, [[-1, 4, 'fab'], [-2, 4, 'fab'], [6, 1, 'diesel'], [6, 2, 'diesel']]);
+ const dirty = run(state, 120);
+ const clean = run(district(), 120);
+ assert.ok(dirty.heat > clean.heat + 15, `dirty ${Math.round(dirty.heat)} vs clean ${Math.round(clean.heat)}`);
+});
+
+test('cutting the road to a district takes it offline', () => {
+ const state = run(district(), 24);
+ const { x, y } = centre(state);
+ const severed = { ...state, grid: { ...state.grid, tiles: state.grid.tiles.map((t) => ({ ...t })) } };
+ // Remove the entire southern cross street.
+ for (let d = -4; d <= 4; d++) {
+ const target = severed.grid.tiles[(y + 3) * severed.grid.w + (x + d)];
+ if (target.b === 'road') target.b = null;
+ }
+ const after = run(severed, 1);
+ assert.ok(after.stats.stranded > 0, 'severed structures must report as cut off');
+ assert.ok(after.stats.capacity < state.stats.capacity, 'stranded housing must stop counting');
+});
+
+test('doing nothing for 20 years is survivable but goes nowhere', () => {
+ let state = newCity(30000);
+ for (let i = 0; i < 240; i++) state = tickMonth(state);
+ assert.ok(state.population < 25, 'an unbuilt city must not grow on its own');
+ assert.ok(state.credits > 0, 'the starting grant must outlast an idle player');
+});
+
+test('the simulation stays fast enough for 3× speed', () => {
+ const state = run(district(), 24);
+ const started = performance.now();
+ for (let i = 0; i < 50; i++) tickMonth(state);
+ const perTick = (performance.now() - started) / 50;
+ // 3× speed ticks every 380ms; anything near that budget would stutter.
+ assert.ok(perTick < 20, `tick cost ${perTick.toFixed(2)}ms on a built-out city`);
+});
diff --git a/games/gridlock/tests/commands.test.mjs b/games/gridlock/tests/commands.test.mjs
new file mode 100644
index 0000000..daaf7ea
--- /dev/null
+++ b/games/gridlock/tests/commands.test.mjs
@@ -0,0 +1,90 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { canPlace, demolish, place, setSpeed, setTax, REFUND_RATE } from '../src/core/commands.js';
+import { getBuilding } from '../src/config/buildings.js';
+import { TERRAIN } from '../src/core/grid.js';
+import { TAX } from '../src/config/balance.js';
+import { newCity, centre, tile, flatten } from './helpers.mjs';
+
+test('placing deducts the cost and marks the tile', () => {
+ const state = newCity();
+ const { x, y } = centre(state);
+ const result = place(state, x + 1, y + 1, 'hab');
+ assert.equal(result.ok, true);
+ assert.equal(tile(result.state, x + 1, y + 1).b, 'hab');
+ assert.equal(result.state.credits, state.credits - getBuilding('hab').cost);
+});
+
+test('commands never mutate the state they were given', () => {
+ const state = newCity();
+ const { x, y } = centre(state);
+ const before = state.credits;
+ place(state, x + 1, y + 1, 'hab');
+ assert.equal(state.credits, before);
+ assert.equal(tile(state, x + 1, y + 1).b, null);
+});
+
+test('occupied tiles are refused', () => {
+ const state = newCity();
+ const { x, y } = centre(state);
+ const first = place(state, x + 1, y + 1, 'hab').state;
+ assert.equal(canPlace(first, x + 1, y + 1, 'hab').ok, false);
+});
+
+test('water and ridges block the wrong structures', () => {
+ const state = newCity();
+ const { x, y } = centre(state);
+ tile(state, x + 3, y).t = TERRAIN.WATER;
+ tile(state, x + 4, y).t = TERRAIN.HILL;
+ assert.equal(canPlace(state, x + 3, y, 'hab').reason, 'Cannot build on water.');
+ assert.equal(canPlace(state, x + 4, y, 'hab').reason, 'Ridge is too steep.');
+ assert.equal(canPlace(state, x + 4, y, 'road').ok, true, 'roads may cross a ridge');
+});
+
+test('shoreline structures require adjacent water', () => {
+ const state = flatten(newCity());
+ const { x, y } = centre(state);
+ assert.equal(canPlace(state, x + 2, y + 2, 'pump').ok, false);
+ tile(state, x + 2, y + 3).t = TERRAIN.WATER;
+ assert.equal(canPlace(state, x + 2, y + 2, 'pump').ok, true);
+});
+
+test('geothermal must be sunk into a ridge', () => {
+ const state = flatten(newCity());
+ const { x, y } = centre(state);
+ state.era = 2;
+ assert.equal(canPlace(state, x + 2, y + 2, 'geo').ok, false);
+ tile(state, x + 2, y + 2).t = TERRAIN.HILL;
+ assert.equal(canPlace(state, x + 2, y + 2, 'geo').ok, true);
+});
+
+test('era locks and affordability are enforced', () => {
+ const state = newCity(100);
+ const { x, y } = centre(state);
+ assert.equal(canPlace(state, x + 1, y + 1, 'spire').reason, 'Locked until Era 3.');
+ assert.equal(canPlace(state, x + 1, y + 1, 'hab').reason, 'Not enough credits.');
+});
+
+test('demolishing refunds a share and cannot remove the Nexus', () => {
+ const state = newCity();
+ const { x, y } = centre(state);
+ const built = place(state, x + 1, y + 1, 'fab').state;
+ const razed = demolish(built, x + 1, y + 1);
+ assert.equal(razed.ok, true);
+ assert.equal(tile(razed.state, x + 1, y + 1).b, null);
+ assert.equal(
+ razed.state.credits,
+ built.credits + Math.round(getBuilding('fab').cost * REFUND_RATE),
+ );
+ assert.equal(demolish(built, x, y).reason, 'The Nexus cannot be removed.');
+ assert.equal(demolish(built, x + 6, y + 6).reason, 'Nothing here.');
+});
+
+test('tax and speed settings are clamped', () => {
+ const state = newCity();
+ assert.equal(setTax(state, 5).taxRate, TAX.max);
+ assert.equal(setTax(state, -1).taxRate, TAX.min);
+ assert.equal(setSpeed(state, 9).speed, 3);
+ assert.equal(setSpeed(state, -4).speed, 0);
+});
diff --git a/games/gridlock/tests/conditions.test.mjs b/games/gridlock/tests/conditions.test.mjs
new file mode 100644
index 0000000..1737727
--- /dev/null
+++ b/games/gridlock/tests/conditions.test.mjs
@@ -0,0 +1,60 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { CRISIS_DECK } from '../src/config/events.js';
+import { computeStats } from '../src/core/stats.js';
+import { drawCrisis } from '../src/core/events.js';
+import { EVENTS } from '../src/config/balance.js';
+import { newCity } from './helpers.mjs';
+
+const fire = (id, state, stats) => CRISIS_DECK.find((c) => c.id === id).when(state, stats);
+
+test('a comfortably over-supplied grid does not raise a surge', () => {
+ const state = newCity();
+ const stats = { ...computeStats(state), counts: { power: 2 } };
+ stats.power = { supply: 400, demand: 100, ratio: 1 };
+ assert.equal(fire('grid-surge', state, stats), false);
+});
+
+test('a grid running at the margin does raise a surge', () => {
+ const state = newCity();
+ const stats = { ...computeStats(state), counts: { power: 2 } };
+ stats.power = { supply: 104, demand: 100, ratio: 1 };
+ assert.equal(fire('grid-surge', state, stats), true);
+});
+
+test('an idle grid with no draw never raises a surge', () => {
+ const state = newCity();
+ const stats = { ...computeStats(state), counts: { power: 1 } };
+ stats.power = { supply: 60, demand: 0, ratio: 1 };
+ assert.equal(fire('grid-surge', state, stats), false);
+});
+
+test('no condition depends on the clamped utility ratio being above 1', () => {
+ // power.ratio and water.ratio are min(1, supply/demand): a condition of the
+ // form `ratio < k` for k > 1 is always true and would fire every month.
+ const source = CRISIS_DECK.map((c) => c.when.toString()).join('\n');
+ assert.equal(/ratio\s*<\s*(1\.\d+|[2-9])/.test(source), false);
+});
+
+test('crises never fire during the grace period', () => {
+ const state = newCity();
+ state.month = EVENTS.graceMonths - 1;
+ const stats = computeStats(state);
+ for (let i = 0; i < 200; i++) assert.equal(drawCrisis(state, stats), null);
+});
+
+test('crises respect the cooldown after one resolves', () => {
+ const state = newCity();
+ state.month = 40;
+ state.crisis = { active: null, cooldownUntil: 50, seen: [] };
+ const stats = computeStats(state);
+ for (let i = 0; i < 200; i++) assert.equal(drawCrisis(state, stats), null);
+});
+
+test('only one crisis is open at a time', () => {
+ const state = newCity();
+ state.month = 40;
+ state.crisis = { active: 'heatwave', cooldownUntil: 0, seen: [] };
+ assert.equal(drawCrisis(state, computeStats(state)), null);
+});
diff --git a/games/gridlock/tests/directives.test.mjs b/games/gridlock/tests/directives.test.mjs
new file mode 100644
index 0000000..7f759a1
--- /dev/null
+++ b/games/gridlock/tests/directives.test.mjs
@@ -0,0 +1,88 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { DIRECTIVES, MAX_ACTIVE_DIRECTIVES, directiveById } from '../src/config/directives.js';
+import { directiveProgress, evaluateDirectives, refillDirectives } from '../src/core/directives.js';
+import { computeStats } from '../src/core/stats.js';
+import { newCity } from './helpers.mjs';
+
+const withStats = (mutate = () => {}) => {
+ const state = newCity();
+ mutate(state);
+ return { state, stats: computeStats(state) };
+};
+
+test('the queue fills to the active limit and no further', () => {
+ const { state } = withStats();
+ refillDirectives(state);
+ assert.equal(state.directives.active.length, MAX_ACTIVE_DIRECTIVES);
+ refillDirectives(state);
+ assert.equal(state.directives.active.length, MAX_ACTIVE_DIRECTIVES);
+});
+
+test('completing a directive pays out and pulls in the next one', () => {
+ const { state, stats } = withStats((s) => { s.population = 400; });
+ const credits = state.credits;
+ evaluateDirectives(state, stats);
+ assert.ok(state.directives.done.includes('first-roots'));
+ assert.equal(state.credits, credits + directiveById('first-roots').reward.credits);
+ assert.equal(state.directives.active.length, MAX_ACTIVE_DIRECTIVES);
+ assert.equal(state.directives.active.includes('first-roots'), false);
+});
+
+test('streak directives require consecutive months and reset on a break', () => {
+ const { state, stats } = withStats((s) => { s.population = 400; });
+ state.directives.active = ['keep-the-lights-on'];
+ const held = { ...stats, power: { ratio: 1 }, water: { ratio: 1 } };
+
+ for (let i = 0; i < 3; i++) evaluateDirectives(state, held);
+ assert.equal(state.directives.progress['keep-the-lights-on'], 3);
+
+ evaluateDirectives(state, { ...held, power: { ratio: 0.4 } });
+ assert.equal(state.directives.progress['keep-the-lights-on'], 0, 'a blackout resets the streak');
+
+ for (let i = 0; i < 6; i++) evaluateDirectives(state, held);
+ assert.ok(state.directives.done.includes('keep-the-lights-on'));
+});
+
+test('progress is reported as current/goal and never exceeds the goal', () => {
+ const { state, stats } = withStats((s) => { s.population = 9999; });
+ const [current, goal] = directiveProgress(directiveById('first-roots'), state, stats);
+ assert.equal(goal, 250);
+ assert.equal(current, 250, 'progress is clamped for the bar');
+});
+
+test('the victory directive flags a win once Zero Point is running', () => {
+ const state = newCity();
+ const cx = Math.floor(state.grid.w / 2);
+ const cy = Math.floor(state.grid.h / 2);
+ const at = (x, y, id) => { state.grid.tiles[y * state.grid.w + x].b = id; };
+ at(cx + 1, cy + 1, 'zeropoint'); // beside the starting road stub
+ for (let i = 0; i < 3; i++) at(cx + 5 + i, cy + 5, 'solar'); // enough generation
+
+ state.directives.active = ['zero-point'];
+ evaluateDirectives(state, computeStats(state));
+ assert.equal(state.won, true);
+});
+
+test('an unpowered Zero Point does not win the game', () => {
+ const state = newCity();
+ const cx = Math.floor(state.grid.w / 2);
+ const cy = Math.floor(state.grid.h / 2);
+ state.grid.tiles[(cy + 1) * state.grid.w + (cx + 1)].b = 'zeropoint';
+
+ state.directives.active = ['zero-point'];
+ evaluateDirectives(state, computeStats(state));
+ assert.equal(state.won, false, 'the Nexus alone cannot carry a 140MW draw');
+});
+
+test('every directive is measurable and uniquely named', () => {
+ const ids = new Set();
+ for (const directive of DIRECTIVES) {
+ assert.ok(!ids.has(directive.id), `duplicate directive: ${directive.id}`);
+ ids.add(directive.id);
+ assert.ok(directive.title && directive.detail);
+ assert.ok(directive.test || directive.holds, `${directive.id} has no completion condition`);
+ if (directive.streak) assert.equal(typeof directive.holds, 'function');
+ }
+});
diff --git a/games/gridlock/tests/events.test.mjs b/games/gridlock/tests/events.test.mjs
new file mode 100644
index 0000000..febb3b9
--- /dev/null
+++ b/games/gridlock/tests/events.test.mjs
@@ -0,0 +1,104 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { getCrisis, nextRoll, resolveCrisis } from '../src/core/events.js';
+import { computeStats } from '../src/core/stats.js';
+import { CRISIS_DECK } from '../src/config/events.js';
+import { newCity, centre, forceTile } from './helpers.mjs';
+
+const withCrisis = (id, credits = 50000) => {
+ const state = newCity(credits);
+ state.crisis = { ...state.crisis, active: id };
+ return state;
+};
+
+test('every deck entry is well formed', () => {
+ const ids = new Set();
+ for (const card of CRISIS_DECK) {
+ assert.ok(card.id && !ids.has(card.id), `duplicate or missing id: ${card.id}`);
+ ids.add(card.id);
+ assert.equal(typeof card.when, 'function');
+ assert.ok(card.choices.length >= 2, `${card.id} needs a real trade-off`);
+ for (const choice of card.choices) {
+ assert.ok(choice.label && choice.detail, `${card.id} choice needs copy`);
+ assert.ok(Array.isArray(choice.effects));
+ }
+ }
+});
+
+test('resolving applies credits, approval, and a timed modifier', () => {
+ const state = withCrisis('grid-surge');
+ const stats = computeStats(state);
+ const result = resolveCrisis(state, stats, 0); // shed load
+ assert.equal(result.ok, true);
+ assert.ok(result.state.approval < state.approval);
+ assert.equal(result.state.modifiers.length, 1);
+ assert.equal(result.state.modifiers[0].id, 'surge-shed');
+ assert.equal(result.state.crisis.active, null);
+ assert.ok(result.state.crisis.seen.includes('grid-surge'));
+});
+
+test('resolving never mutates the state it was given', () => {
+ const state = withCrisis('grid-surge');
+ const snapshot = JSON.stringify({ ...state, stats: null });
+ resolveCrisis(state, computeStats(state), 0);
+ assert.equal(JSON.stringify({ ...state, stats: null }), snapshot);
+});
+
+test('a choice the treasury cannot cover is refused', () => {
+ const state = withCrisis('breach', 100);
+ const result = resolveCrisis(state, computeStats(state), 0); // 6,500 ransom
+ assert.equal(result.ok, false);
+ assert.equal(result.reason, 'Not enough credits.');
+ assert.equal(result.state.crisis.active, 'breach', 'the crisis stays open');
+});
+
+test('costs are deducted exactly once', () => {
+ const state = withCrisis('breach');
+ const result = resolveCrisis(state, computeStats(state), 0);
+ assert.equal(result.state.credits, state.credits - 6500);
+});
+
+test('unknown crises and options fail closed', () => {
+ const state = withCrisis('grid-surge');
+ assert.equal(resolveCrisis(state, computeStats(state), 99).ok, false);
+ assert.equal(resolveCrisis(newCity(), computeStats(newCity()), 0).ok, false);
+});
+
+test('risk outcomes are seeded, not random', () => {
+ const a = withCrisis('tremor');
+ const b = withCrisis('tremor');
+ const first = resolveCrisis(a, computeStats(a), 1).state;
+ const second = resolveCrisis(b, computeStats(b), 1).state;
+ assert.equal(first.rollCount, second.rollCount);
+ assert.equal(first.approval, second.approval);
+});
+
+test('destruction never takes the Nexus', () => {
+ const state = withCrisis('tremor');
+ const { x, y } = centre(state);
+ forceTile(state, x + 5, y + 5, 'fab');
+ // Force the destructive branch by exhausting the risk roll many times.
+ for (let i = 0; i < 40; i++) {
+ const attempt = resolveCrisis({ ...state, rollCount: i }, computeStats(state), 1);
+ const nexus = attempt.state.grid.tiles.find((t) => t.b === 'nexus');
+ assert.ok(nexus, 'the Nexus must always survive');
+ }
+});
+
+test('the roll stream advances deterministically', () => {
+ const a = newCity();
+ const b = newCity();
+ assert.equal(nextRoll(a), nextRoll(b));
+ assert.equal(a.rollCount, 1);
+ assert.notEqual(nextRoll(a), nextRoll({ ...b, rollCount: 5 }));
+});
+
+test('deck conditions never throw on a fresh city', () => {
+ const state = newCity();
+ const stats = computeStats(state);
+ for (const card of CRISIS_DECK) {
+ assert.doesNotThrow(() => card.when(state, stats), `${card.id} condition threw`);
+ }
+ assert.ok(getCrisis('heatwave'));
+});
diff --git a/games/gridlock/tests/grid.test.mjs b/games/gridlock/tests/grid.test.mjs
new file mode 100644
index 0000000..81bdddf
--- /dev/null
+++ b/games/gridlock/tests/grid.test.mjs
@@ -0,0 +1,69 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { createGrid, TERRAIN, neighbors4, neighbors8, touchesTerrain, idx } from '../src/core/grid.js';
+import { makeRng, weightedPick } from '../src/core/rng.js';
+
+test('terrain generation is deterministic for a seed', () => {
+ const a = createGrid(1234);
+ const b = createGrid(1234);
+ assert.deepEqual(a.tiles.map((t) => t.t), b.tiles.map((t) => t.t));
+});
+
+test('different seeds produce different maps', () => {
+ const a = createGrid(1).tiles.map((t) => t.t).join('');
+ const b = createGrid(2).tiles.map((t) => t.t).join('');
+ assert.notEqual(a, b);
+});
+
+test('the founding centre is always buildable land', () => {
+ for (const seed of [1, 77, 4242, 999999]) {
+ const grid = createGrid(seed);
+ const cx = Math.floor(grid.w / 2);
+ const cy = Math.floor(grid.h / 2);
+ for (let dy = -2; dy <= 2; dy++) {
+ for (let dx = -2; dx <= 2; dx++) {
+ assert.equal(grid.tiles[idx(grid, cx + dx, cy + dy)].t, TERRAIN.LAND, `seed ${seed}`);
+ }
+ }
+ }
+});
+
+test('every map has land, and water is generated for coastal builds', () => {
+ const grid = createGrid(4242);
+ const kinds = new Set(grid.tiles.map((t) => t.t));
+ assert.ok(kinds.has(TERRAIN.LAND));
+ assert.ok(kinds.has(TERRAIN.WATER), 'expected a bay on this seed');
+});
+
+test('neighbour queries clip at the map edge', () => {
+ const grid = createGrid(7);
+ assert.equal(neighbors4(grid, 0, 0).length, 2);
+ assert.equal(neighbors8(grid, 0, 0).length, 3);
+ assert.equal(neighbors4(grid, 5, 5).length, 4);
+ assert.equal(neighbors8(grid, 5, 5).length, 8);
+});
+
+test('touchesTerrain only looks orthogonally', () => {
+ const grid = createGrid(7);
+ grid.tiles.forEach((t) => { t.t = TERRAIN.LAND; });
+ grid.tiles[idx(grid, 4, 3)].t = TERRAIN.WATER;
+ assert.equal(touchesTerrain(grid, 4, 4, TERRAIN.WATER), true);
+ assert.equal(touchesTerrain(grid, 5, 4, TERRAIN.WATER), false, 'diagonal must not count');
+});
+
+test('rng is reproducible and bounded', () => {
+ const a = makeRng(99);
+ const b = makeRng(99);
+ for (let i = 0; i < 50; i++) {
+ const value = a();
+ assert.equal(value, b());
+ assert.ok(value >= 0 && value < 1);
+ }
+});
+
+test('weightedPick respects weights and handles a zero-weight set', () => {
+ const items = [{ w: 0 }, { w: 10 }];
+ assert.equal(weightedPick(items, (i) => i.w, 0.5), items[1]);
+ assert.equal(weightedPick(items, () => 0, 0.5), null);
+});
diff --git a/games/gridlock/tests/helpers.mjs b/games/gridlock/tests/helpers.mjs
new file mode 100644
index 0000000..5d92596
--- /dev/null
+++ b/games/gridlock/tests/helpers.mjs
@@ -0,0 +1,47 @@
+import { createInitialState } from '../src/core/state.js';
+import { idx, TERRAIN } from '../src/core/grid.js';
+import { place } from '../src/core/commands.js';
+import { refreshStats } from '../src/core/simulate.js';
+
+export const FIXED_SEED = 20870101;
+
+/** A deterministic starting city for every test. */
+export const newCity = (credits = 1e6) => {
+ const state = createInitialState(FIXED_SEED);
+ state.credits = credits;
+ return state;
+};
+
+/**
+ * Flatten the whole map to buildable land. Terrain is seeded, so any test
+ * about placement rules must remove terrain luck first.
+ */
+export const flatten = (state) => {
+ state.grid.tiles.forEach((t) => { t.t = TERRAIN.LAND; });
+ return state;
+};
+
+export const centre = (state) => ({
+ x: Math.floor(state.grid.w / 2),
+ y: Math.floor(state.grid.h / 2),
+});
+
+export const tile = (state, x, y) => state.grid.tiles[idx(state.grid, x, y)];
+
+/** Force a tile's contents, bypassing validation — for building fixtures. */
+export const forceTile = (state, x, y, buildingId) => {
+ tile(state, x, y).b = buildingId;
+ return state;
+};
+
+/** Place a list of [dx, dy, id] offsets relative to the Nexus. */
+export const placeAll = (state, entries) => {
+ const { x, y } = centre(state);
+ let next = state;
+ for (const [dx, dy, id] of entries) {
+ const result = place(next, x + dx, y + dy, id);
+ if (!result.ok) throw new Error(`fixture placement failed at ${dx},${dy} (${id}): ${result.reason}`);
+ next = result.state;
+ }
+ return refreshStats(next);
+};
diff --git a/games/gridlock/tests/iso.test.mjs b/games/gridlock/tests/iso.test.mjs
new file mode 100644
index 0000000..983782a
--- /dev/null
+++ b/games/gridlock/tests/iso.test.mjs
@@ -0,0 +1,50 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { createGrid } from '../src/core/grid.js';
+import { clampCamera, createCamera, pickTile, screenToTile, tileToScreen } from '../src/render/iso.js';
+
+const view = { width: 1200, height: 800 };
+
+test('screen projection round-trips back to the same tile', () => {
+ const grid = createGrid(11);
+ const cam = createCamera(grid);
+ for (const [x, y] of [[0, 0], [5, 12], [31, 31], [17, 3]]) {
+ const { sx, sy } = tileToScreen(x, y, cam, view);
+ const back = screenToTile(sx, sy, cam, view);
+ assert.ok(Math.abs(back.x - x) < 1e-9, `x ${back.x} != ${x}`);
+ assert.ok(Math.abs(back.y - y) < 1e-9, `y ${back.y} != ${y}`);
+ }
+});
+
+test('round-trip holds under zoom and pan', () => {
+ const grid = createGrid(11);
+ const cam = { ...createCamera(grid), zoom: 1.9, panX: -120, panY: 64 };
+ const { sx, sy } = tileToScreen(9, 4, cam, view);
+ assert.deepEqual(pickTile(sx, sy, cam, view, grid), { x: 9, y: 4 });
+});
+
+test('picking snaps to the nearest tile centre, not the floor', () => {
+ const grid = createGrid(11);
+ const cam = createCamera(grid);
+ const { sx, sy } = tileToScreen(6, 6, cam, view);
+ // A few pixels off-centre must still resolve to the same tile.
+ assert.deepEqual(pickTile(sx + 6, sy + 2, cam, view, grid), { x: 6, y: 6 });
+});
+
+test('picking off the grid returns null', () => {
+ const grid = createGrid(11);
+ const cam = createCamera(grid);
+ assert.equal(pickTile(-5000, -5000, cam, view, grid), null);
+});
+
+test('camera zoom is clamped to its configured bounds', () => {
+ const grid = createGrid(11);
+ const cam = createCamera(grid);
+ cam.zoom = 99;
+ clampCamera(cam, grid);
+ assert.equal(cam.zoom, cam.maxZoom);
+ cam.zoom = 0.001;
+ clampCamera(cam, grid);
+ assert.equal(cam.zoom, cam.minZoom);
+});
diff --git a/games/gridlock/tests/network.test.mjs b/games/gridlock/tests/network.test.mjs
new file mode 100644
index 0000000..96ebe13
--- /dev/null
+++ b/games/gridlock/tests/network.test.mjs
@@ -0,0 +1,74 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { computeNetwork } from '../src/core/network.js';
+import { ration, POWER_PRIORITY } from '../src/core/rationing.js';
+import { idx } from '../src/core/grid.js';
+import { newCity, centre, forceTile } from './helpers.mjs';
+
+test('the starting road stub is wired to the Nexus', () => {
+ const state = newCity();
+ const { x, y } = centre(state);
+ const net = computeNetwork(state.grid);
+ assert.ok(net.roads.has(idx(state.grid, x + 1, y)));
+ assert.ok(net.roads.has(idx(state.grid, x + 2, y)));
+ assert.equal(net.roadCount, 6);
+});
+
+test('a structure touching a live road is connected', () => {
+ const state = newCity();
+ const { x, y } = centre(state);
+ forceTile(state, x + 1, y + 1, 'hab'); // orthogonal to the road at (x+1, y)
+ const net = computeNetwork(state.grid);
+ assert.ok(net.connected.has(idx(state.grid, x + 1, y + 1)));
+});
+
+test('a structure on an orphan road island is not connected', () => {
+ const state = newCity();
+ const { x, y } = centre(state);
+ forceTile(state, x + 8, y + 8, 'road');
+ forceTile(state, x + 8, y + 9, 'hab');
+ const net = computeNetwork(state.grid);
+ assert.equal(net.roads.has(idx(state.grid, x + 8, y + 8)), false);
+ assert.equal(net.connected.has(idx(state.grid, x + 8, y + 9)), false);
+});
+
+test('utilities and parks run without a road', () => {
+ const state = newCity();
+ const { x, y } = centre(state);
+ forceTile(state, x + 9, y + 9, 'solar');
+ forceTile(state, x + 9, y + 10, 'park');
+ const net = computeNetwork(state.grid);
+ assert.ok(net.connected.has(idx(state.grid, x + 9, y + 9)));
+ assert.ok(net.connected.has(idx(state.grid, x + 9, y + 10)));
+});
+
+test('extending the road graph reaches further structures', () => {
+ const state = newCity();
+ const { x, y } = centre(state);
+ for (let i = 3; i <= 6; i++) forceTile(state, x + i, y, 'road');
+ forceTile(state, x + 6, y + 1, 'hab');
+ const net = computeNetwork(state.grid);
+ assert.ok(net.connected.has(idx(state.grid, x + 6, y + 1)));
+});
+
+test('rationing serves by priority then index, and is deterministic', () => {
+ const consumers = [
+ { i: 5, cat: 'industry', draw: 60 },
+ { i: 1, cat: 'residential', draw: 30 },
+ { i: 9, cat: 'civic', draw: 20 },
+ ];
+ const first = ration(consumers, 55, POWER_PRIORITY);
+ const second = ration(consumers, 55, POWER_PRIORITY);
+ assert.deepEqual([...first.served].sort(), [...second.served].sort());
+ assert.ok(first.served.has(9), 'civic is served before residential');
+ assert.ok(first.served.has(1), 'residential fits in the remaining budget');
+ assert.equal(first.served.has(5), false, 'industry browns out first');
+ assert.equal(first.demand, 110);
+});
+
+test('rationing with no consumers reports a healthy ratio', () => {
+ const result = ration([], 0, POWER_PRIORITY);
+ assert.equal(result.ratio, 1);
+ assert.equal(result.demand, 0);
+});
diff --git a/games/gridlock/tests/simulate.test.mjs b/games/gridlock/tests/simulate.test.mjs
new file mode 100644
index 0000000..4f1f66c
--- /dev/null
+++ b/games/gridlock/tests/simulate.test.mjs
@@ -0,0 +1,100 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { tickMonth } from '../src/core/simulate.js';
+import { computeStats } from '../src/core/stats.js';
+import { nextHeat } from '../src/core/economy.js';
+import { TERRAIN } from '../src/core/grid.js';
+import { newCity, centre, placeAll, tile } from './helpers.mjs';
+
+/** A minimal working city: road, housing, jobs, power, and a shoreline pump. */
+const workingCity = () => {
+ const state = newCity();
+ const { x, y } = centre(state);
+ state.grid.tiles.forEach((t) => { t.t = TERRAIN.LAND; }); // flat map, no terrain luck
+ tile(state, x + 3, y + 4).t = TERRAIN.WATER; // shoreline for the pump below
+ return placeAll(state, [
+ [1, 1, 'hab'], [1, -1, 'hab'], [2, 1, 'hab'],
+ [-1, 1, 'market'], [-1, -1, 'market'],
+ [0, 3, 'diesel'], [0, -3, 'solar'],
+ [3, 3, 'pump'],
+ ]);
+};
+
+test('ticking is pure — the input state is untouched', () => {
+ const before = workingCity();
+ const snapshot = JSON.stringify({ ...before, stats: null });
+ tickMonth(before);
+ assert.equal(JSON.stringify({ ...before, stats: null }), snapshot);
+});
+
+test('ticking is deterministic for identical states', () => {
+ const state = workingCity();
+ const a = tickMonth(state);
+ const b = tickMonth(state);
+ assert.equal(
+ JSON.stringify({ ...a, stats: null }),
+ JSON.stringify({ ...b, stats: null }),
+ );
+});
+
+test('a serviced city gains residents', () => {
+ let state = newCity();
+ state.grid.tiles.forEach((t) => { t.t = TERRAIN.LAND; });
+ state.era = 3; // the condenser is the terrain-free water source
+ state = placeAll(state, [
+ [1, 1, 'hab'], [2, 1, 'hab'], [1, -1, 'hab'],
+ [-1, 1, 'market'], [-2, 1, 'market'],
+ [0, 3, 'solar'], [0, 4, 'solar'], [3, 0, 'condenser'],
+ ]);
+ for (let i = 0; i < 12; i++) state = tickMonth(state);
+ assert.ok(state.population > 20, `expected growth, got ${state.population}`);
+ assert.equal(Number.isFinite(state.credits), true);
+});
+
+test('housing beyond the Nexus supply browns out until utilities are built', () => {
+ let state = newCity();
+ state.grid.tiles.forEach((t) => { t.t = TERRAIN.LAND; });
+ // Twelve habitat blocks draw 72MW against the Nexus's own 60MW.
+ state = placeAll(state, [
+ [1, 1, 'hab'], [1, -1, 'hab'], [2, 1, 'hab'], [2, -1, 'hab'],
+ [-1, 1, 'hab'], [-1, -1, 'hab'], [-2, 1, 'hab'], [-2, -1, 'hab'],
+ [3, 0, 'hab'], [-3, 0, 'hab'], [0, 2, 'hab'], [0, -2, 'hab'],
+ ]);
+ state = tickMonth(state);
+ assert.ok(state.stats.power.ratio < 1, 'demand must exceed the Nexus supply');
+ const dark = state.grid.tiles.filter((t) => t.b === 'hab' && !t.on);
+ assert.ok(dark.length > 0, 'some blocks must be unpowered');
+
+ // Adding generation lights the whole district back up.
+ state = placeAll(state, [[0, 4, 'solar'], [0, 5, 'solar']]);
+ state = tickMonth(state);
+ assert.equal(state.stats.power.ratio, 1);
+ assert.equal(state.grid.tiles.filter((t) => t.b === 'hab' && !t.on).length, 0);
+});
+
+test('the treasury moves by exactly the reported net', () => {
+ const state = workingCity();
+ const stats = computeStats(state);
+ const next = tickMonth(state);
+ assert.ok(Math.abs(next.credits - (state.credits + stats.net)) < 1e-9);
+});
+
+test('dirty generation raises the heat index, clean generation does not', () => {
+ const dirty = nextHeat({ heat: 20 }, { heatLoad: 12 });
+ const clean = nextHeat({ heat: 20 }, { heatLoad: 0 });
+ assert.ok(dirty > 20, 'emissions must warm the city');
+ assert.ok(clean < 20, 'with no load the index decays');
+});
+
+test('the heat index is bounded', () => {
+ assert.equal(nextHeat({ heat: 99.9 }, { heatLoad: 5000 }), 100);
+ assert.equal(nextHeat({ heat: 0 }, { heatLoad: 0 }), 0);
+});
+
+test('an empty city stays solvent long enough to build', () => {
+ let state = newCity(30000);
+ for (let i = 0; i < 24; i++) state = tickMonth(state);
+ assert.equal(state.bankrupt, false);
+ assert.equal(state.month, 24);
+});
diff --git a/games/gridlock/tests/synergy.test.mjs b/games/gridlock/tests/synergy.test.mjs
new file mode 100644
index 0000000..cda7c81
--- /dev/null
+++ b/games/gridlock/tests/synergy.test.mjs
@@ -0,0 +1,72 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { previewSynergy, tileSynergy } from '../src/core/synergy.js';
+import { TERRAIN } from '../src/core/grid.js';
+import { newCity, centre, forceTile, tile, flatten } from './helpers.mjs';
+
+test('commerce beside housing earns a revenue multiplier per neighbour', () => {
+ const state = flatten(newCity());
+ const { x, y } = centre(state);
+ forceTile(state, x + 5, y + 5, 'market');
+ forceTile(state, x + 5, y + 4, 'hab');
+ forceTile(state, x + 6, y + 5, 'hab');
+ const syn = tileSynergy(state.grid, tile(state, x + 5, y + 5));
+ assert.ok(Math.abs(syn.revenueMul - 1.16) < 1e-9, `got ${syn.revenueMul}`);
+ assert.equal(syn.hits[0].label, 'Foot Traffic');
+ assert.equal(syn.hits[0].count, 2);
+});
+
+test('synergy counts are capped', () => {
+ const state = flatten(newCity());
+ const { x, y } = centre(state);
+ forceTile(state, x + 5, y + 5, 'market');
+ for (const [dx, dy] of [[0, 1], [0, -1], [1, 0], [-1, 0], [1, 1], [-1, -1]]) {
+ forceTile(state, x + 5 + dx, y + 5 + dy, 'hab');
+ }
+ const syn = tileSynergy(state.grid, tile(state, x + 5, y + 5));
+ assert.equal(syn.hits[0].count, 4, 'Foot Traffic caps at four neighbours');
+ assert.ok(Math.abs(syn.revenueMul - 1.32) < 1e-9);
+});
+
+test('housing beside industry loses appeal', () => {
+ const state = flatten(newCity());
+ const { x, y } = centre(state);
+ forceTile(state, x + 7, y + 7, 'hab');
+ forceTile(state, x + 7, y + 8, 'fab');
+ const syn = tileSynergy(state.grid, tile(state, x + 7, y + 7));
+ assert.ok(syn.appealAdd < 0, `expected a penalty, got ${syn.appealAdd}`);
+});
+
+test('waterfront data centres run cooler', () => {
+ const state = flatten(newCity());
+ const { x, y } = centre(state);
+ tile(state, x + 7, y + 8).t = TERRAIN.WATER;
+ forceTile(state, x + 7, y + 7, 'server');
+ const syn = tileSynergy(state.grid, tile(state, x + 7, y + 7));
+ assert.ok(syn.heatMul < 1, `expected cooling, got ${syn.heatMul}`);
+});
+
+test('a lone structure scores neutral', () => {
+ const state = flatten(newCity());
+ const { x, y } = centre(state);
+ forceTile(state, x + 9, y + 9, 'market');
+ const syn = tileSynergy(state.grid, tile(state, x + 9, y + 9));
+ assert.equal(syn.revenueMul, 1);
+ assert.equal(syn.hits.length, 0);
+});
+
+test('preview matches what the tile would score, without mutating the grid', () => {
+ const state = flatten(newCity());
+ const { x, y } = centre(state);
+ forceTile(state, x + 5, y + 4, 'hab');
+ const preview = previewSynergy(state.grid, x + 5, y + 5, 'market');
+ assert.equal(preview.hits[0].label, 'Foot Traffic');
+ assert.equal(tile(state, x + 5, y + 5).b, null, 'preview must not place anything');
+});
+
+test('empty tiles have no synergy', () => {
+ const state = flatten(newCity());
+ const { x, y } = centre(state);
+ assert.equal(tileSynergy(state.grid, tile(state, x + 9, y + 9)).hits.length, 0);
+});
diff --git a/package.json b/package.json
index 84872db..e624691 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,8 @@
"start": "react-native start",
"test": "jest --passWithNoTests",
"lint": "eslint . --ext .ts,.tsx",
- "type-check": "tsc --noEmit"
+ "type-check": "tsc --noEmit",
+ "test:gridlock": "node --test --test-reporter=spec games/gridlock/tests/*.test.mjs"
},
"dependencies": {
"react": "18.2.0",
@@ -68,4 +69,4 @@
"url": "https://github.com/cloudygetty-ai/run-down.git"
},
"license": "MIT"
-}
\ No newline at end of file
+}
diff --git a/vercel.json b/vercel.json
index b4db9fc..9f066a9 100644
--- a/vercel.json
+++ b/vercel.json
@@ -1,5 +1,12 @@
{
"version": 2,
- "builds": [{ "src": "index.html", "use": "@vercel/static" }],
- "routes": [{ "src": "/(.*)", "dest": "/index.html" }]
+ "builds": [
+ { "src": "index.html", "use": "@vercel/static" },
+ { "src": "games/**", "use": "@vercel/static" }
+ ],
+ "routes": [
+ { "src": "/gridlock/?", "dest": "/games/gridlock/index.html" },
+ { "src": "/games/(.*)", "dest": "/games/$1" },
+ { "src": "/(.*)", "dest": "/index.html" }
+ ]
}