Sharper steering, solid traffic, and a map that is mostly war

Steering. The falloff with speed was linear, so the car had already lost a third
of its lock by 20km/h — exactly the speed you take a right-angle junction at, on
a map made of right-angle junctions. It is quadratic now, which leaves low
speeds nearly untouched and still calms things down at pace, plus a faster rack
and a little more lock. Pinned with a test that drives an actual quarter turn and
measures its radius. A first attempt asserted that fast corners take longer,
which is simply false: a fast car swings through ninety degrees quicker, just
across far more tarmac. Radius is what a corner costs you.

Contact. Units were points that ignored everything, so there was nothing to hit.
Vehicles now carry kinematic bodies and ramming one is a real collision that
damages them and shoves you. People get no collider on purpose — a capsule means
snagging on pedestrians or launching them — so you drive through them and they go
down, and running over a civilian is counted for later.

Territory. Liberated ground covered more than half the map because the player
started in the middle of it, so most of the world was safe by geometry. The
starting base is now at the friendly edge and the bands are anchored to give
roughly 15/25/27/33 across liberated, contested, occupied and frontier. Measured
in the running game rather than guessed, since the axis runs diagonally across a
square and area is not linear in depth.

Also fixes a fragile test that compared reward rates across whichever targets a
board happened to offer, and so mostly measured route length rather than the
novelty premium it claimed to check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino
2026-08-07 21:42:17 +02:00
co-authored by Claude Opus 5
parent 62c67ea649
commit 9ae00dc978
13 changed files with 455 additions and 30 deletions
+11 -6
View File
@@ -4,13 +4,18 @@ import type { PhysicsWorld } from './physics';
import { WHEELS } from '../carSpec';
/** How fast the steering rack follows the key, radians per second. */
const STEER_RATE = 2.6;
const STEER_RETURN_RATE = 4.5;
const STEER_RATE = 4.6;
const STEER_RETURN_RATE = 6;
/**
* Steering authority falls off with speed, or the car is undriveable at pace.
* Lower = more falloff. At 12, full lock is roughly halved by 45 km/h.
* Speed at which steering authority is halved, metres per second.
*
* The falloff is quadratic rather than linear, and that matters. Linear falloff
* starts eating lock immediately — at 20 km/h the car had already lost a third
* of its steering, which is exactly the speed you take a 90-degree junction at,
* so the map's right-angle corners were nearly impossible to hit. A quadratic
* curve leaves low speeds almost untouched and still calms the car down at pace.
*/
const STEER_SPEED_FALLOFF = 12;
const STEER_SPEED_FALLOFF = 20;
/** Below this, lifting off should bring the car to rest rather than a crawl. */
const CREEP_SPEED = 1.2;
@@ -37,7 +42,7 @@ export function drive(
// Steering: ease toward the target rather than snapping, and shrink the
// available lock as speed rises.
const authority = 1 / (1 + Math.abs(speed) / STEER_SPEED_FALLOFF);
const authority = 1 / (1 + (Math.abs(speed) / STEER_SPEED_FALLOFF) ** 2);
const target = input.steer * handling.maxSteer * authority;
const rate = input.steer === 0 ? STEER_RETURN_RATE : STEER_RATE;
const maxDelta = rate * handling.maxSteer * dt;
+65 -1
View File
@@ -109,6 +109,67 @@ describe('slowing down', () => {
});
});
/** Seconds to swing the nose through a quarter turn from a given entry speed. */
async function quarterTurn(entrySpeed: number) {
const physics = await createPhysics(generateWorld(1, 0));
const state = createDriveState();
const handling = deriveHandling(freshCondition());
// Get up to the speed you would actually take a junction at.
while (physics.vehicle.currentVehicleSpeed() < entrySpeed) {
drive(physics, state, { ...IDLE, throttle: 1 }, handling, STEP);
physics.step(STEP);
}
const yawOf = () => {
const r = physics.chassis.rotation();
return Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x));
};
let turned = 0;
let previous = yawOf();
let time = 0;
let travelled = 0;
for (let i = 0; i < 8 / STEP && turned < Math.PI / 2; i++) {
drive(physics, state, { ...IDLE, throttle: 0.4, steer: 1 }, handling, STEP);
physics.step(STEP);
const now = yawOf();
let delta = now - previous;
// Unwrap, so crossing ±π does not read as a huge jump.
if (delta > Math.PI) delta -= Math.PI * 2;
if (delta < -Math.PI) delta += Math.PI * 2;
turned += Math.abs(delta);
previous = now;
time += STEP;
travelled += Math.abs(physics.vehicle.currentVehicleSpeed()) * STEP;
}
// Radius, not time, is what a corner costs you: a fast car sweeps through
// ninety degrees *quicker* than a slow one, just across far more tarmac.
return { turned, time, radius: travelled / Math.max(turned, 1e-6) };
}
describe('taking a junction', () => {
it('gets round a right-angle corner at junction speed', async () => {
// The map is a grid of 90-degree turns. If the car cannot make one at a
// sane approach speed, the whole road network fights the player.
const turn = await quarterTurn(9);
expect(turn.turned).toBeGreaterThanOrEqual(Math.PI / 2);
expect(turn.time).toBeLessThan(3);
// Tight enough to stay inside a junction rather than swinging into the
// buildings on the far side of it.
expect(turn.radius).toBeLessThan(14);
});
it('costs you road, not steering, as speed rises', async () => {
const slow = await quarterTurn(6);
const fast = await quarterTurn(22);
// Both corners get made; the fast one just eats far more tarmac doing it.
expect(slow.turned).toBeGreaterThanOrEqual(Math.PI / 2);
expect(fast.turned).toBeGreaterThanOrEqual(Math.PI / 2);
expect(fast.radius).toBeGreaterThan(slow.radius * 1.5);
});
});
describe('vehicle', () => {
it('settles on its suspension instead of sinking or bouncing away', async () => {
const r = await run({}, 2);
@@ -131,7 +192,10 @@ describe('vehicle', () => {
const turning = await run({ throttle: 1, steer: 1 }, 5);
// Position is a poor check here — a hard turn loops back near the start.
expect(turning.maxYawRate).toBeGreaterThan(0.3);
expect(turning.maxYawRate).toBeLessThan(1.6);
// Loosened when steering was sharpened for junctions: at low speed under
// full lock the car now comes round at about 100 deg/s, which is tight but
// is what makes the map's right angles drivable.
expect(turning.maxYawRate).toBeLessThan(2.2);
expect(straight.maxYawRate).toBeLessThan(0.05);
});
+26
View File
@@ -18,6 +18,16 @@ export interface PhysicsWorld {
removeBody(body: RAPIER.RigidBody): void;
/** What the wheels are doing, for anything that needs to react to grip. */
telemetry(): Telemetry;
/** A body the sim moves by hand, which still collides with the player. */
addKinematicBox(box: KinematicBox): RAPIER.RigidBody;
}
export interface KinematicBox {
x: number;
y: number;
z: number;
yaw: number;
halfExtents: { x: number; y: number; z: number };
}
export interface Telemetry {
@@ -147,6 +157,22 @@ export async function createPhysics(model: WorldModel): Promise<PhysicsWorld> {
return v;
},
addKinematicBox(box) {
// Kinematic rather than dynamic: the unit sim owns where these are, but
// they still shove the player's car when they meet it.
const body = world.createRigidBody(
RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(box.x, box.y, box.z),
);
world.createCollider(
RAPIER.ColliderDesc.cuboid(box.halfExtents.x, box.halfExtents.y, box.halfExtents.z)
.setFriction(0.6)
.setActiveEvents(RAPIER.ActiveEvents.CONTACT_FORCE_EVENTS)
.setContactForceEventThreshold(IMPACT_THRESHOLD),
body,
);
return body;
},
telemetry() {
let sideSlip = 0;
let wheelsOnGround = 0;