Heat: credit the verge to the road, and block the whole corridor

Driving alongside a road accrued no heat at all, so the dominant strategy was
to never actually use one. Heat is now credited to any road within 12m of the
centreline, tarmac or verge.

The cutoff is hard rather than tapered: a taper leaves a gradient to optimise
along, where sitting at 80% of the catchment costs 20% of the heat. A cliff
edge means you either use the route or genuinely leave it.

12m is a balance, not a guess. It puts ~7.5m of verge past the kerb — two car
widths, so hugging the shoulder gains nothing — while leaving 57% of the map
off-route. At 20m two thirds of the map counts as on-route, heat becomes
unavoidable everywhere, and choosing a different route stops meaning anything.

Barricades now span the full corridor for the same reason. Stopping at the kerb
left them free to round on the verge at speed.

Junction heat is still deferred; a point near a junction is credited to the
nearest segment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-07 09:41:52 +02:00
parent 506d07be5d
commit 475db8773c
6 changed files with 202 additions and 27 deletions

View File

@ -28,8 +28,8 @@ 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:
Every metre driven **on or alongside** a road adds heat to that segment; every
road everywhere sheds it slowly. Cross a threshold and the road escalates:
`Clear → Patrol → Barricade → Turret`
@ -41,7 +41,27 @@ 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:
### The route corridor
Heat is credited to a road for anything within `ROUTE_CATCHMENT` (12 m) of its
centreline, not just the 9 m of tarmac. Otherwise the dominant strategy is to
drive *next to* every road and never accrue heat at all.
Two consequences follow from that, and both are deliberate:
- **The cutoff is hard, not a taper.** A taper leaves a gradient to optimise
along — sit at 80% of the catchment, take 20% of the heat. A cliff edge means
you either use the route or you actually leave it.
- **Barricades span the full corridor**, well past the kerb. A checkpoint that
stopped at the tarmac would be free to round on the verge at speed.
12 m is chosen, not arbitrary: it puts ~7.5 m of verge beyond the kerb (about
two car widths, so hugging the shoulder gains nothing) while leaving 57% of the
map genuinely off-route. Widening it backfires — at 20 m two thirds of the map
counts as on-route, heat becomes unavoidable, and "take a different route" stops
being a choice at all.
### Other deliberate choices
- **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
@ -97,6 +117,14 @@ one place where "how broken the car is" turns into "how it drives".
rate changes.
- **Condition and heat are not yet persisted** across reloads. IndexedDB comes
with the campaign layer.
- **Junctions have no heat of their own.** A point near a junction is credited to
whichever segment is nearest, so a heavily used crossroads never fortifies as a
crossroads. Known gap, deferred on purpose.
- **Barricades are visually crude.** Blocking the whole 24 m corridor means two
long concrete slabs, which reads more like a wall than a checkpoint. The
gameplay shape is right; the presentation wants berms, wire, or wreckage on the
outer sections. They can also clip scenery placed on the verge — both are
static bodies, so physics is unaffected, but it looks wrong up close.
- **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.

View File

