Braking, stutter, a real road hierarchy, denser towns, and autosave

Braking. Rapier brake impulses were far too small next to a 1100kg chassis, so
the car would not come to a standstill. Raised, plus a coast brake so lifting
off actually slows you and a hold below walking pace so "stop the car" for a
mission is not a fight. First attempt overshot to 1.9g, which reads as hitting a
wall; tuned to about 0.8g, ~25m from 70km/h, and pinned with tests that assert
both an upper and a lower bound on stopping distance.

Stutter. The render drew the raw latest physics transform, so frames that
consumed two steps or none landed as visible jitter at speed. Now interpolated
between the last two steps by the leftover accumulator. Buildings moved into one
instanced draw call and the minimap dropped to 10Hz, both of which were wasted
work in a frame budget that also has to fit a shadow pass.

Roads. Three classes: single-track lanes, two-lane roads, four-lane trunks that
cross the whole map and are never thinned away. Class drives width, the route
corridor, barricade span, map line weight, surface colour, and two things that
matter more: routing is now by travel time so journeys prefer trunks, and
attention scales with class, so a farm track is slow and unwatched while a trunk
is fast and the first place anyone looks.

Towns. Buildings were small and sparse enough to drive around freely. They are
now laid out on a jittered lattice — rejection sampling cannot pack boxes and
saturated at a third of the target — which takes bypass lanes around a barricade
from 0% obstructed to about two thirds. World scaled to 400m half-extent, 81
junctions, 107 roads.

Autosave. Every 20 seconds, plus an immediate checkpoint on taking and
completing a job. Saves carry the subsystem ceilings, what the player knew
including the parts that are now wrong, and where the war had got to. A save is
validated against seed, format version and array lengths before anything is
applied: half-restoring a campaign is worse than starting a fresh one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino
2026-08-07 13:28:15 +02:00
co-authored by Claude Opus 5
parent f5742de595
commit 90dc18fd17
16 changed files with 853 additions and 109 deletions
+14 -5
View File
@@ -11,6 +11,8 @@ const STEER_RETURN_RATE = 4.5;
* Lower = more falloff. At 12, full lock is roughly halved by 45 km/h.
*/
const STEER_SPEED_FALLOFF = 12;
/** Below this, lifting off should bring the car to rest rather than a crawl. */
const CREEP_SPEED = 1.2;
export interface DriveState {
steer: number;
@@ -48,11 +50,18 @@ export function drive(
const wantsReverse = input.throttle < 0;
const braking = input.handbrake || (wantsReverse && speed > 1) || (input.throttle > 0 && speed < -1);
const engineForce = braking ? 0 : input.throttle * handling.engineForce;
const brakeForce = input.handbrake
? handling.brakeForce * 1.6
: braking
? handling.brakeForce
: 0;
let brakeForce: number;
if (input.handbrake) brakeForce = handling.brakeForce * 1.6;
else if (braking) brakeForce = handling.brakeForce;
else if (input.throttle === 0) brakeForce = handling.coastBrake;
else brakeForce = 0;
// At a crawl with no throttle, hold the car still instead of letting it creep.
// Otherwise "stop the car" for a mission is a fight against a rolling wreck.
if (input.throttle === 0 && Math.abs(speed) < CREEP_SPEED) {
brakeForce = Math.max(brakeForce, handling.brakeForce);
}
for (let i = 0; i < WHEELS.length; i++) {
const w = WHEELS[i]!;
+69
View File
@@ -39,6 +39,75 @@ async function run(input: Partial<DriverInput>, seconds: number) {
};
}
/** Gets up to speed, then measures what happens after a given input. */
async function stopFrom(after: Partial<DriverInput>, seconds = 8) {
const world = generateWorld(1, 0);
const physics = await createPhysics(world);
const state = createDriveState();
const handling = deriveHandling(freshCondition());
for (let i = 0; i < 6 / STEP; i++) {
drive(physics, state, { ...IDLE, throttle: 1 }, handling, STEP);
physics.step(STEP);
}
const entry = physics.vehicle.currentVehicleSpeed();
const from = physics.chassis.translation();
let time = 0;
const cmd = { ...IDLE, ...after };
for (let i = 0; i < seconds / STEP; i++) {
if (Math.abs(physics.vehicle.currentVehicleSpeed()) < 0.3) break;
drive(physics, state, cmd, handling, STEP);
physics.step(STEP);
time += STEP;
}
const to = physics.chassis.translation();
return {
entrySpeed: entry,
finalSpeed: physics.vehicle.currentVehicleSpeed(),
distance: Math.hypot(to.x - from.x, to.z - from.z),
time,
};
}
describe('slowing down', () => {
it('hauls the car to a standstill on the brake', async () => {
const r = await stopFrom({ throttle: -1 });
expect(r.entrySpeed).toBeGreaterThan(12);
expect(Math.abs(r.finalSpeed)).toBeLessThan(0.3);
// Roughly road-car braking: not a parachute, not a barge. The lower bound
// matters as much as the upper one — brakes strong enough to stop in 10m
// read as hitting a wall.
expect(r.distance).toBeGreaterThan(15);
expect(r.distance).toBeLessThan(38);
expect(r.time).toBeGreaterThan(1.5);
expect(r.time).toBeLessThan(4.5);
});
it('comes to rest on its own when you simply lift off', async () => {
const r = await stopFrom({}, 20);
// The complaint this fixes: coasting used to never actually stop.
expect(Math.abs(r.finalSpeed)).toBeLessThan(0.3);
expect(r.time).toBeLessThan(16);
});
it('stops harder on the brake than on the overrun', async () => {
const braked = await stopFrom({ throttle: -1 });
const coasted = await stopFrom({}, 20);
expect(braked.distance).toBeLessThan(coasted.distance);
});
it('stops worse on bald tyres', async () => {
const physics = await createPhysics(generateWorld(1, 0));
const worn = deriveHandling({
level: { engine: 0.2, tires: 0.2, chassis: 0.2 },
ceiling: { engine: 1, tires: 1, chassis: 1 },
});
expect(worn.brakeForce).toBeLessThan(deriveHandling(freshCondition()).brakeForce);
expect(physics.vehicle.numWheels()).toBe(4);
});
});
describe('vehicle', () => {
it('settles on its suspension instead of sinking or bouncing away', async () => {
const r = await run({}, 2);