Phase 1: road network and heat

Roads are a jittered 7x7 grid with ~a quarter of the edges thinned out, kept
connected. The loops are the point: "take a different road" is not a decision
without alternative routes.

Every metre driven adds heat to that segment; all roads shed it slowly. Crossing
a threshold escalates Clear -> Patrol -> Barricade -> Turret, each of which puts
real obstructions on the tarmac. Hysteresis stops a road on a boundary from
rebuilding its barricade every few seconds.

Level changes are deferred while the car is on the segment. Building a barricade
around the player would spawn a static collider inside the chassis, and the
player is meant to discover escalation by returning to a road, not by watching
it assemble behind them.

Checkpoint positions are seeded from the segment id, so a stretch of road always
fortifies in the same place — recognising it is part of learning the map.

Scenery now avoids the tarmac and the car spawns at a junction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-07 07:47:19 +02:00
parent a66032674d
commit 506d07be5d
14 changed files with 830 additions and 49 deletions

View File

@ -1,8 +1,9 @@
# Drive Between the Lines — Phase 0 prototype
# Drive Between the Lines — prototype
Endless driving survival game. This is the **Phase 0** skeleton from the roadmap:
a drivable car on a flat plate with scattered obstacles, plus the persistent wear
system that makes condition felt through the wheel.
Endless driving survival game.
- **Phase 0** — a drivable car with persistent wear that is felt through the wheel.
- **Phase 1** — a road network whose roads remember being driven, and escalate.
## Running
@ -25,6 +26,38 @@ npm test # sim + headless physics
npm run build # typecheck + production bundle
```
## Road heat
Every metre driven on a road adds heat to that segment; every road everywhere
sheds it slowly. Cross a threshold and the road escalates:
`Clear → Patrol → Barricade → Turret`
- **Patrol** parks a vehicle on the verge. The road narrows.
- **Barricade** puts concrete across it, leaving a gap you have to slow for.
- **Turret** adds a tower overlooking the checkpoint.
Roughly four traversals take a road from clear to turret; an untouched road
cools off in about four minutes. Both numbers are in `sim/heat.ts` and both are
guesses meant to be tuned.
Three things are deliberate:
- **No AI yet.** The props are stationary hazards. Escalation currently means the
road gets slower and more expensive to get wrong, which is enough to test the
phase's real question.
- **Changes never happen on the road you are on.** They are deferred until you
leave. Otherwise a barricade would spawn a static collider inside your car —
and you are supposed to discover escalation by *coming back*, not by watching
it assemble behind you.
- **A checkpoint's position is seeded from its segment**, so a given stretch of
road always fortifies in the same place. Recognising it is part of learning
the map.
The road network is a jittered grid with roughly a quarter of its edges removed,
keeping the whole thing connected. The loops are the point: "take a different
road this time" is not a decision unless alternative routes exist.
## Layout
The one rule worth keeping: **`src/sim/` imports neither three.js nor Rapier.**
@ -35,14 +68,20 @@ ever opening a browser.
```
src/
sim/ pure model — world generation, car condition → handling
sim/ pure model — roads, heat, world generation, condition → handling
physics/ Rapier world, raycast vehicle, input → wheel forces
render/ three.js scene, chase camera
render/ three.js scene, roads, chase camera
core/ fixed-timestep loop, seeded RNG, keyboard
ui/ debug HUD
carSpec.ts shared car dimensions, so body and mesh cannot drift apart
heatProps.ts integration layer: heat levels → colliders + meshes
```
`heatProps.ts` sits at the top level on purpose — it is the one module allowed to
touch both Rapier and three.js, because it owns objects that must exist in both
or neither. Its tests run headless: three.js scene graphs work fine in Node, so
"the barricade's collider was removed along with its mesh" is a unit test.
Data flows one way: `sim``physics``render`. Condition reaches the physics
layer already digested into a `Handling` by `deriveHandling`, so there is exactly
one place where "how broken the car is" turns into "how it drives".
@ -56,8 +95,13 @@ one place where "how broken the car is" turns into "how it drives".
rather than hours. Turn them down in `applyWear` once the curve reads right.
- **No interpolation** between physics steps. Fine at 60 Hz; revisit if the step
rate changes.
- **Condition is not yet persisted** across reloads. IndexedDB comes with the
campaign layer.
- **Condition and heat are not yet persisted** across reloads. IndexedDB comes
with the campaign layer.
- **Heat has no diegetic signal at a distance.** You learn a road is hot by
arriving at the checkpoint. The brief wants it readable from patrol density and
wreckage before you commit — that needs the enemy presence Phase 5 brings.
- **The heat HUD lines are debug scaffolding.** The brief is explicit that no
numeric heat meter ships; they exist to tune the curve and should come out.
- **Bundle:** ~2.7 MB raw / ~945 KB gzipped, dominated by Rapier's WASM, which
`rapier3d-compat` inlines as base64. Switching to the non-compat `@dimforge/rapier3d`
package serves the WASM as a separate file (~570 KB gzipped, compiled in
@ -66,5 +110,11 @@ one place where "how broken the car is" turns into "how it drives".
## Next
Phase 0's gate: drive for 1520 minutes, feel the condition changing how the car
handles, and want to keep driving anyway. Everything else waits on that.
Phase 1's gate: **you catch yourself avoiding a road because of its history, not
its distance.** That cannot be checked by a test — it needs you driving the same
routes for a while and noticing what you start doing.
If it fails, the likely culprits, in order: escalation is too slow to matter
within a session (`METRES_PER_HEAT`), decay is so fast that nothing accumulates
(`DECAY_PER_SECOND`), or the props are not actually inconvenient enough to route
around. Tune before building Phase 2 on top.

View File

@ -20,7 +20,8 @@ export const CAR = {
offsetY: -0.15,
suspensionRestLength: 0.32,
},
spawn: { x: 0, y: 1.2, z: 0 },
/** Only the drop height; the horizontal spawn comes from the world model. */
spawn: { y: 1.2 },
} as const;
/** Wheel order used everywhere: front-left, front-right, rear-left, rear-right. */

71
src/heatProps.test.ts Normal file
View File

@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import * as THREE from 'three';
import { createHeatProps } from './heatProps';
import { createPhysics } from './physics/physics';
import { createHeat, stepHeat } from './sim/heat';
import { generateWorld } from './sim/world';
/**
* three.js scene graphs work without a browser (only WebGLRenderer needs one),
* so the prop lifecycle can be checked end to end including that removing a
* barricade removes its collider, not just its mesh.
*/
describe('heat props lifecycle', () => {
it('builds and tears down colliders and meshes together', async () => {
const world = generateWorld(3, 0);
const physics = await createPhysics(world);
const scene = new THREE.Scene();
const props = createHeatProps(world.roads, physics, scene);
const heat = createHeat(world.roads);
const baseBodies = physics.rapier.bodies.len();
const baseMeshes = scene.children.length;
const drive = (metres: number) => {
for (let m = 0; m < metres; m++) {
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1 }), heat, 0);
}
};
const idle = (seconds: number) => {
for (let i = 0; i < seconds * 60; i++) {
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: null, distance: 0 }), heat, null);
}
};
drive(400);
expect(heat.level[0]).toBe('turret');
// Still on the road, so nothing has been built around the car yet.
expect(physics.rapier.bodies.len()).toBe(baseBodies);
// Leaving it lets the deferred work land.
idle(1);
const hotBodies = physics.rapier.bodies.len();
expect(hotBodies).toBeGreaterThan(baseBodies);
expect(scene.children.length - baseMeshes).toBe(hotBodies - baseBodies);
// Left alone, the road should give everything back.
idle(300);
expect(heat.level[0]).toBe('clear');
expect(physics.rapier.bodies.len()).toBe(baseBodies);
expect(scene.children.length).toBe(baseMeshes);
});
it('does not leak bodies when a road escalates through every level', async () => {
const world = generateWorld(3, 0);
const physics = await createPhysics(world);
const scene = new THREE.Scene();
const props = createHeatProps(world.roads, physics, scene);
const heat = createHeat(world.roads);
const baseBodies = physics.rapier.bodies.len();
for (let cycle = 0; cycle < 3; cycle++) {
for (let m = 0; m < 400; m++) {
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1 }), heat, 0);
}
for (let i = 0; i < 300 * 60; i++) {
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: null, distance: 0 }), heat, null);
}
expect(physics.rapier.bodies.len()).toBe(baseBodies);
}
});
});

87
src/heatProps.ts Normal file
View File

@ -0,0 +1,87 @@
/**
* Turns heat levels into things that physically exist on the road.
*
* This is an integration layer: it is allowed to touch both Rapier and three.js,
* which is why it sits outside src/sim/. The sim decides *what* should be there
* ({@link propsFor}); this only builds and tears it down.
*/
import * as THREE from 'three';
import type RAPIER from '@dimforge/rapier3d-compat';
import { propsFor, type HeatProp, type HeatState } from './sim/heat';
import type { RoadNetwork } from './sim/roads';
import type { PhysicsWorld } from './physics/physics';
const MATERIALS: Record<HeatProp['kind'], THREE.MeshStandardMaterial> = {
patrol: new THREE.MeshStandardMaterial({ color: 0x4b5540, roughness: 0.7 }),
barricade: new THREE.MeshStandardMaterial({ color: 0x9aa0a4, roughness: 0.95 }),
tower: new THREE.MeshStandardMaterial({ color: 0x54493d, roughness: 0.9 }),
};
const BOX = new THREE.BoxGeometry(1, 1, 1);
interface Placed {
bodies: RAPIER.RigidBody[];
meshes: THREE.Mesh[];
}
export function createHeatProps(
roads: RoadNetwork,
physics: PhysicsWorld,
scene: THREE.Scene,
) {
const placed = new Map<number, Placed>();
/** Level changes waiting for the player to get off the road in question. */
const pending = new Set<number>();
const clear = (segmentId: number) => {
const existing = placed.get(segmentId);
if (!existing) return;
for (const body of existing.bodies) physics.removeBody(body);
for (const mesh of existing.meshes) scene.remove(mesh);
placed.delete(segmentId);
};
const build = (segmentId: number, heat: HeatState) => {
const segment = roads.segments[segmentId]!;
const props = propsFor(segment, heat.level[segmentId]!);
if (props.length === 0) return;
const entry: Placed = { bodies: [], meshes: [] };
for (const prop of props) {
entry.bodies.push(physics.addStaticBox(prop));
const mesh = new THREE.Mesh(BOX, MATERIALS[prop.kind]);
mesh.scale.set(prop.width, prop.height, prop.depth);
mesh.position.set(prop.x, prop.height / 2, prop.z);
mesh.rotation.y = prop.yaw;
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
entry.meshes.push(mesh);
}
placed.set(segmentId, entry);
};
return {
/**
* Rebuild the segments whose level changed but never the one the car is
* currently on. A barricade appearing around the player would spawn a static
* collider inside the chassis, and watching a checkpoint assemble itself in
* the mirror would break the fiction anyway. Changes wait until they are
* out of sight, which is how the player is meant to find them: by returning.
*
* Call every step, not only when something changed, so deferred work drains.
*/
sync(changedSegmentIds: readonly number[], heat: HeatState, occupiedSegmentId: number | null) {
for (const id of changedSegmentIds) pending.add(id);
if (pending.size === 0) return;
for (const id of [...pending]) {
if (id === occupiedSegmentId) continue;
pending.delete(id);
clear(id);
build(id, heat);
}
},
};
}

View File

@ -1,5 +1,8 @@
import { generateWorld } from './sim/world';
import { applyWear, deriveHandling, freshCondition } from './sim/car';
import { createHeat, stepHeat } from './sim/heat';
import { segmentAt } from './sim/roads';
import { createHeatProps } from './heatProps';
import { seedFromString } from './core/rng';
import { startLoop } from './core/loop';
import { createInput } from './core/input';
@ -25,10 +28,13 @@ async function boot() {
const input = createInput();
const hud = createHud(seed);
const driveState = createDriveState();
const heat = createHeat(model.roads);
const heatProps = createHeatProps(model.roads, physics, view.scene);
let condition = freshCondition();
let elapsed = 0;
let respawnLatch = false;
let currentSegment: number | null = null;
document.getElementById('boot')?.remove();
@ -45,12 +51,18 @@ async function boot() {
// Wear is applied from what actually happened this step, not from intent.
const speed = physics.vehicle.currentVehicleSpeed();
const distance = Math.abs(speed) * dt;
condition = applyWear(condition, {
dt,
distance: Math.abs(speed) * dt,
distance,
throttle: Math.abs(cmd.throttle),
impactForce: physics.drainImpactForce(),
});
// Heat: the road under the wheels remembers being used.
const at = physics.chassis.translation();
currentSegment = segmentAt(model.roads, at.x, at.z)?.id ?? null;
heatProps.sync(stepHeat(heat, { dt, segmentId: currentSegment, distance }), heat, currentSegment);
},
render(_alpha, frameDt) {
@ -84,7 +96,12 @@ async function boot() {
view.followSun();
updateCamera(view, frameDt, physics.vehicle.currentVehicleSpeed());
view.renderer.render(view.scene, view.camera);
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed);
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, {
segmentId: currentSegment,
value: currentSegment === null ? 0 : heat.value[currentSegment]!,
level: currentSegment === null ? null : heat.level[currentSegment]!,
hottest: Math.max(...heat.value),
});
},
});
}

View File

@ -10,7 +10,9 @@ const IDLE: DriverInput = { throttle: 0, steer: 0, handbrake: false, respawn: fa
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */
async function run(input: Partial<DriverInput>, seconds: number) {
const physics = await createPhysics(generateWorld(1, 0));
const world = generateWorld(1, 0);
const physics = await createPhysics(world);
const start = physics.chassis.translation();
const state = createDriveState();
const handling = deriveHandling(freshCondition());
const cmd = { ...IDLE, ...input };
@ -21,8 +23,10 @@ async function run(input: Partial<DriverInput>, seconds: number) {
physics.step(STEP);
maxYawRate = Math.max(maxYawRate, Math.abs(physics.chassis.angvel().y));
}
const now = physics.chassis.translation();
return {
pos: physics.chassis.translation(),
// Displacement from the spawn point, which is a road junction, not the origin.
pos: { x: now.x - start.x, y: now.y, z: now.z - start.z },
speed: physics.vehicle.currentVehicleSpeed(),
maxYawRate,
grounded: [0, 1, 2, 3].every((i) => physics.vehicle.wheelIsInContact(i)),

View File

@ -13,6 +13,18 @@ export interface PhysicsWorld {
drainImpactForce(): number;
step(dt: number): void;
respawn(): void;
/** Static box, added and removed at runtime as road heat rises and falls. */
addStaticBox(box: StaticBox): RAPIER.RigidBody;
removeBody(body: RAPIER.RigidBody): void;
}
export interface StaticBox {
x: number;
z: number;
yaw: number;
width: number;
height: number;
depth: number;
}
/** Contacts weaker than this are just kerb-scrubbing, not damage. */
@ -55,7 +67,7 @@ export async function createPhysics(model: WorldModel): Promise<PhysicsWorld> {
// --- Car chassis ---
const chassis = world.createRigidBody(
RAPIER.RigidBodyDesc.dynamic()
.setTranslation(CAR.spawn.x, CAR.spawn.y, CAR.spawn.z)
.setTranslation(model.spawn.x, CAR.spawn.y, model.spawn.z)
.setLinearDamping(0.1)
.setAngularDamping(0.4)
// The mass comes from here, not from collider density, so the centre of mass
@ -127,8 +139,25 @@ export async function createPhysics(model: WorldModel): Promise<PhysicsWorld> {
return v;
},
addStaticBox(box) {
const body = world.createRigidBody(
RAPIER.RigidBodyDesc.fixed()
.setTranslation(box.x, box.height / 2, box.z)
.setRotation({ x: 0, y: Math.sin(box.yaw / 2), z: 0, w: Math.cos(box.yaw / 2) }),
);
world.createCollider(
RAPIER.ColliderDesc.cuboid(box.width / 2, box.height / 2, box.depth / 2).setFriction(0.8),
body,
);
return body;
},
removeBody(body) {
world.removeRigidBody(body);
},
respawn() {
chassis.setTranslation({ x: CAR.spawn.x, y: CAR.spawn.y, z: CAR.spawn.z }, true);
chassis.setTranslation({ x: model.spawn.x, y: CAR.spawn.y, z: model.spawn.z }, true);
chassis.setRotation({ x: 0, y: 0, z: 0, w: 1 }, true);
chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
chassis.setAngvel({ x: 0, y: 0, z: 0 }, true);

View File

@ -60,6 +60,27 @@ export function createScene(model: WorldModel): SceneView {
grid.position.y = 0.02;
scene.add(grid);
// --- Roads ---
const roadMat = new THREE.MeshStandardMaterial({ color: 0x23262a, roughness: 1 });
const quad = new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2);
for (const s of model.roads.segments) {
const strip = new THREE.Mesh(quad, roadMat);
strip.scale.set(s.width, 1, s.length);
strip.position.set((s.ax + s.bx) / 2, 0.03, (s.az + s.bz) / 2);
strip.rotation.y = Math.atan2(s.bx - s.ax, s.bz - s.az);
strip.receiveShadow = true;
scene.add(strip);
}
// Discs fill the wedge-shaped gaps where segments meet at an angle.
const junction = new THREE.CircleGeometry(1, 20).rotateX(-Math.PI / 2);
for (const n of model.roads.nodes) {
const disc = new THREE.Mesh(junction, roadMat);
disc.scale.setScalar(4.5);
disc.position.set(n.x, 0.031, n.z);
disc.receiveShadow = true;
scene.add(disc);
}
// --- Obstacles ---
const blockMat = new THREE.MeshStandardMaterial({ color: 0x767c82, roughness: 0.9 });
const crateMat = new THREE.MeshStandardMaterial({ color: 0xa9773f, roughness: 0.8 });

147
src/sim/heat.test.ts Normal file
View File

@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest';
import { generateRoads, segmentAt, distanceToRoad, projectOntoSegment } from './roads';
import { createHeat, levelFor, propsFor, stepHeat, type HeatState } from './heat';
import { generateWorld } from './world';
const roads = generateRoads(99, 220);
describe('road network', () => {
it('is reproducible and connected', () => {
expect(generateRoads(99, 220)).toEqual(roads);
// Every node must be reachable, or part of the map is unusable.
const adjacency = new Map<number, number[]>();
for (const s of roads.segments) {
adjacency.set(s.a, [...(adjacency.get(s.a) ?? []), s.b]);
adjacency.set(s.b, [...(adjacency.get(s.b) ?? []), s.a]);
}
const seen = new Set([roads.nodes[0]!.id]);
const stack = [roads.nodes[0]!.id];
while (stack.length) {
for (const next of adjacency.get(stack.pop()!) ?? []) {
if (seen.has(next)) continue;
seen.add(next);
stack.push(next);
}
}
expect(seen.size).toBe(roads.nodes.length);
});
it('offers alternative routes, not just a tree', () => {
// More edges than nodes-1 means the graph contains loops — which is the
// whole premise of "take a different road this time".
expect(roads.segments.length).toBeGreaterThan(roads.nodes.length);
});
it('locates the segment under a point on it, and none off it', () => {
const s = roads.segments[3]!;
const mid = { x: (s.ax + s.bx) / 2, z: (s.az + s.bz) / 2 };
expect(segmentAt(roads, mid.x, mid.z)?.id).toBe(s.id);
expect(projectOntoSegment(s, mid.x, mid.z).t).toBeCloseTo(0.5, 5);
expect(distanceToRoad(roads, mid.x, mid.z)).toBeLessThan(0);
});
});
describe('world', () => {
it('spawns the car on the network and keeps scenery off the tarmac', () => {
const world = generateWorld(5);
expect(segmentAt(world.roads, world.spawn.x, world.spawn.z)).not.toBeNull();
for (const o of world.obstacles) {
const reach = Math.hypot(o.width, o.depth) / 2;
expect(distanceToRoad(world.roads, o.x, o.z)).toBeGreaterThan(reach);
}
});
});
/** Drive `metres` along one segment, then idle for `idleSeconds`. */
function simulate(state: HeatState, segmentId: number, metres: number, idleSeconds = 0) {
const changed: number[] = [];
const stepMetres = 1;
for (let m = 0; m < metres; m += stepMetres) {
changed.push(...stepHeat(state, { dt: 1 / 60, segmentId, distance: stepMetres }));
}
for (let i = 0; i < idleSeconds * 60; i++) {
changed.push(...stepHeat(state, { dt: 1 / 60, segmentId: null, distance: 0 }));
}
return changed;
}
describe('heat', () => {
it('escalates through every level as a road is reused', () => {
const state = createHeat(roads);
const seen: string[] = [];
for (let trip = 0; trip < 6; trip++) {
simulate(state, 0, 80);
seen.push(state.level[0]!);
}
expect(seen).toContain('patrol');
expect(seen).toContain('barricade');
expect(seen).toContain('turret');
});
it('leaves unused roads alone', () => {
const state = createHeat(roads);
simulate(state, 0, 300);
expect(state.value[0]).toBeGreaterThan(0.5);
expect(state.value[1]).toBe(0);
});
it('cools a road that goes unused', () => {
const state = createHeat(roads);
simulate(state, 0, 300);
const hot = state.value[0]!;
simulate(state, 1, 0, 120);
expect(state.value[0]).toBeLessThan(hot - 0.4);
});
it('does not flap between levels while hovering on a threshold', () => {
const state = createHeat(roads);
// Park heat just above the patrol threshold, then hold it there.
simulate(state, 0, 90);
expect(state.level[0]).toBe('patrol');
let flips = 0;
for (let i = 0; i < 600; i++) {
// Roughly cancel the decay, so heat sits on the boundary.
const changed = stepHeat(state, { dt: 1 / 60, segmentId: 0, distance: 0.022 });
flips += changed.length;
}
expect(flips).toBe(0);
});
it('hysteresis means a level survives a small dip below its threshold', () => {
expect(levelFor(0.24, 'clear')).toBe('clear');
expect(levelFor(0.25, 'clear')).toBe('patrol');
expect(levelFor(0.2, 'patrol')).toBe('patrol');
expect(levelFor(0.16, 'patrol')).toBe('clear');
});
});
describe('heat props', () => {
const segment = roads.segments[0]!;
it('puts nothing on a clear road and escalates from there', () => {
expect(propsFor(segment, 'clear')).toEqual([]);
const counts = (['patrol', 'barricade', 'turret'] as const).map(
(l) => propsFor(segment, l).length,
);
expect(counts[0]).toBeLessThan(counts[1]!);
expect(counts[1]).toBeLessThan(counts[2]!);
});
it('is stable for a segment, so a checkpoint stays where you left it', () => {
expect(propsFor(segment, 'turret')).toEqual(propsFor(segment, 'turret'));
});
it('leaves a gap in the barricade rather than sealing the road', () => {
const blocks = propsFor(segment, 'barricade').filter((p) => p.kind === 'barricade');
const spanned = blocks.reduce((sum, b) => sum + b.width, 0);
expect(spanned).toBeLessThan(segment.width - 3);
});
it('keeps patrols and barricades within the road, and towers beside it', () => {
for (const prop of propsFor(segment, 'turret')) {
const { distance } = projectOntoSegment(segment, prop.x, prop.z);
if (prop.kind === 'tower') expect(distance).toBeGreaterThan(segment.width / 2);
else expect(distance).toBeLessThan(segment.width / 2);
}
});
});

142
src/sim/heat.ts Normal file
View File

@ -0,0 +1,142 @@
/**
* Road heat. Pure no three.js, no Rapier.
*
* "Reuse has consequences": every metre driven on a road makes that road worse,
* and roads left alone quietly recover. The player is never shown a number the
* escalation is meant to be read off what is physically sitting in the road.
*/
import { makeRng, randRange } from '../core/rng';
import { pointOnSegment, type RoadNetwork, type RoadSegment } from './roads';
export const HEAT_LEVELS = ['clear', 'patrol', 'barricade', 'turret'] as const;
export type HeatLevel = (typeof HEAT_LEVELS)[number];
/** Heat at which each level takes hold. */
const THRESHOLDS: Record<Exclude<HeatLevel, 'clear'>, number> = {
patrol: 0.25,
barricade: 0.55,
turret: 0.85,
};
/**
* A level, once established, holds until heat drops this far below its threshold.
* Without it a road sitting on a boundary rebuilds and dismantles its barricade
* every few seconds.
*/
const HYSTERESIS = 0.08;
/** Metres of driving that add a full point of heat. ~4 traversals to turret. */
const METRES_PER_HEAT = 330;
/** Heat shed per second everywhere. A hot road cools in roughly four minutes. */
const DECAY_PER_SECOND = 0.004;
export interface HeatState {
/** Indexed by segment id. */
value: number[];
level: HeatLevel[];
}
export function createHeat(roads: RoadNetwork): HeatState {
return {
value: new Array(roads.segments.length).fill(0),
level: new Array(roads.segments.length).fill('clear'),
};
}
export function levelFor(heat: number, current: HeatLevel): HeatLevel {
const rank = HEAT_LEVELS.indexOf(current);
let next: HeatLevel = 'clear';
for (const name of ['patrol', 'barricade', 'turret'] as const) {
// Rising uses the plain threshold; falling has to clear the hysteresis band.
const isCurrentOrLower = HEAT_LEVELS.indexOf(name) <= rank;
const bar = THRESHOLDS[name] - (isCurrentOrLower ? HYSTERESIS : 0);
if (heat >= bar) next = name;
}
return next;
}
export interface HeatStep {
dt: number;
/** Segment the car is on right now, or null if off-road. */
segmentId: number | null;
/** Metres travelled this step. */
distance: number;
}
/** Advances heat and returns the segments whose *level* changed. */
export function stepHeat(state: HeatState, step: HeatStep): number[] {
const changed: number[] = [];
const decay = DECAY_PER_SECOND * step.dt;
for (let id = 0; id < state.value.length; id++) {
let heat = state.value[id]! - decay;
if (id === step.segmentId) heat += step.distance / METRES_PER_HEAT;
heat = Math.min(1, Math.max(0, heat));
state.value[id] = heat;
const next = levelFor(heat, state.level[id]!);
if (next !== state.level[id]) {
state.level[id] = next;
changed.push(id);
}
}
return changed;
}
export interface HeatProp {
x: number;
z: number;
yaw: number;
width: number;
height: number;
depth: number;
kind: 'patrol' | 'barricade' | 'tower';
}
/**
* What a given heat level physically puts on a road.
*
* There is no enemy AI yet, so these are stationary hazards: escalation shows up
* as the road getting harder to drive fast and more expensive to get wrong. That
* is enough to test the phase's real question do you start avoiding a road
* because of its history?
*/
export function propsFor(segment: RoadSegment, level: HeatLevel): HeatProp[] {
if (level === 'clear') return [];
// Seeded off the segment so a road's checkpoint is always in the same place —
// recognising a specific stretch is part of learning the map.
const rng = makeRng((segment.id + 1) * 0x2545f491);
const props: HeatProp[] = [];
const half = segment.width / 2;
// Patrol: parked on the verge. Narrows the road, does not block it.
const patrolT = randRange(rng, 0.3, 0.7);
const patrolSide = rng() < 0.5 ? -1 : 1;
const patrol = pointOnSegment(segment, patrolT, patrolSide * (half - 1.4));
props.push({ ...patrol, width: 2, height: 1.6, depth: 4.4, kind: 'patrol' });
if (level === 'patrol') return props;
// Barricade: blocks across the road with a gap you have to slow down for.
const barricadeT = randRange(rng, 0.35, 0.65);
const gapSide = rng() < 0.5 ? -1 : 1;
const gapCentre = gapSide * randRange(rng, half * 0.35, half * 0.6);
for (const side of [-1, 1]) {
const inner = gapCentre + side * 2.1;
const outer = side * half;
const blockWidth = Math.abs(outer - inner);
if (blockWidth < 0.8) continue;
const at = pointOnSegment(segment, barricadeT, (inner + outer) / 2);
props.push({ ...at, width: blockWidth, height: 1.5, depth: 1.6, kind: 'barricade' });
}
if (level === 'barricade') return props;
// Turret: a tower overlooking the checkpoint. Inert for now — it is a landmark
// that says "this road has been noticed", and something to collide with.
const tower = pointOnSegment(segment, barricadeT + 0.06, (rng() < 0.5 ? -1 : 1) * (half + 2.5));
props.push({ ...tower, width: 3, height: 6, depth: 3, kind: 'tower' });
return props;
}

178
src/sim/roads.ts Normal file
View File

@ -0,0 +1,178 @@
/**
* The road network. Pure data no three.js, no Rapier.
*
* A jittered grid with some edges removed. The grid matters: the central choice
* of the game is "reuse this road or take another one", which only exists if
* there are genuinely alternative routes between the same two places. Removed
* edges give the network character; the connectivity check stops removal from
* stranding a corner of the map.
*/
import { makeRng, randRange, type Rng } from '../core/rng';
export interface RoadNode {
id: number;
x: number;
z: number;
}
export interface RoadSegment {
id: number;
a: number;
b: number;
ax: number;
az: number;
bx: number;
bz: number;
length: number;
width: number;
}
export interface RoadNetwork {
nodes: RoadNode[];
segments: RoadSegment[];
}
const GRID = 7;
const ROAD_WIDTH = 9;
/** Fraction of grid spacing a node may wander from its lattice point. */
const JITTER = 0.3;
/** Share of edges to try to remove, budget permitting. */
const THINNING = 0.28;
export function generateRoads(seed: number, extent: number): RoadNetwork {
const rng: Rng = makeRng(seed ^ 0x9e3779b9);
const spacing = (extent * 2) / (GRID - 1);
const nodes: RoadNode[] = [];
for (let row = 0; row < GRID; row++) {
for (let col = 0; col < GRID; col++) {
nodes.push({
id: row * GRID + col,
x: -extent + col * spacing + randRange(rng, -1, 1) * spacing * JITTER,
z: -extent + row * spacing + randRange(rng, -1, 1) * spacing * JITTER,
});
}
}
// Full lattice first, then thin it out.
const edges: Array<[number, number]> = [];
for (let row = 0; row < GRID; row++) {
for (let col = 0; col < GRID; col++) {
const i = row * GRID + col;
if (col + 1 < GRID) edges.push([i, i + 1]);
if (row + 1 < GRID) edges.push([i, i + GRID]);
}
}
const order = shuffle(edges.map((_, i) => i), rng);
const removed = new Set<number>();
const budget = Math.floor(edges.length * THINNING);
for (const idx of order) {
if (removed.size >= budget) break;
removed.add(idx);
// A road that cuts the map in two is worse than a boring one.
if (!isConnected(nodes.length, edges, removed)) removed.delete(idx);
}
const segments: RoadSegment[] = [];
edges.forEach((edge, idx) => {
if (removed.has(idx)) return;
const a = nodes[edge[0]]!;
const b = nodes[edge[1]]!;
segments.push({
id: segments.length,
a: a.id,
b: b.id,
ax: a.x,
az: a.z,
bx: b.x,
bz: b.z,
length: Math.hypot(b.x - a.x, b.z - a.z),
width: ROAD_WIDTH,
});
});
return { nodes, segments };
}
function shuffle<T>(items: T[], rng: Rng): T[] {
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[items[i], items[j]] = [items[j]!, items[i]!];
}
return items;
}
function isConnected(
nodeCount: number,
edges: Array<[number, number]>,
removed: ReadonlySet<number>,
): boolean {
const adjacency: number[][] = Array.from({ length: nodeCount }, () => []);
edges.forEach(([a, b], idx) => {
if (removed.has(idx)) return;
adjacency[a]!.push(b);
adjacency[b]!.push(a);
});
const seen = new Uint8Array(nodeCount);
const stack = [0];
seen[0] = 1;
let visited = 1;
while (stack.length) {
for (const next of adjacency[stack.pop()!]!) {
if (seen[next]) continue;
seen[next] = 1;
visited++;
stack.push(next);
}
}
return visited === nodeCount;
}
/** Squared distance from a point to a segment, plus how far along it fell (0..1). */
export function projectOntoSegment(
s: RoadSegment,
x: number,
z: number,
): { distance: number; t: number } {
const dx = s.bx - s.ax;
const dz = s.bz - s.az;
const lenSq = dx * dx + dz * dz;
const t = lenSq === 0 ? 0 : Math.max(0, Math.min(1, ((x - s.ax) * dx + (z - s.az) * dz) / lenSq));
return { distance: Math.hypot(x - (s.ax + dx * t), z - (s.az + dz * t)), t };
}
/** Nearest segment the point is actually *on*, or null if off-road. */
export function segmentAt(roads: RoadNetwork, x: number, z: number): RoadSegment | null {
let best: RoadSegment | null = null;
let bestDistance = Infinity;
for (const s of roads.segments) {
const { distance } = projectOntoSegment(s, x, z);
if (distance < s.width / 2 && distance < bestDistance) {
bestDistance = distance;
best = s;
}
}
return best;
}
/** Distance to the nearest road surface, used to keep scenery off the tarmac. */
export function distanceToRoad(roads: RoadNetwork, x: number, z: number): number {
let best = Infinity;
for (const s of roads.segments) {
best = Math.min(best, projectOntoSegment(s, x, z).distance - s.width / 2);
}
return best;
}
/** A point a fraction `t` along the segment, offset `side` metres to its left. */
export function pointOnSegment(s: RoadSegment, t: number, side: number) {
const dx = (s.bx - s.ax) / s.length;
const dz = (s.bz - s.az) / s.length;
return {
x: s.ax + (s.bx - s.ax) * t + dz * side,
z: s.az + (s.bz - s.az) * t - dx * side,
yaw: Math.atan2(s.bx - s.ax, s.bz - s.az),
};
}

View File

@ -12,8 +12,9 @@ describe('world generation', () => {
});
it('keeps the spawn point clear', () => {
for (const o of generateWorld(7).obstacles) {
expect(Math.hypot(o.x, o.z)).toBeGreaterThanOrEqual(12);
const world = generateWorld(7);
for (const o of world.obstacles) {
expect(Math.hypot(o.x - world.spawn.x, o.z - world.spawn.z)).toBeGreaterThanOrEqual(12);
}
});
});

View File

@ -6,6 +6,7 @@
* of engine imports is what makes those systems unit-testable and fast-forwardable.
*/
import { makeRng, randRange, type Rng } from '../core/rng';
import { distanceToRoad, generateRoads, type RoadNetwork } from './roads';
export interface Obstacle {
x: number;
@ -23,24 +24,36 @@ export interface WorldModel {
seed: number;
/** Half-width of the drivable plate, in metres. */
extent: number;
roads: RoadNetwork;
obstacles: Obstacle[];
/** Where the car starts: a junction, so the network is reachable immediately. */
spawn: { x: number; z: number };
}
const SPAWN_CLEARANCE = 12;
/** Scenery this close to tarmac would read as a roadblock. Heat places those. */
const ROAD_CLEARANCE = 2.5;
export function generateWorld(seed: number, count = 140, extent = 220): WorldModel {
export function generateWorld(seed: number, count = 200, extent = 220): WorldModel {
const rng: Rng = makeRng(seed);
const roads = generateRoads(seed, extent);
const obstacles: Obstacle[] = [];
while (obstacles.length < count) {
const spawnNode = roads.nodes.reduce((best, n) =>
Math.hypot(n.x, n.z) < Math.hypot(best.x, best.z) ? n : best,
);
const spawn = { x: spawnNode.x, z: spawnNode.z };
let attempts = 0;
while (obstacles.length < count && attempts < count * 40) {
attempts++;
const x = randRange(rng, -extent, extent);
const z = randRange(rng, -extent, extent);
// Leave the player's spawn point clear so the car never starts inside a wall.
if (Math.hypot(x, z) < SPAWN_CLEARANCE) continue;
if (Math.hypot(x - spawn.x, z - spawn.z) < SPAWN_CLEARANCE) continue;
const crate = rng() < 0.45;
obstacles.push(
crate
const obstacle: Obstacle = crate
? {
x,
z,
@ -58,9 +71,15 @@ export function generateWorld(seed: number, count = 140, extent = 220): WorldMod
depth: randRange(rng, 1.5, 5),
yaw: rng() * Math.PI,
kind: 'block',
},
);
};
// Roads have to stay drivable — obstruction is heat's job, not scenery's.
// Measured from the box's corner, since it may be rotated any which way.
const reach = Math.hypot(obstacle.width, obstacle.depth) / 2;
if (distanceToRoad(roads, x, z) < ROAD_CLEARANCE + reach) continue;
obstacles.push(obstacle);
}
return { seed, extent, obstacles };
return { seed, extent, roads, obstacles, spawn };
}

View File

@ -1,4 +1,5 @@
import type { CarCondition } from '../sim/car';
import type { HeatLevel } from '../sim/heat';
const BAR_WIDTH = 12;
@ -7,12 +8,19 @@ function bar(value: number): string {
return '█'.repeat(filled) + '·'.repeat(BAR_WIDTH - filled);
}
export interface HeatReadout {
segmentId: number | null;
value: number;
level: HeatLevel | null;
hottest: number;
}
export function createHud(seed: number) {
const el = document.getElementById('hud')!;
let last = 0;
return {
update(speedMs: number, condition: CarCondition, now: number) {
update(speedMs: number, condition: CarCondition, now: number, heat: HeatReadout) {
// The HUD is debug scaffolding, not the real interface — 10 Hz is plenty.
if (now - last < 0.1) return;
last = now;
@ -23,6 +31,12 @@ export function createHud(seed: number) {
`tires ${bar(condition.tires)} ${(condition.tires * 100).toFixed(0)}%`,
`chassis ${bar(condition.chassis)} ${(condition.chassis * 100).toFixed(0)}%`,
'',
// Debug only. The real game signals heat through the road itself —
// see the design brief: no numeric meter ships.
`[debug] road ${heat.segmentId === null ? 'off-road' : `#${heat.segmentId}`}`,
`[debug] heat ${bar(heat.value)} ${heat.level ?? '—'}`,
`[debug] hottest ${bar(heat.hottest)}`,
'',
`seed ${seed}`,
'WASD drive · space handbrake · R respawn',
].join('\n');