@ -1,7 +1,7 @@
import { generateWorld } from './sim/world';
import { applyWear, deriveHandling, freshCondition } from './sim/car';
import { createHeat, stepHeat } from './sim/heat';
import { segmentAt } from './sim/roads';
import { routeAt, segmentAt } from './sim/roads';
import { createHeatProps } from './heatProps';
import { seedFromString } from './core/rng';
import { startLoop } from './core/loop';
@ -34,7 +34,9 @@ async function boot() {
let condition = freshCondition();
let elapsed = 0;
let respawnLatch = false;
/** The road being travelled along — includes the verge, not just the tarmac. */
let currentSegment: number | null = null;
let onTarmac = false;
document.getElementById('boot')?.remove();
@ -59,9 +61,10 @@ async function boot() {
impactForce: physics.drainImpactForce(),
});
// Heat: the road under the wheels remembers being used.
// Heat: the road remembers being used — including being driven alongside.
const at = physics.chassis.translation();
currentSegment = segmentAt(model.roads, at.x, at.z)?.id ?? null;
currentSegment = routeAt(model.roads, at.x, at.z)?.id ?? null;
onTarmac = segmentAt(model.roads, at.x, at.z) !== null;
heatProps.sync(stepHeat(heat, { dt, segmentId: currentSegment, distance }), heat, currentSegment);
},
@ -98,6 +101,7 @@ async function boot() {
view.renderer.render(view.scene, view.camera);
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, {
segmentId: currentSegment,
onTarmac,
value: currentSegment === null ? 0 : heat.value[currentSegment]!,
level: currentSegment === null ? null : heat.level[currentSegment]!,
hottest: Math.max(...heat.value),

View File

@ -1,5 +1,13 @@
import { describe, expect, it } from 'vitest';
import { generateRoads, segmentAt, distanceToRoad, projectOntoSegment } from './roads';
import {
generateRoads,
segmentAt,
routeAt,
distanceToRoad,
pointOnSegment,
projectOntoSegment,
ROUTE_CATCHMENT,
} from './roads';
import { createHeat, levelFor, propsFor, stepHeat, type HeatState } from './heat';
import { generateWorld } from './world';
@ -41,6 +49,51 @@ describe('road network', () => {
});
});
describe('route catchment', () => {
const s = roads.segments[3]!;
const beside = (metres: number) => pointOnSegment(s, 0.5, metres);
it('counts the verge as using the road', () => {
const verge = beside(s.width / 2 + 2);
// Off the tarmac, but still unmistakably travelling that route.
expect(segmentAt(roads, verge.x, verge.z)).toBeNull();
expect(routeAt(roads, verge.x, verge.z)?.id).toBe(s.id);
});
it('closes the drive-alongside exploit right up to the cutoff', () => {
for (const offset of [s.width / 2 + 0.5, ROUTE_CATCHMENT * 0.7, ROUTE_CATCHMENT - 0.5]) {
const p = beside(offset);
expect(routeAt(roads, p.x, p.z)?.id).toBe(s.id);
}
});
it('stops counting once you have genuinely left the route', () => {
const away = beside(ROUTE_CATCHMENT + 5);
expect(routeAt(roads, away.x, away.z)).toBeNull();
});
it('accrues heat at the same rate on the verge as on the tarmac', () => {
const onRoad = createHeat(roads);
const onVerge = createHeat(roads);
const centre = pointOnSegment(s, 0.5, 0);
const verge = beside(s.width / 2 + 2);
for (let m = 0; m < 200; m++) {
stepHeat(onRoad, {
dt: 1 / 60,
segmentId: routeAt(roads, centre.x, centre.z)!.id,
distance: 1,
});
stepHeat(onVerge, {
dt: 1 / 60,
segmentId: routeAt(roads, verge.x, verge.z)!.id,
distance: 1,
});
}
// No discount for hugging the shoulder — that is the whole point.
expect(onVerge.value[s.id]).toBeCloseTo(onRoad.value[s.id]!, 10);
});
});
describe('world', () => {
it('spawns the car on the network and keeps scenery off the tarmac', () => {
const world = generateWorld(5);
@ -131,17 +184,36 @@ describe('heat props', () => {
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('leaves a gap in the barricade rather than sealing the route', () => {
for (const seed of [0, 1, 2, 3, 4, 5, 6, 7]) {
const s = roads.segments[seed]!;
const blocks = propsFor(s, 'barricade')
.filter((p) => p.kind === 'barricade')
.sort((a, b) => a.lateral - b.lateral);
expect(blocks).toHaveLength(2);
const gap = blocks[1]!.lateral - blocks[1]!.width / 2 - (blocks[0]!.lateral + blocks[0]!.width / 2);
// Wide enough for a 1.8m car, tight enough to force a lift off the throttle.
expect(gap).toBeGreaterThan(3.5);
expect(gap).toBeLessThan(5);
}
});
it('keeps patrols and barricades within the road, and towers beside it', () => {
it('blocks the whole route corridor, not just the tarmac', () => {
const blocks = propsFor(segment, 'barricade').filter((p) => p.kind === 'barricade');
// Driving round the end of a barricade must mean leaving the route entirely.
for (const side of [-1, 1]) {
const reach = Math.max(
...blocks.map((b) => side * b.lateral + b.width / 2),
);
expect(reach).toBeGreaterThanOrEqual(ROUTE_CATCHMENT);
}
});
it('keeps patrols on the tarmac and towers off it', () => {
for (const prop of propsFor(segment, 'turret')) {
const { distance } = projectOntoSegment(segment, prop.x, prop.z);
if (prop.kind === 'patrol') expect(distance).toBeLessThan(segment.width / 2);
if (prop.kind === 'tower') expect(distance).toBeGreaterThan(segment.width / 2);
else expect(distance).toBeLessThan(segment.width / 2);
}
});
});

View File

@ -6,7 +6,7 @@
* 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';
import { pointOnSegment, ROUTE_CATCHMENT, type RoadNetwork, type RoadSegment } from './roads';
export const HEAT_LEVELS = ['clear', 'patrol', 'barricade', 'turret'] as const;
export type HeatLevel = (typeof HEAT_LEVELS)[number];
@ -30,6 +30,9 @@ const METRES_PER_HEAT = 330;
/** Heat shed per second everywhere. A hot road cools in roughly four minutes. */
const DECAY_PER_SECOND = 0.004;
/** Half the width of the opening left in a barricade. The car is 1.8m wide. */
const GAP_HALF_WIDTH = 2.1;
export interface HeatState {
/** Indexed by segment id. */
value: number[];
@ -87,6 +90,8 @@ export interface HeatProp {
x: number;
z: number;
yaw: number;
/** Offset from the road centreline, positive to the segment's left. */
lateral: number;
width: number;
height: number;
depth: number;
@ -113,30 +118,56 @@ export function propsFor(segment: RoadSegment, level: HeatLevel): HeatProp[] {
// 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' });
const patrolLateral = patrolSide * (half - 1.4);
props.push({
...pointOnSegment(segment, patrolT, patrolLateral),
lateral: patrolLateral,
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.
// Barricade: blocks with a gap you have to slow down for.
//
// It spans the full route catchment, not just the tarmac. A checkpoint that
// stopped at the kerb would be pointless — you would swing onto the verge,
// round it at speed, and still be on the route. Blocking the whole corridor
// means the only ways past are the gap or genuinely leaving the road.
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 inner = gapCentre + side * GAP_HALF_WIDTH;
const outer = side * ROUTE_CATCHMENT;
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' });
const lateral = (inner + outer) / 2;
props.push({
...pointOnSegment(segment, barricadeT, lateral),
lateral,
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' });
const towerLateral = (rng() < 0.5 ? -1 : 1) * (half + 2.5);
props.push({
...pointOnSegment(segment, barricadeT + 0.06, towerLateral),
lateral: towerLateral,
width: 3,
height: 6,
depth: 3,
kind: 'tower',
});
return props;
}

View File

@ -143,13 +143,32 @@ export function projectOntoSegment(
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 {
/**
* How far off the tarmac still counts as using a road.
*
* Without this, the obvious play is to drive alongside a road rather than on it
* and never accrue heat at all which defeats the entire system. The verge is
* part of the route as far as anyone watching it is concerned.
*
* 12m leaves roughly 7.5m of verge beyond the tarmac edge about two car widths,
* so hugging the shoulder buys nothing. Widening it is tempting but backfires:
* at 20m two thirds of the map counts as on-route, heat becomes unavoidable
* everywhere, and "take a different route" stops being a choice. At 12m about
* 57% of the map is genuinely off-route.
*/
export const ROUTE_CATCHMENT = 12;
function nearest(
roads: RoadNetwork,
x: number,
z: number,
limit: (s: RoadSegment) => 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) {
if (distance < limit(s) && distance < bestDistance) {
bestDistance = distance;
best = s;
}
@ -157,6 +176,21 @@ export function segmentAt(roads: RoadNetwork, x: number, z: number): RoadSegment
return best;
}
/** Nearest segment the point is actually *on the tarmac of*, or null. */
export const segmentAt = (roads: RoadNetwork, x: number, z: number): RoadSegment | null =>
nearest(roads, x, z, (s) => s.width / 2);
/**
* Nearest segment the point counts as *travelling along*, tarmac or verge.
*
* Deliberately a hard cutoff rather than a taper. A taper would leave a gradient
* to optimise along sit at 80% of the catchment and take 20% of the heat.
* A cliff edge means you either use the route or you genuinely leave it, and
* out there the scenery is its own punishment.
*/
export const routeAt = (roads: RoadNetwork, x: number, z: number): RoadSegment | null =>
nearest(roads, x, z, () => ROUTE_CATCHMENT);
/** 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;

View File

@ -10,6 +10,8 @@ function bar(value: number): string {
export interface HeatReadout {
segmentId: number | null;
/** False while on the verge — still counts as using the road. */
onTarmac: boolean;
value: number;
level: HeatLevel | null;
hottest: number;
@ -33,7 +35,11 @@ export function createHud(seed: number) {
'',
// 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] road ${
heat.segmentId === null
? 'off-route'
: `#${heat.segmentId} ${heat.onTarmac ? '(on road)' : '(alongside)'}`
}`,
`[debug] heat ${bar(heat.value)} ${heat.level ?? '—'}`,
`[debug] hottest ${bar(heat.hottest)}`,
'',