Three fixes, all from watching it run. Cars stuck against buildings. routeTo dropped the node a route starts from, which is fine for a unit standing on it and wrong for anything re-routing from mid-segment: its first leg became a straight line to a node two hops away, across buildings and open country. Routes now include their start node so a unit rejoins the road before setting off. No patrols anywhere. They only existed as a reaction to the player's own tracks, so hostile territory was empty until you had personally made a road notorious. Enemy ground now carries standing patrols by how dangerous it is — none behind your lines, five on the frontier — spawned onto their roads so the territory has them the moment you arrive. This surfaced a second bug: dispatched units were culled at 546m from the player while dispatch origins reached 600m, so a patrol or engineer could be deleted on its first step and simply never turn up. Anything with an assignment is now kept regardless of range; its expiry still bounds it. Barricades appearing instantly. Crossing a heat threshold placed concrete on the road with nobody involved. Construction is now staged: an engineer is dispatched for the barricade, another for the tower, and heatProps is driven by finished builds rather than by heat levels. It still refuses to close a slab around a car parked in the gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
444 lines
22 KiB
Markdown
444 lines
22 KiB
Markdown
# Drive Between the Lines — prototype
|
||
|
||
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.
|
||
- **Phase 2** — bases, a quest board, and known-vs-new target selection.
|
||
- **Phase 3** — regional control: behind your own lines, nobody is watching.
|
||
- **Phase 4** — the front line moves, from your work and on its own.
|
||
- **Phase 5** — four distinct jobs, radio texture, and work found in the field.
|
||
- **Phase 6** — an inhabited world: traffic, people, patrols, and a live war.
|
||
|
||
## Running
|
||
|
||
```bash
|
||
npm run dev
|
||
```
|
||
|
||
Then open http://localhost:5173. Append `?seed=anything` to regenerate the world —
|
||
seeds may be numbers or words.
|
||
|
||
| | |
|
||
|---|---|
|
||
| `W` / `S` | throttle / brake-reverse |
|
||
| `A` / `D` | steer |
|
||
| `Space` | handbrake (rear wheels only) |
|
||
| `R` | respawn the car — **does not** repair it |
|
||
| `1`–`3` | accept a mission, when parked at a base |
|
||
|
||
Progress saves itself every 20 seconds, and immediately whenever you take or
|
||
finish a job. Saves are per seed; `?seed=x` is a separate campaign.
|
||
|
||
Drive to a green beacon (a base) and stop. Take a job, drive to the amber
|
||
beacon, stop and hold position for three seconds, then return to **any** base —
|
||
the amber beacon goes out once the job at the target is done, since the target
|
||
is no longer anywhere you need to be.
|
||
|
||
```bash
|
||
npm test # sim + headless physics
|
||
npm run build # typecheck + production bundle
|
||
```
|
||
|
||
## Road heat
|
||
|
||
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`
|
||
|
||
- **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.
|
||
|
||
### The road hierarchy
|
||
|
||
Roads are not all the same road:
|
||
|
||
| Class | Tarmac | Speed | Attention |
|
||
|---|---|---|---|
|
||
| Track (single car) | 4.5 m | ×0.55 | ×0.55 |
|
||
| Road (two lanes) | 9 m | ×1 | ×1 |
|
||
| Trunk (four lanes) | 15 m | ×1.35 | ×1.35 |
|
||
|
||
That trade is the network doing real work rather than being the floor you drive
|
||
on: a trunk is fast and direct and exactly where anyone looking for you would
|
||
look; a lane is slow and awkward and nobody is watching it. Trunks run the whole
|
||
width of the map and are never thinned away, so there is a shape to learn — get
|
||
onto the trunk, then work down the lanes — instead of a uniform mesh where every
|
||
road is as good as every other. Route previews are costed by **travel time**, not
|
||
distance, so the board's estimate matches the way a driver would actually go.
|
||
|
||
### The route corridor
|
||
|
||
Heat is credited to a road for anything within its corridor — half the tarmac
|
||
plus a 7.5 m verge, so it scales with the class — not just the tarmac itself.
|
||
Otherwise the dominant strategy is to drive *next to* every road and never
|
||
accrue heat at all.
|
||
|
||
Two consequences follow, and both are deliberate:
|
||
|
||
- **The cutoff is hard, not a taper.** A taper leaves a gradient to optimise
|
||
along — sit at 80% of the corridor, 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.
|
||
|
||
The 7.5 m verge is about two car widths, so hugging the shoulder gains nothing,
|
||
while leaving most of the map genuinely off-route. Widening it backfires: make
|
||
the corridor too generous and heat becomes unavoidable everywhere, and "take a
|
||
different route" stops being a choice at all.
|
||
|
||
### Buildings, and why they are dense
|
||
|
||
Off-road used to be open country, which made every barricade optional — you just
|
||
swung wide around it. Buildings are now laid out on a jittered lattice (random
|
||
scatter cannot pack boxes and saturated at a third of the target), which takes
|
||
bypass lanes around a checkpoint from none obstructed to about two thirds. You
|
||
can still get around one; it means threading a maze at walking pace instead of a
|
||
free detour at speed.
|
||
|
||
### 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
|
||
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.
|
||
|
||
## Missions and intel
|
||
|
||
The board at each base offers a mix of **known targets** and **new** ones, and
|
||
describes the route to each in words rather than numbers — "barricaded · last
|
||
word 6 min ago", "no one has been down that way".
|
||
|
||
The important part is *where those words come from*. The board never reads heat
|
||
directly. It reads `sim/intel.ts`, which is a snapshot of what the player has
|
||
actually observed and when. Drive a road and you write down what was on it;
|
||
leave it alone and your note ages while the road keeps escalating without you.
|
||
So a known route is genuinely knowable, genuinely stale, and never a guarantee —
|
||
which is the tension the brief asks for.
|
||
|
||
**Recon missions** are the tool for converting a new target into a known one:
|
||
completing one surveys every road within 130 m, filling in the map without
|
||
having to drive each one.
|
||
|
||
Four jobs run end to end, and they differ in **pacing and risk**, not just in
|
||
the noun on the board — otherwise four mission types is one mission type with
|
||
four labels:
|
||
|
||
| Job | At the target | The risk |
|
||
|---|---|---|
|
||
| **Supply run** | 3s unloading | the drive out |
|
||
| **Retrieval** | 5s loading | the drive *home*, with goods that break |
|
||
| **Intel drop** | 9s transmitting | a long wait, stationary, in the open |
|
||
| **Survey** | 2s | cheapest on site; the payoff is the map |
|
||
|
||
Retrieval is the one that carries something home, and impacts damage it. It pays
|
||
the most, but it pays for what *arrives*, not what you set off with. Hand-in
|
||
works at *any* base, not the issuing one, because the brief is explicit that a
|
||
blocked road home must never strand the player.
|
||
|
||
### Work you find rather than work you are given
|
||
|
||
Past the line, and only when you have nothing in hand, someone occasionally
|
||
turns up: a wounded stranger, a contact flashing headlights, a wreck worth
|
||
stripping. They are deliberately **not** on a board — you cannot plan a route to
|
||
one, compare it against anything, or shop between them. You take the detour in
|
||
front of you or you drive on.
|
||
|
||
### Radio
|
||
|
||
Chatter that frames who you are — an undercover driver being talked at by people
|
||
busy elsewhere. Every line is triggered by something actually happening in the
|
||
sim: crossing a border, a road you have made notorious, a car past saving, or
|
||
the front genuinely drifting your way, which is the brief's "the enemy has other
|
||
priorities" said out loud without ever showing a number.
|
||
|
||
### Rewards, and why repairs exist
|
||
|
||
New targets pay `NOVELTY_BONUS` (1.8×) more than known ones. That multiplier is
|
||
the phase's tuning dial: if you never take the new target it is too low, and if
|
||
you never take the known one it is too high.
|
||
|
||
Payment is in **parts**, which repair the car — the only thing in the game that
|
||
pushes condition back up. Repairs go to the worst subsystem first and stop at
|
||
its **ceiling**, which drops permanently with every bit of damage taken
|
||
(`PERMANENT_SHARE`, currently 30%). So a car can be patched back to what it is
|
||
still capable of, but never to what it was. The HUD bars show this directly: `█`
|
||
is current condition, `·` is what repairs could still reach, `×` is gone.
|
||
|
||
This is what gives the board's reward a reason to matter. Without an economy the
|
||
choice between a safe route and a risky one has no stakes; with one, the risky
|
||
job is what keeps the car alive a while longer.
|
||
|
||
## A world with people in it
|
||
|
||
Up to Phase 5 the world was scenery. Heat poured concrete on a road and that was
|
||
the whole of the enemy. `sim/units.ts` is the layer that makes it inhabited.
|
||
|
||
**Enemy ground is patrolled because it is enemy ground.** There are standing
|
||
patrols on the roads wherever you are past the line, and more of them the deeper
|
||
in you go — none in liberated territory, one in contested, three in occupied,
|
||
five on the frontier. Nobody summoned these; they are what hostile territory
|
||
looks like.
|
||
|
||
**On top of that, nothing appears — things are sent.** When a road turns
|
||
notorious a patrol is *dispatched*: it starts at a junction a few hundred metres
|
||
away, drives there, sweeps up and down for about a minute, and while it works the
|
||
road it brings the heat back down. Then it leaves. The enemy is not punishing
|
||
you; they are re-securing a route and going home.
|
||
|
||
**Everything on a road is built, including the concrete.** At `barricade` an
|
||
engineer is dispatched and the blocks go up over about twenty seconds of someone
|
||
standing there; at `turret` another is sent and the tower takes forty. Arrive
|
||
mid-build and you find a half-finished stump with nobody on it. Only once the
|
||
tower stands does a gunner man it, up on top so he shoots over his own barricade.
|
||
Let the road cool and the whole thing is abandoned.
|
||
|
||
**Traffic and people.** Civilian cars route across the network with somewhere to
|
||
be; people wander on foot near the buildings. Both scatter when shooting starts.
|
||
|
||
### The war you are driving through
|
||
|
||
Skirmishes break out between the two armies on contested and occupied ground,
|
||
whether or not you are anywhere near. They are not a set piece for the player —
|
||
you are not invited.
|
||
|
||
Rounds are **real objects in flight**, not instant hits resolved between two
|
||
combatants. That is the whole point: if shots resolved instantly, driving through
|
||
a battle would cost nothing, and "the only road to the target runs through a
|
||
firefight" would not be a decision. A stray round does not check whose war it is,
|
||
so it will hit a bystander, a civilian, or you.
|
||
|
||
Buildings stop bullets, which is what makes cover worth anything. Hits are routed
|
||
through the same wear model as a crash, so being shot costs you ceiling too — it
|
||
is permanent in the same way everything else is.
|
||
|
||
**The enemy does not shoot at you by default.** You are an undercover driver, not
|
||
a target. They open up only past the line *and* on a road they have already
|
||
checkpointed — that is, a road you personally made notorious.
|
||
|
||
## Regional control
|
||
|
||
The map is cut by a single **front line** into four bands, running from your own
|
||
territory out past the edge of the known world:
|
||
|
||
`Liberated → Contested → Occupied → Frontier`
|
||
|
||
| Band | Heat accrual | Heat decay |
|
||
|---|---|---|
|
||
| Liberated | **×0** | ×4 |
|
||
| Contested | ×0.55 | ×1.4 |
|
||
| Occupied | ×1 | ×1 |
|
||
| Frontier | ×1.3 | ×0.8 |
|
||
|
||
Liberated accrual is exactly zero, not merely small. A trickle would eventually
|
||
fortify your own back garden, which is nonsense, and it would mean the safe area
|
||
quietly punished you for using it. Behind your own lines, nobody is writing down
|
||
which roads you take.
|
||
|
||
Decay is judged by whoever holds *the road*, not whoever holds the ground the
|
||
car is standing on — your own side dismantles checkpoints on its own roads
|
||
whether or not you are there to watch.
|
||
|
||
### The line moves
|
||
|
||
Two forces, and they are independent by design.
|
||
|
||
**Your work.** Completing a mission pushes the front forward, weighted by what
|
||
the job was worth — a long haul into unknown country moves the war more than a
|
||
local errand, and supply runs move it more than surveys. Ground won is not held
|
||
forever: the player's contribution decays over about seven minutes, so the enemy
|
||
presses back on its own.
|
||
|
||
**The war elsewhere.** The line also drifts with nobody's help, in both
|
||
directions. This is not background flavour: the brief is explicit that ambient
|
||
drift is what makes the setting a war rather than a level, and that it is the
|
||
*only* mechanism for the enemy's attention being pulled elsewhere — there is no
|
||
separate "baseline threat" system alongside it.
|
||
|
||
Drift is a sum of slow waves rather than a random walk. A walk either wanders
|
||
off the map or, once reined in, moves so little the war reads as static. Waves
|
||
at incommensurate periods are unpredictable in practice, always bounded, and
|
||
reproducible from the seed — so "the region changed while you were away" is
|
||
something a test can assert. Most of the movement is shared by every boundary,
|
||
because a front that advances advances as a whole; each band then wobbles a
|
||
little on its own so the map is not one rigid object sliding about.
|
||
|
||
In practice a boundary swings about ±45 m and a spot near a border changes hands
|
||
several times over a quarter of an hour.
|
||
|
||
**Nothing announces any of this.** There is no notification, no readout, no
|
||
number. The minimap shows who held each patch of ground *when you last stood in
|
||
it* — so after a while away, your map is simply wrong, and the only way to find
|
||
out is to go back and look.
|
||
|
||
**You are never told which band you are in by the world itself.** Crossing a
|
||
border eases the fog, the light and the palette: your own ground is open and
|
||
green, contested goes flat and hazy, occupied turns dusty and closes in, the
|
||
frontier is cold and short-sighted. The HUD names the band for now, but that
|
||
line is debug scaffolding for the same reason the heat numbers are — Phase 3's
|
||
gate is telling them apart *without* being told.
|
||
|
||
## The map
|
||
|
||
A sketch map, top right, drawn from `Intel` rather than from the world:
|
||
|
||
- **The road network's shape is always visible**, as faint dashed lines. You are
|
||
an operative with a map, not an amnesiac.
|
||
- **Roads you have actually seen** are drawn solid and coloured by the heat you
|
||
remember on them — which is what you last saw, not what is there now.
|
||
- **Ground you have driven through** is filled in, tinted by who held it *when
|
||
you were last there*. A survey mission fills in a whole area at once.
|
||
- **The border** is drawn between adjacent remembered cells that you recall as
|
||
belonging to different sides — never from the front's true position, which
|
||
would hand you a live readout of a line that is supposed to move behind your
|
||
back.
|
||
- **Old notes fade.** An observation drawn faint is one you should not trust.
|
||
|
||
Nothing on the map reads live world state. Heat is what you last saw on a road;
|
||
territory is who held it when you last stood there. Both keep changing after you
|
||
leave, and the map has no way of knowing.
|
||
|
||
The HUD also carries an eight-point compass arrow to the active target, since
|
||
the map alone does not tell you which way to point the car.
|
||
|
||
## Layout
|
||
|
||
The one rule worth keeping: **`src/sim/` imports neither three.js nor Rapier.**
|
||
Phases 1–4 (heat, regions, front lines, quests) are all simulation, and keeping
|
||
them engine-free is what makes them unit-testable and fast-forwardable — you can
|
||
run a hundred simulated days in milliseconds to tune an escalation curve without
|
||
ever opening a browser.
|
||
|
||
```
|
||
src/
|
||
sim/ pure model, no engine imports:
|
||
world, roads, heat — the map and its memory
|
||
radio, opportunities — texture: chatter and field work
|
||
units, combat — traffic, patrols, soldiers, rounds
|
||
save — the campaign as JSON
|
||
regions — the front line and who holds what
|
||
intel, routing, quests — what the player knows and is asked to do
|
||
bases, car — where missions come from, and decline
|
||
physics/ Rapier world, raycast vehicle, input → wheel forces
|
||
render/ three.js scene, roads, markers, chase camera
|
||
core/ fixed-timestep loop, seeded RNG, keyboard
|
||
ui/ debug HUD, quest board, sketch minimap
|
||
persistence.ts integration layer: saves in localStorage
|
||
debug.ts ?debug=1 handle: step, render and capture frames to shots/
|
||
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".
|
||
|
||
## Notes on the state of it
|
||
|
||
- **The car handles like a placeholder.** Suspension, grip, and engine numbers in
|
||
`physics/physics.ts` and `sim/car.ts` are first guesses that pass a smoke test,
|
||
not something tuned by feel. That tuning *is* Phase 0's verification gate.
|
||
- **Wear rates are deliberately aggressive** so decline is visible in minutes
|
||
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.
|
||
- **Nothing is persisted** across reloads — condition, heat, intel and completed
|
||
runs all reset. IndexedDB comes with the campaign layer, and `sim/intel.ts`
|
||
plus `sim/heat.ts` are the two things that most need it.
|
||
- **Repairs are automatic on hand-in.** Parts get spent the moment you report
|
||
back. The brief wants repair to be a physical, on-foot act with scavenged
|
||
parts; that is Phase 6.
|
||
- **The board's route preview assumes the shortest path.** If you drive a
|
||
different way, the intel you were shown described a route you did not take.
|
||
`findRoute` already accepts a cost function, so a "safest route" preview is a
|
||
small change when it is wanted.
|
||
- **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.
|
||
- **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
|
||
parallel with the JS) at the cost of extra Vite plugin config. Worth doing
|
||
before anyone but you plays it; not worth doing now.
|
||
|
||
## Next
|
||
|
||
Two gates are open at once, and both need a person, not a test.
|
||
|
||
**Phase 1:** you catch yourself avoiding a road because of its history, not its
|
||
distance. If it fails, check `METRES_PER_HEAT` (escalation too slow to matter in
|
||
one session), `DECAY_PER_SECOND` (nothing accumulates), or whether the props are
|
||
actually inconvenient enough to route around.
|
||
|
||
**Phase 2:** you take the new target a meaningful fraction of the time, for
|
||
reasons other than curiosity — because the known one has genuinely got
|
||
expensive. If the known option is always strictly better, raise `NOVELTY_BONUS`.
|
||
If you never take a known target, lower it.
|
||
|
||
**Phase 3:** driving into an occupied region, you can tell it from a liberated
|
||
one within a couple of minutes, from tone alone, without reading the HUD line
|
||
that names it. If you cannot, the palettes in `render/scene.ts` are too close
|
||
together — push them apart before trusting the band boundaries.
|
||
|
||
**Phase 5:** someone coming to this cold can describe what kind of game it is
|
||
and who they are playing after one session, without the premise being explained
|
||
first. If the four jobs still feel like one job with four names, the dial is
|
||
`MISSION_SHAPE` — push the work times and the cargo risk further apart before
|
||
adding anything new.
|
||
|
||
**Phase 4:** returning to a region after time away, you are genuinely unsure
|
||
what you will find until you are back on the ground — and when it has changed,
|
||
it reads as the world having moved rather than as a notification you were given.
|
||
If the world instead feels arbitrary, the drift is too fast: lengthen the wave
|
||
periods in `sim/regions.ts` before shrinking their amplitude, since it is the
|
||
*rate* of change that reads as noise, not the size of it.
|
||
|
||
**Phase 6:** the world should feel lived in before it feels dangerous — traffic
|
||
and people you have no reason to interact with, and a war that is obviously not
|
||
about you. If firefights read as encounters staged for the player, the spacing
|
||
and spawn rules in `sim/units.ts` are the dial.
|
||
|
||
Still deferred: a deeper repair economy
|
||
with scavenged parts and on-foot work, car identity and disguise feeding into
|
||
heat, and expanded combat. Nothing there should be pulled forward until the loop
|
||
below it is proven.
|
||
|
||
The most useful thing before any of that is taking the debug lines out of the
|
||
HUD. Several gates now depend on reading the world rather than a readout, and
|
||
the scaffolding actively works against you when you sit down to judge them.
|
||
|
||
Watch for one failure mode in particular: if you find yourself taking whichever
|
||
job is *nearest* and ignoring the intel line entirely, the board is decorative
|
||
and the phase has not landed, whatever the reward numbers say.
|