commit 231201da5967788ebf93121d698acf9182f034cb
parent 3e2661dffc75de2d933851692e9ef37d3d6bc71d
Author: Kyle Barlow <kb@kylebarlow.com>
Date: Mon, 13 Jul 2026 19:50:02 -0700
fire-truck: people-first city — bike lanes, bicyclists, buses, denser crowds
- Fix palms baking into the middle of roads: street palms now anchor to a
sidewalk edge and jitter only along it
- Bake painted green bike lanes (with white separator dashes) on straight
segments, matched to the bicyclists' riding line
- New ambient movers on the road graph: 48 bicyclists, 6 buses; pedestrians
up 18 -> 160 and drawn larger; private cars cut 10 -> 4
- Bus-stop shelters baked along straight sidewalk-lined streets (palms skip
those cells)
- Browser ambient test now covers bikes/buses/ped density; CLAUDE.md depth
map replaced with ordering (values live in game.js)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
3 files changed, 153 insertions(+), 34 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
@@ -71,9 +71,9 @@ Every new **world** object must be hidden from the HUD camera —
`this._addWorld(obj, depth)` sets depth + `uiCamera.ignore`. HUD objects are
added to `cameras.main.ignore(...)`.
-**Depth map**: −1 ocean · 0 chunks · 6 boats/buoys · 8 peds · 9 cars · 9.5
-burning overlay · 9.7 fire glow · 10 fire · 11 spray · 12 smoke · 34 cloud
-shadows · 35 truck · 38 birds · 40+ HUD.
+**Depth ordering** (exact values live in game.js): ocean < chunks <
+boats/buoys < peds < bikes < cars/buses < burning overlay < fire glow < fire <
+spray < smoke < cloud shadows < truck < birds < HUD.
**Ambient life** (`_initAmbient` / `_updateAmbient`, driven from `update()` —
never `step()`, which tests call directly): baked-texture Images for traffic
diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js
@@ -14,6 +14,14 @@ const ROUTE_EXTENSION_COUNT = 36;
const FIRE_TRIGGER_DIST = CELL_SIZE * 0.6;
const CAR_SPEED = 300; // world u/s for ambient traffic
const PED_SPEED = 90; // world u/s for pedestrians
+const BIKE_SPEED = 170; // world u/s for bicyclists
+const BUS_SPEED = 230; // world u/s for buses
+// A people-first city: streets are full of walkers, bikes, and buses;
+// private cars are a rare sight.
+const PED_COUNT = 160;
+const BIKE_COUNT = 48;
+const BUS_COUNT = 6;
+const CAR_COUNT = 4;
const FIRE_EXTINGUISH_S = 3.0;
const FIRES_TO_WIN = 4;
@@ -33,6 +41,7 @@ const ROOF_TERRACOTTA = 0xd97b52;
const PARK_GRASS = 0x7ecb62;
const PALM_TRUNK = 0x9a6b43;
const PALM_FROND = 0x46b063;
+const BIKE_LANE_COLOR = 0x3e8e5a; // painted protected bike lanes
const FT_BPM = 158;
const FT_MELODY = [
@@ -385,10 +394,40 @@ class FireTruckScene extends Phaser.Scene {
if (edges.S) gfx.fillRect(gx, gy + BAKE_CELL - band - 2, BAKE_CELL, 2);
if (edges.W) gfx.fillRect(gx + band, gy, 2, BAKE_CELL);
if (edges.E) gfx.fillRect(gx + BAKE_CELL - band - 2, gy, 2, BAKE_CELL);
+
+ // Bus-stop shelters dotted along straight sidewalk-lined streets.
+ const meta = cell.meta || FT.classifyIntersection(cell.exits);
+ if (meta.kind === 'straight' && this._busStopEdge(cell, edges, x, y)) {
+ this._drawBusStop(gfx, gx, gy, this._busStopEdge(cell, edges, x, y));
+ }
}
}
}
+ // Which sidewalk edge (if any) of this road cell hosts a bus stop.
+ // Deterministic via hashCell so every bake places them identically.
+ _busStopEdge(cell, edges, x, y) {
+ if (FT.hashCell(x, y, 35) >= 0.10) return null;
+ for (const card of ['N', 'S', 'E', 'W']) if (edges[card]) return card;
+ return null;
+ }
+
+ _drawBusStop(gfx, gx, gy, edge) {
+ const roof = 0x1d7cf2;
+ // Shelter roof (36×10) hugging the sidewalk band, plus a small yellow sign.
+ const draw = (rx, ry, w, h, sx, sy) => {
+ gfx.fillStyle(0x000000, 0.15); gfx.fillRoundedRect(rx + 2, ry + 2, w, h, 3);
+ gfx.fillStyle(roof, 1); gfx.fillRoundedRect(rx, ry, w, h, 3);
+ gfx.fillStyle(0xffffff, 0.5); gfx.fillRoundedRect(rx + 3, ry + 2, w - 6, 3, 2);
+ gfx.fillStyle(STRIPE_COLOR, 1); gfx.fillCircle(sx, sy, 4);
+ gfx.fillStyle(0x22303a, 1); gfx.fillCircle(sx, sy, 2);
+ };
+ if (edge === 'N') draw(gx + 30, gy + 1, 36, 10, gx + 74, gy + 7);
+ else if (edge === 'S') draw(gx + 30, gy + BAKE_CELL - 11, 36, 10, gx + 74, gy + BAKE_CELL - 7);
+ else if (edge === 'W') draw(gx + 1, gy + 30, 10, 36, gx + 7, gy + 74);
+ else draw(gx + BAKE_CELL - 11, gy + 30, 10, 36, gx + BAKE_CELL - 7, gy + 74);
+ }
+
_bakeRoadMarkings(gfx, ax0, ay0, ax1, ay1, ox, oy) {
const grid = this.island.grid;
for (let y = ay0; y < ay1; y++) {
@@ -399,6 +438,30 @@ class FireTruckScene extends Phaser.Scene {
const gy = (y - oy) * BAKE_CELL;
const meta = cell.meta || FT.classifyIntersection(cell.exits);
if (meta.kind === 'straight') {
+ // Painted protected bike lanes on both sides of every straight
+ // segment (offsets match the bicyclists' riding line), with a
+ // white separator dash on the traffic side.
+ const laneIn = 16, laneOut = 30; // px from road centerline
+ const mid = BAKE_CELL / 2;
+ if (cell.exits.N && cell.exits.S) {
+ gfx.fillStyle(BIKE_LANE_COLOR, 0.55);
+ gfx.fillRect(gx + mid - laneOut, gy, laneOut - laneIn, BAKE_CELL);
+ gfx.fillRect(gx + mid + laneIn, gy, laneOut - laneIn, BAKE_CELL);
+ gfx.fillStyle(0xffffff, 0.7);
+ for (let dy = 4; dy < BAKE_CELL - 4; dy += 20) {
+ gfx.fillRect(gx + mid - laneIn - 2, gy + dy, 2, 10);
+ gfx.fillRect(gx + mid + laneIn, gy + dy, 2, 10);
+ }
+ } else {
+ gfx.fillStyle(BIKE_LANE_COLOR, 0.55);
+ gfx.fillRect(gx, gy + mid - laneOut, BAKE_CELL, laneOut - laneIn);
+ gfx.fillRect(gx, gy + mid + laneIn, BAKE_CELL, laneOut - laneIn);
+ gfx.fillStyle(0xffffff, 0.7);
+ for (let dx = 4; dx < BAKE_CELL - 4; dx += 20) {
+ gfx.fillRect(gx + dx, gy + mid - laneIn - 2, 10, 2);
+ gfx.fillRect(gx + dx, gy + mid + laneIn, 10, 2);
+ }
+ }
gfx.fillStyle(STRIPE_COLOR, 0.9);
if (cell.exits.N && cell.exits.S) {
for (let dy = 8; dy < BAKE_CELL - 8; dy += 24) gfx.fillRect(gx + BAKE_CELL / 2 - 3, gy + dy, 6, 14);
@@ -526,19 +589,27 @@ class FireTruckScene extends Phaser.Scene {
for (let y = ay0; y < ay1; y++) {
for (let x = ax0; x < ax1; x++) {
const cell = grid[y][x];
- let place = false;
+ const gx = (x - ox) * BAKE_CELL;
+ const gy = (y - oy) * BAKE_CELL;
if (cell.type === T.BEACH && FT.hashCell(x, y, 30) < 0.5) {
- place = true;
+ const jx = gx + BAKE_CELL / 2 + (FT.hashCell(x, y, 32) - 0.5) * BAKE_CELL * 0.4;
+ const jy = gy + BAKE_CELL / 2 + (FT.hashCell(x, y, 33) - 0.5) * BAKE_CELL * 0.4;
+ this._drawPalm(gfx, jx, jy);
} else if (cell.type === T.ROAD) {
+ // Street trees stand on the sidewalk band, never in the roadway:
+ // anchor to a sidewalk edge and jitter only along that edge.
const e = FT.sidewalkEdges(grid, x, y);
- if ((e.N || e.S || e.E || e.W) && FT.hashCell(x, y, 31) < 0.12) place = true;
+ if (FT.hashCell(x, y, 31) >= 0.12) continue;
+ if (this._busStopEdge(cell, e, x, y)) continue; // shelter lives here
+ const edges = ['N', 'S', 'E', 'W'].filter((c) => e[c]);
+ if (!edges.length) continue;
+ const edge = edges[Math.floor(FT.hashCell(x, y, 34) * edges.length) % edges.length];
+ const along = BAKE_CELL / 2 + (FT.hashCell(x, y, 32) - 0.5) * BAKE_CELL * 0.5;
+ if (edge === 'N') this._drawPalm(gfx, gx + along, gy + 7);
+ else if (edge === 'S') this._drawPalm(gfx, gx + along, gy + BAKE_CELL - 7);
+ else if (edge === 'W') this._drawPalm(gfx, gx + 7, gy + along);
+ else this._drawPalm(gfx, gx + BAKE_CELL - 7, gy + along);
}
- if (!place) continue;
- const gx = (x - ox) * BAKE_CELL;
- const gy = (y - oy) * BAKE_CELL;
- const jx = gx + BAKE_CELL / 2 + (FT.hashCell(x, y, 32) - 0.5) * BAKE_CELL * 0.4;
- const jy = gy + BAKE_CELL / 2 + (FT.hashCell(x, y, 33) - 0.5) * BAKE_CELL * 0.4;
- this._drawPalm(gfx, jx, jy);
}
}
}
@@ -641,6 +712,39 @@ class FireTruckScene extends Phaser.Scene {
g.generateTexture(key, 30, 18); g.destroy();
});
+ // Top-down bicyclists (facing +x): two wheel lines, frame, rider on top.
+ const bikeShirts = [0xe63946, 0x1d7cf2, 0x2ec4b6, 0xff8c42];
+ bikeShirts.forEach((color, i) => {
+ const key = 'bike-' + i;
+ if (this.textures.exists(key)) return;
+ const g = mk();
+ g.fillStyle(0x000000, 0.2); g.fillEllipse(15, 10, 22, 5);
+ g.fillStyle(0x22303a, 1);
+ g.fillRoundedRect(2, 6, 9, 3, 1.5); // rear wheel
+ g.fillRoundedRect(19, 6, 9, 3, 1.5); // front wheel
+ g.fillStyle(0x8a929c, 1); g.fillRect(10, 6.5, 10, 2); // frame
+ g.fillStyle(0xd8dde3, 1); g.fillRect(20, 3, 2, 9); // handlebars
+ g.fillStyle(color, 1); g.fillRoundedRect(9, 3, 10, 9, 3); // rider torso
+ g.fillStyle(0xf0c8a0, 1); g.fillCircle(14, 7.5, 3); // head
+ g.generateTexture(key, 30, 15); g.destroy();
+ });
+
+ // Top-down buses (facing +x): long body, window rows, white roof stripe.
+ const busColors = [0x2ec4b6, 0xffd23f];
+ busColors.forEach((color, i) => {
+ const key = 'bus-' + i;
+ if (this.textures.exists(key)) return;
+ const g = mk();
+ g.fillStyle(0x000000, 0.2); g.fillEllipse(24, 16, 44, 8);
+ g.fillStyle(color, 1); g.fillRoundedRect(2, 3, 44, 14, 4);
+ g.lineStyle(2, darken(color, 50), 1); g.strokeRoundedRect(2, 3, 44, 14, 4);
+ g.fillStyle(0x22303a, 0.85);
+ g.fillRect(41, 5, 4, 10); // windshield (front = +x)
+ for (let wx = 6; wx <= 34; wx += 7) { g.fillRect(wx, 4, 5, 3); g.fillRect(wx, 13, 5, 3); }
+ g.fillStyle(0xffffff, 0.6); g.fillRoundedRect(8, 8, 30, 4, 2); // roof stripe
+ g.generateTexture(key, 48, 20); g.destroy();
+ });
+
const shirts = [0x1d7cf2, 0xe63946, 0x2ec4b6, 0xffd23f];
shirts.forEach((color, i) => {
const key = 'ped-' + i;
@@ -730,25 +834,25 @@ class FireTruckScene extends Phaser.Scene {
this.ambientRng = FT.createSeededRng(0x51ce77);
this.trafficCars = [];
this.pedestrians = [];
+ this.bicyclists = [];
+ this.buses = [];
this.clouds = [];
this.boats = [];
this.birds = [];
- for (let i = 0; i < 10; i++) {
- const start = this._randomRoadStart();
- if (!start) continue;
- const key = 'car-' + Math.floor(this.ambientRng.next() * 5);
- const img = this._addWorld(this.add.image(0, 0, key).setScale(SCALE * 0.85), 9);
- this.trafficCars.push({ img, from: start.from, to: start.to, heading: start.heading, t: this.ambientRng.next() });
- }
-
- for (let i = 0; i < 18; i++) {
- const start = this._randomRoadStart();
- if (!start) continue;
- const key = 'ped-' + Math.floor(this.ambientRng.next() * 4);
- const img = this._addWorld(this.add.image(0, 0, key).setScale(SCALE * 0.7), 8);
- this.pedestrians.push({ img, from: start.from, to: start.to, heading: start.heading, t: this.ambientRng.next() });
- }
+ const spawnMovers = (count, list, texture, textureCount, scale, depth) => {
+ for (let i = 0; i < count; i++) {
+ const start = this._randomRoadStart();
+ if (!start) continue;
+ const key = texture + '-' + Math.floor(this.ambientRng.next() * textureCount);
+ const img = this._addWorld(this.add.image(0, 0, key).setScale(SCALE * scale), depth);
+ list.push({ img, from: start.from, to: start.to, heading: start.heading, t: this.ambientRng.next() });
+ }
+ };
+ spawnMovers(CAR_COUNT, this.trafficCars, 'car', 5, 0.85, 9);
+ spawnMovers(BUS_COUNT, this.buses, 'bus', 2, 0.9, 9);
+ spawnMovers(BIKE_COUNT, this.bicyclists, 'bike', 4, 0.85, 8.5);
+ spawnMovers(PED_COUNT, this.pedestrians, 'ped', 4, 0.85, 8);
const b = this.island.bounds;
for (let i = 0; i < 3; i++) {
@@ -830,6 +934,8 @@ class FireTruckScene extends Phaser.Scene {
_updateAmbient(dt) {
for (const car of this.trafficCars) this._moveMover(car, CAR_SPEED, CELL_SIZE * 0.16, dt, true);
+ for (const bus of this.buses) this._moveMover(bus, BUS_SPEED, CELL_SIZE * 0.14, dt, true);
+ for (const bike of this.bicyclists) this._moveMover(bike, BIKE_SPEED, CELL_SIZE * 0.24, dt, true);
for (const ped of this.pedestrians) this._moveMover(ped, PED_SPEED, CELL_SIZE * 0.34, dt, false);
const b = this.island.bounds;
diff --git a/tests/browser/runner.js b/tests/browser/runner.js
@@ -536,15 +536,28 @@ async function main() {
await page.waitForTimeout(1800);
const info = await page.evaluate(() => {
const s = window.__FT_SCENE__;
- const cars = s.trafficCars || [];
- const allRoad = cars.every(c =>
- s.cellAt(c.from.x, c.from.y).type === 'road' &&
- s.cellAt(c.to.x, c.to.y).type === 'road');
- return { count: cars.length, allRoad, ambientReady: s.ambientReady };
+ const movers = [
+ ...(s.trafficCars || []), ...(s.buses || []),
+ ...(s.bicyclists || []), ...(s.pedestrians || []),
+ ];
+ const allRoad = movers.every(m =>
+ s.cellAt(m.from.x, m.from.y).type === 'road' &&
+ s.cellAt(m.to.x, m.to.y).type === 'road');
+ return {
+ cars: (s.trafficCars || []).length,
+ buses: (s.buses || []).length,
+ bikes: (s.bicyclists || []).length,
+ peds: (s.pedestrians || []).length,
+ allRoad,
+ ambientReady: s.ambientReady,
+ };
});
if (!info.ambientReady) throw new Error('ambient systems not ready');
- if (info.count === 0) throw new Error('no traffic cars spawned');
- if (!info.allRoad) throw new Error('a traffic car left the road graph');
+ if (info.cars === 0) throw new Error('no traffic cars spawned');
+ if (info.buses === 0) throw new Error('no buses spawned');
+ if (info.bikes === 0) throw new Error('no bicyclists spawned');
+ if (info.peds < 20) throw new Error(`expected a bustling crowd, got ${info.peds} pedestrians`);
+ if (!info.allRoad) throw new Error('an ambient mover left the road graph');
});
// ── On-screen touch controls ────────────────────────────────────────────────