Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
195 changes: 195 additions & 0 deletions games/gridlock/README.md
Original file line number Diff line number Diff line change
@@ -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.
131 changes: 131 additions & 0 deletions games/gridlock/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GRIDLOCK — Everything runs through you</title>
<meta name="description" content="An isometric neon city-builder. Zone it, power it, cool it, and keep the whole thing moving." />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230d0a14'/%3E%3Cpath d='M16 5l11 6.5v9L16 27 5 20.5v-9z' fill='none' stroke='%23c9a84c' stroke-width='2'/%3E%3Cpath d='M16 14h6v6' fill='none' stroke='%238b5cf6' stroke-width='2'/%3E%3C/svg%3E" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@600;900&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="app">
<canvas id="stage" aria-label="City map"></canvas>

<header id="hud" class="hud">
<div class="hud-brand">
<span class="brand-mark">GRIDLOCK</span>
<span class="brand-date" id="hud-date">JAN 2087</span>
<span class="brand-grade" id="hud-grade" data-tone="ok">STABLE</span>
</div>

<div class="hud-stat">
<span class="hs-label">Treasury</span>
<span class="hs-value" id="hud-credits">₡0</span>
<span class="hs-note" id="hud-net">+₡0/mo</span>
</div>

<div class="hud-stat">
<span class="hs-label">Residents</span>
<span class="hs-value" id="hud-pop">0</span>
<span class="hs-note" id="hud-pop-note">0 working</span>
</div>

<div class="hud-stat">
<span class="hs-label">Approval</span>
<span class="hs-value" id="hud-approval">68%</span>
<div class="meter" id="hud-approval-bar" data-tone="good"><i></i></div>
</div>

<div class="hud-stat">
<span class="hs-label">Power</span>
<span class="hs-value" id="hud-power">0/0</span>
<div class="meter" id="hud-power-bar" data-tone="good"><i></i></div>
</div>

<div class="hud-stat">
<span class="hs-label">Water</span>
<span class="hs-value" id="hud-water">0/0</span>
<div class="meter" id="hud-water-bar" data-tone="good"><i></i></div>
</div>

<div class="hud-stat">
<span class="hs-label">Heat Index</span>
<span class="hs-value" id="hud-heat">12</span>
<div class="meter" id="hud-heat-bar" data-tone="good"><i></i></div>
</div>

<div class="hud-stat wide">
<span class="hs-label">Era</span>
<span class="hs-value" id="hud-era">ERA 1 · DIESEL AGE</span>
<span class="hs-note" id="hud-era-note">120 research to Solar Age</span>
</div>

<div class="hud-alerts" id="hud-alerts" aria-live="polite"></div>
</header>

<aside class="panel left">
<div class="panel-head">Build <span class="panel-hint">click, then drag on the map</span></div>
<div id="build-list" class="build-list"></div>
</aside>

<aside class="panel right">
<div id="inspector" class="inspector"></div>
<section class="panel-section">
<h3>Directives</h3>
<div id="panel-directives"></div>
</section>
<section class="panel-section">
<h3>Pressures</h3>
<div id="panel-modifiers"></div>
</section>
<section class="panel-section">
<h3>Trends</h3>
<div id="panel-trends"></div>
</section>
<section class="panel-section">
<h3>City Log</h3>
<div id="panel-log"></div>
</section>
</aside>

<footer id="controls" class="controls">
<div class="ctrl-group" id="speed-group">
<span class="ctrl-label">Speed <b id="hud-speed">1×</b></span>
<button class="btn tiny" data-speed="0" title="Pause [Space]">❚❚</button>
<button class="btn tiny" data-speed="1" title="Normal [1]">▶</button>
<button class="btn tiny" data-speed="2" title="Fast [2]">▶▶</button>
<button class="btn tiny" data-speed="3" title="Very fast [3]">▶▶▶</button>
</div>

<div class="ctrl-group">
<span class="ctrl-label">Tax rate</span>
<button class="btn tiny" id="tax-down" title="Lower taxes">−</button>
<b id="tax-value">9%</b>
<button class="btn tiny" id="tax-up" title="Raise taxes">+</button>
</div>

<div class="ctrl-group" id="overlay-group">
<span class="ctrl-label">View [O]</span>
</div>

<div class="ctrl-group right-align">
<button class="btn" id="btn-save" title="Save to this browser">Save</button>
<button class="btn" id="btn-load" title="Load the saved city">Load</button>
<button class="btn" id="btn-new" title="Start a new city">New</button>
<button class="btn tiny" id="btn-mute" title="Mute audio">♪</button>
<button class="btn tiny" id="btn-help" title="How to play [H]">?</button>
</div>
</footer>

<div id="modal" class="modal"></div>
<div id="toasts" class="toasts"></div>
<pre id="telemetry" class="telemetry hidden"></pre>
</div>

<script type="module" src="./src/main.js"></script>
</body>
</html>
10 changes: 10 additions & 0 deletions games/gridlock/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading