diff --git a/README.md b/README.md
index f424480..50891e0 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ procedural graphics (no asset downloads), and a simulation modeled on the classi
| ๐ฐ **Budget & taxes** | Live tax slider, itemized income/upkeep ledger, debt warnings |
| ๐ **Traffic** | Animated cars flow along your road network |
| ๐ **Day/night cycle** | Windows light up as the sun sets |
+| ๐ช **Auto-Build advisor** | One click founds a town, then keeps power ahead of demand, connects zoned land, adds districts and services where coverage lags โ always keeping an emergency reserve |
| ๐ฏ **Goals & milestones** | From *Outpost* to *Megalopolis*, with an onboarding quest list |
| ๐พ **Saves** | Autosave, 3 manual slots, JSON export/import |
| ๐ฑ **Touch support** | Paint with one finger, pinch-zoom, two-finger rotate |
diff --git a/shots/01-boot.png b/shots/01-boot.png
index 5798535..ab50e7e 100644
Binary files a/shots/01-boot.png and b/shots/01-boot.png differ
diff --git a/shots/02-built.png b/shots/02-built.png
index 4a5c4f6..affc791 100644
Binary files a/shots/02-built.png and b/shots/02-built.png differ
diff --git a/shots/03-grown.png b/shots/03-grown.png
index bdac787..c27551e 100644
Binary files a/shots/03-grown.png and b/shots/03-grown.png differ
diff --git a/shots/08-menu.png b/shots/08-menu.png
index 2f5f28c..c042505 100644
Binary files a/shots/08-menu.png and b/shots/08-menu.png differ
diff --git a/shots/08-stats.png b/shots/08-stats.png
index adbde06..f774155 100644
Binary files a/shots/08-stats.png and b/shots/08-stats.png differ
diff --git a/shots/09-final.png b/shots/09-final.png
index 394e5d5..8bd956a 100644
Binary files a/shots/09-final.png and b/shots/09-final.png differ
diff --git a/shots/scene-autobuild.png b/shots/scene-autobuild.png
new file mode 100644
index 0000000..b7b7a76
Binary files /dev/null and b/shots/scene-autobuild.png differ
diff --git a/src/game/autobuilder.js b/src/game/autobuilder.js
new file mode 100644
index 0000000..c36dbe2
--- /dev/null
+++ b/src/game/autobuilder.js
@@ -0,0 +1,317 @@
+import { STRUCT, ZONE, BUILDINGS, START_MONEY } from '../config.js';
+import { lineTiles } from '../utils.js';
+
+/**
+ * AutoBuilder โ a SimCity-style advisor that spends one "planning pass"
+ * per call, always keeping an emergency reserve. Priorities:
+ * 1. Power headroom (blackouts stall everything)
+ * 2. Road access for zoned land (nothing develops without a road)
+ * 3. New districts when demand is high and empty zone plots run out
+ * 4. Missing service coverage over developed tiles
+ * 5. Leisure to push land value over upgrade thresholds
+ */
+export class AutoBuilder {
+ constructor(city) {
+ this.city = city;
+ this.reserve = Math.max(1500, Math.round(START_MONEY * 0.12));
+ }
+
+ get g() { return this.city.grid; }
+ canAfford(cost) { return this.city.money - this.reserve >= cost; }
+
+ /** Run one pass; returns human-readable list of what was built. */
+ run() {
+ const actions = [];
+ for (const step of [this.stepGenesis, this.stepPower, this.stepRoadAccess,
+ this.stepDistricts, this.stepServices, this.stepLeisure]) {
+ try { const r = step.call(this); if (r) { actions.push(r); this.city.recomputePower(); } } catch { /* keep going */ }
+ }
+ return actions;
+ }
+
+ /** Blank-map bootstrap: lay a main street through open land and zone
+ * homes on one side, shops + industry on the other. */
+ stepGenesis() {
+ const g = this.g, S = g.size;
+ let any = false;
+ for (let i = 0; i < g.n && !any; i++) if (g.struct[i] || g.zone[i]) any = true;
+ if (any) return null;
+
+ // find the longest clear horizontal runway near the vertical middle
+ let best = null, bestLen = 0;
+ for (let z = (S >> 2); z < S - (S >> 2); z++) {
+ let run = 0, startX = -1;
+ for (let x = 2; x < S - 2; x++) {
+ const j = g.idx(x, z);
+ if (g.terrain[j] === 0 && !g.rubble[j]) {
+ if (startX < 0) startX = x;
+ run++;
+ if (run > bestLen) { bestLen = run; best = [startX, z]; }
+ } else { run = 0; startX = -1; }
+ }
+ }
+ if (!best || bestLen < 12) return null;
+
+ const [x0, z] = best;
+ const len = Math.min(bestLen, 16);
+ if (!this.canAfford(len * BUILDINGS[STRUCT.ROAD].cost)) return null;
+ for (let k = 0; k < len; k++) this.city.placeStruct(STRUCT.ROAD, x0 + k, z);
+
+ const mid = x0 + ((len / 2) | 0);
+ const band = (zx0, zx1, zz0, zz1, zid) => {
+ const tiles = [];
+ for (let zz = zz0; zz <= zz1; zz++) for (let xx = zx0; xx <= zx1; xx++) {
+ if (!g.inB(xx, zz)) continue;
+ const j = g.idx(xx, zz);
+ if (this.freeForBuilding(j)) tiles.push([xx, zz]);
+ }
+ if (tiles.length >= 6) this.city.placeZone(zid, tiles);
+ };
+ const half = ((len / 2) | 0) - 1;
+ band(x0, x0 + len - 1, z - 3, z - 1, ZONE.RES);
+ band(mid - half, mid + half, z + 1, z + 2, ZONE.COM);
+ band(mid + 2, x0 + len - 1, z + 1, z + 3, ZONE.IND);
+ return `Founded the town: main street \u00d7${len} with homes, shops and industry`;
+ }
+
+ // ---------- helpers ----------
+
+ freeForBuilding(i) {
+ const g = this.g;
+ return g.terrain[i] === 0 && !g.struct[i] && !g.rubble[i] && !g.burning[i] && !g.level[i];
+ }
+
+ findSpot(w, h, cx, cz, allowZone = true) {
+ const g = this.g;
+ for (let r = 1; r < 42; r++) {
+ for (let z = cz - r; z <= cz + r; z++) {
+ for (let x = cx - r; x <= cx + r; x++) {
+ let okSpot = true;
+ for (let dz = -1; dz <= h && okSpot; dz++) {
+ for (let dx = -1; dx <= w; dx++) {
+ if (!g.inB(x + dx, z + dz)) { okSpot = false; break; }
+ const j = g.idx(x + dx, z + dz);
+ const blocked = !this.freeForBuilding(j) || (allowZone ? false : !!g.zone[j]);
+ // allow claiming EMPTY zoned plots (same rule as manual placement)
+ const zonedOk = allowZone && g.zone[j] !== 0 && !g.level[j] && !g.struct[j] && !g.rubble[j] && !g.burning[j] && g.terrain[j] === 0;
+ if (blocked && !zonedOk) { okSpot = false; break; }
+ }
+ }
+ if (okSpot) return [x, z];
+ }
+ }
+ }
+ return null;
+ }
+
+ place(sid, w = 1, h = 1, nearX = 32, nearZ = 32) {
+ const cost = BUILDINGS[sid].cost;
+ if (!this.canAfford(cost)) return null;
+ const p = this.findSpot(w, h, nearX, nearZ);
+ if (!p) return null;
+ const r = this.city.placeStruct(sid, p[0], p[1]);
+ return r.ok ? p : null;
+ }
+
+ developedCentroid() {
+ const g = this.g;
+ let sx = 0, sz = 0, n = 0;
+ for (let i = 0; i < g.n; i++) {
+ if (g.level[i] > 0 || g.struct[i]) {
+ sx += i % g.size; sz += (i / g.size) | 0; n++;
+ }
+ }
+ if (!n) return null;
+ return [(sx / n) | 0, (sz / n) | 0];
+ }
+
+ avgCoverage(map) {
+ const g = this.g;
+ let sum = 0, n = 0;
+ for (let i = 0; i < g.n; i++) {
+ if (g.level[i] > 0) { sum += map[i]; n++; }
+ }
+ return n ? sum / n : 1; // nothing developed โ services not needed yet
+ }
+
+ // ---------- steps ----------
+
+ stepPower() {
+ const s = this.city.stats;
+ // plan ahead: zoned land with zero capacity needs the first plant NOW
+ let hasZones = false;
+ for (let i = 0; i < this.g.n && !hasZones; i++) if (this.g.zone[i]) hasZones = true;
+ const strain = s.brownouts > 0 || s.powerUse > s.powerCap * 0.65 ||
+ (s.powerCap === 0 && hasZones);
+ if (!strain) return null;
+ const deficit = Math.max(600, s.powerUse * 0.8 - s.powerCap);
+ let sid;
+ if (deficit > 3000 && this.canAfford(BUILDINGS[STRUCT.COAL].cost)) sid = STRUCT.COAL;
+ else if (deficit > 1200 && this.canAfford(BUILDINGS[STRUCT.SOLAR].cost)) sid = STRUCT.SOLAR;
+ else sid = STRUCT.WIND;
+ const c = this.developedCentroid() || [32, 32];
+ const p = this.place(sid, BUILDINGS[sid].w, BUILDINGS[sid].h, c[0], c[1]);
+ return p ? `${BUILDINGS[sid].name} for ${deficit | 0}W deficit` : null;
+ }
+
+ /** Zoned-empty plots with no road within 2 tiles never develop โ connect them. */
+ stepRoadAccess() {
+ const g = this.g, S = g.size;
+ let orphan = null;
+ outer:
+ for (let z = 2; z < S - 2; z++) {
+ for (let x = 2; x < S - 2; x++) {
+ const i = g.idx(x, z);
+ if (g.zone[i] && !g.level[i] && !g.struct[i] && !g.rubble[i]) {
+ if (!this.city.roadNear(x, z, 3)) { orphan = [x, z]; break outer; }
+ }
+ }
+ }
+ if (!orphan) return null;
+
+ // BFS to nearest existing road through buildable land, then lay it
+ const [ox, oz] = orphan;
+ const prev = new Map();
+ const q = [[ox, oz]];
+ const seen = new Set([g.idx(ox, oz)]);
+ let goal = null;
+ while (q.length && !goal) {
+ const [x, z] = q.shift();
+ for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ const nx = x + dx, nz = z + dz;
+ if (!g.inB(nx, nz)) continue;
+ const j = g.idx(nx, nz);
+ if (seen.has(j)) continue;
+ seen.add(j);
+ if (g.struct[j] === STRUCT.ROAD) { goal = [nx, nz]; prev.set(j, g.idx(x, z)); break; }
+ if (g.terrain[j] !== 0 || g.struct[j] || g.rubble[j] || g.level[j]) continue;
+ prev.set(j, g.idx(x, z));
+ q.push([nx, nz]);
+ }
+ }
+ if (!goal) return null;
+
+ // walk back from goal to orphan, laying road on every tile except the
+ // final one adjacent to the orphan (that's the connection point)
+ let laid = 0;
+ let cur = g.idx(goal[0], goal[1]);
+ const orphanIdx = g.idx(ox, oz);
+ while (cur !== orphanIdx && laid < 14) {
+ const x = cur % S, z = (cur / S) | 0;
+ if (g.struct[cur] !== STRUCT.ROAD) {
+ if (!this.canAfford(BUILDINGS[STRUCT.ROAD].cost)) break;
+ this.city.placeStruct(STRUCT.ROAD, x, z);
+ laid++;
+ }
+ if (!prev.has(cur)) break;
+ cur = prev.get(cur);
+ }
+ return laid ? `Road spur ร${laid} to reach zoned land` : null;
+ }
+
+ /** Stamp a fresh district (road spur + flanking zone bands) for the
+ * demand type that most needs land. */
+ stepDistricts() {
+ const s = this.city.stats;
+ const needs = [
+ { d: s.resDemand, z: ZONE.RES, name: 'Homes' },
+ { d: s.comDemand, z: ZONE.COM, name: 'Shops' },
+ { d: s.indDemand, z: ZONE.IND, name: 'Industry' }
+ ].sort((a, b) => b.d - a.d);
+ const want = needs[0];
+ if (want.d < 0.22) return null;
+
+ // count empty zoned plots for that type; plenty left โ no need
+ const g = this.g;
+ let empty = 0;
+ for (let i = 0; i < g.n; i++) {
+ if (g.zone[i] === want.z && !g.level[i] && !g.struct[i] &&
+ this.city.roadNear(i % g.size, (i / g.size) | 0, 2)) empty++;
+ }
+ if (empty >= 14) return null;
+
+ // seed: road tile with the most buildable space around its far end
+ const seeds = [];
+ for (let i = 0; i < g.n; i++) {
+ if (g.struct[i] !== STRUCT.ROAD) continue;
+ seeds.push(i);
+ }
+ if (!seeds.length) return null;
+ let best = null, bestScore = -1;
+ for (const si of seeds) {
+ const sx = si % g.size, sz = (si / g.size) | 0;
+ for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ // measure open runway 6 tiles out in this direction
+ let open = 0;
+ for (let k = 1; k <= 6; k++) {
+ const x = sx + dx * k, z = sz + dz * k;
+ if (!g.inB(x, z)) break;
+ const j = g.idx(x, z);
+ if (g.terrain[j] !== 0 || g.struct[j] || g.level[j] || g.rubble[j]) break;
+ open++;
+ }
+ if (open > bestScore) { bestScore = open; best = [sx, sz, dx, dz]; }
+ }
+ }
+ if (!best || bestScore < 4) return null;
+ const [sx, sz, dx, dz] = best;
+
+ // budget guard for the whole district
+ const estCost = (bestScore + 1) * BUILDINGS[STRUCT.ROAD].cost;
+ if (!this.canAfford(estCost)) return null;
+
+ // lay the spur
+ let endX = sx, endZ = sz;
+ for (let k = 1; k <= bestScore; k++) {
+ endX = sx + dx * k; endZ = sz + dz * k;
+ this.city.placeStruct(STRUCT.ROAD, endX, endZ);
+ }
+ // perpendicular axis for bands
+ const px = dz !== 0 ? 1 : 0, pz = dx !== 0 ? 1 : 0;
+ for (const side of [1, -1]) {
+ const tiles = [];
+ for (let along = -2; along <= 2; along++) {
+ for (let depth = 1; depth <= 3; depth++) {
+ const bx = endX + px * side * depth + dx * along;
+ const bz = endZ + pz * side * depth + dz * along;
+ if (!g.inB(bx, bz)) continue;
+ const j = g.idx(bx, bz);
+ if (this.freeForBuilding(j)) tiles.push([bx, bz]);
+ }
+ }
+ if (tiles.length >= 6) this.city.placeZone(want.z, tiles);
+ }
+ return `${want.name} district: spur + two zone bands`;
+ }
+
+ stepServices() {
+ const c = this.city;
+ const plan = [
+ { sid: STRUCT.POLICE, map: c.mapPolice, name: 'Police' },
+ { sid: STRUCT.FIRE, map: c.mapFire, name: 'Fire Stn' },
+ { sid: STRUCT.HOSPITAL, map: c.mapHealth, name: 'Hospital' },
+ { sid: STRUCT.SCHOOL, map: c.mapEdu, name: 'School' }
+ ];
+ const worst = plan
+ .map(p => ({ ...p, avg: this.avgCoverage(p.map) }))
+ .sort((a, b) => a.avg - b.avg)[0];
+ if (!worst || worst.avg > 0.34) return null;
+ const ctr = this.developedCentroid();
+ if (!ctr) return null;
+ // services like being NEAR town but not in the middle of zones โ search
+ // outward from centroid; empty-zone claims are allowed
+ const p = this.place(worst.sid, 1, 1, ctr[0] + 4, ctr[1] + 3);
+ return p ? `${worst.name} (coverage was ${(worst.avg * 100) | 0}%)` : null;
+ }
+
+ stepLeisure() {
+ if (this.avgCoverage(this.city.mapLeisure) > 0.30) return null;
+ if (this.city.stats.pop < 60) return null;
+ const ctr = this.developedCentroid();
+ if (!ctr) return null;
+ const pick = this.canAfford(BUILDINGS[STRUCT.PLAZA].cost) &&
+ Math.random() < 0.5 ? STRUCT.PLAZA : STRUCT.PARK;
+ const p = this.place(pick, 1, 1, ctr[0] - 3, ctr[1] + 4);
+ return p ? BUILDINGS[pick].name : null;
+ }
+}
diff --git a/src/main.js b/src/main.js
index 34aa3d6..a89ecfd 100644
--- a/src/main.js
+++ b/src/main.js
@@ -6,6 +6,7 @@ import { Input } from './input.js';
import { UI } from './ui/ui.js';
import { Audio } from './audio.js';
import { SaveManager } from './save.js';
+import { AutoBuilder } from './game/autobuilder.js';
import { SETTINGS_KEY, START_MONEY } from './config.js';
import { clamp } from './utils.js';
@@ -87,6 +88,7 @@ class Game {
teardown() {
for (const off of this._unsubs) off();
this._unsubs = [];
+ if (this._simTimer) { clearInterval(this._simTimer); this._simTimer = null; }
if (this._parts) {
this._parts.input.destroy?.();
this._parts.ui.destroy?.();
@@ -142,6 +144,17 @@ class Game {
checkQuests() { this.ui?.checkQuests(); }
+ /** Advisor: one planning pass. Rebuilt with the city so it always
+ * points at the live grid. */
+ autobuild() {
+ if (!this._advisor || this._advisor.city !== this.city) {
+ this._advisor = new AutoBuilder(this.city);
+ }
+ const actions = this._advisor.run();
+ this._syncSoon();
+ return actions;
+ }
+
_syncSoon() {
if (this.city.tilesDirty && this._parts) {
this._parts.renderer.sync();
@@ -155,21 +168,11 @@ class Game {
loop() {
const tick = (now) => {
this._raf = requestAnimationFrame(tick);
+ this._lastFrameAt = now;
const dt = Math.min(0.05, (now - this._lastT) / 1000);
this._lastT = now;
- // simulation stepping
- const msPerMonth = [0, 5000, 1800, 700][this.speedIdx] || 0;
- if (!this.paused && msPerMonth > 0) {
- this._simAcc += dt * 1000;
- let guard = 0;
- while (this._simAcc >= msPerMonth && guard < 4) {
- this.city.tick();
- this._simAcc -= msPerMonth;
- guard++;
- }
- if (guard >= 4) this._simAcc = 0; // avoid runaway catch-up
- }
+ this.advance(dt);
this._syncSoon();
if (this._parts) {
@@ -178,6 +181,35 @@ class Game {
}
};
this._raf = requestAnimationFrame(tick);
+
+ // Safety net: browsers throttle requestAnimationFrame for hidden or
+ // occluded pages, which would freeze the simulation (and headless
+ // test rigs). Compensate ONLY when frames stop outright for 3s โ
+ // never when they are merely slow, or software GL would drown.
+ if (!this._simTimer) {
+ this._simTimer = setInterval(() => {
+ if (!this._raf || this.paused) return;
+ const idleFor = performance.now() - (this._lastFrameAt ?? performance.now());
+ if (idleFor > 3000) {
+ this._lastFrameAt = performance.now();
+ this.advance(Math.min(3, idleFor / 1000));
+ this._syncSoon();
+ }
+ }, 750);
+ }
+ }
+
+ advance(dt) {
+ const msPerMonth = [0, 5000, 1800, 700][this.speedIdx] || 0;
+ if (this.paused || msPerMonth <= 0) return;
+ this._simAcc += dt * 1000;
+ let guard = 0;
+ while (this._simAcc >= msPerMonth && guard < 8) {
+ this.city.tick();
+ this._simAcc -= msPerMonth;
+ guard++;
+ }
+ if (guard >= 8) this._simAcc = 0; // avoid runaway catch-up
}
}
diff --git a/src/render/renderer.js b/src/render/renderer.js
index f860198..ed4a5d6 100644
--- a/src/render/renderer.js
+++ b/src/render/renderer.js
@@ -20,9 +20,11 @@ export class Renderer {
this.settings = settings;
const w = container.clientWidth, h = container.clientHeight;
- // preserveDrawingBuffer keeps the frame readable for user screenshots
- // (right-click save / "share your city") and headless captures.
- this.renderer3 = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
+ // preserveDrawingBuffer enables canvas.toDataURL snapshots ("share
+ // your city") but costs a full-buffer readback per composited frame,
+ // which is brutal on software GL โ opt in via ?cap=1.
+ const capMode = /[?&]cap=1/.test(location.search);
+ this.renderer3 = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: capMode });
this.renderer3.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer3.setSize(w, h);
this.renderer3.shadowMap.enabled = !!settings.shadows;
diff --git a/src/style.css b/src/style.css
index 9f0b1c6..bec7950 100644
--- a/src/style.css
+++ b/src/style.css
@@ -112,6 +112,20 @@ html, body {
.tl-btn.poor { opacity: .45; filter: saturate(.4); }
.tl-btn.poor .tl-cost { color: #ff7a7a; }
+/* advisor button โ stands apart from the tools */
+.auto-btn {
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
+ min-width: 68px; padding: 6px 8px 5px; border-radius: 11px;
+ border: 1px solid rgba(139,109,255,.55);
+ background: linear-gradient(160deg, rgba(123,92,255,.28), rgba(74,47,214,.16));
+ color: var(--text); cursor: pointer; transition: all .13s;
+}
+.auto-btn:hover { transform: translateY(-1px); border-color: #a58cff;
+ box-shadow: 0 0 16px rgba(123,92,255,.35); }
+.auto-btn:active { transform: translateY(0) scale(.97); }
+.auto-btn .tl-ico svg { width: 30px; height: 30px; border-radius: 7px; }
+.auto-btn .tl-label { font-size: 10.5px; color: #cdbdff; white-space: nowrap; }
+
/* floating "current tool" chip above the bar */
#toolReadout {
position: absolute; bottom: 92px; left: 50%; transform: translate(-50%, 6px);
diff --git a/src/ui/icons.js b/src/ui/icons.js
index ea277c9..e4d74ef 100644
--- a/src/ui/icons.js
+++ b/src/ui/icons.js
@@ -9,6 +9,17 @@ const sky = (id, c1 = '#bfe3ff', c2 = '#8fc7f2') =>
`