Recover a stuck racer
Goal
A racer wedged somewhere the course never meant them to be — pinned against a rail, caught under a sweeper, standing in a corner — goes back to their last checkpoint after twelve seconds of racing without getting anywhere, the same way a fall sends them back today. Every teleport starts the clock again, so a racer is never sent back twice in a row.
Files you will edit
src/course.tssrc/respawn.ts
Steps
Say what stuck means, beside the course's other tuning. "Getting anywhere" is three metres from where the racer last stood still: forward, sideways or back, so a racer fighting their way off a ledge is not stuck.
ts// src/course.ts /** A racer who has not moved `distance` metres in `seconds` of racing is wedged. */ export const STUCK = { distance: 3, seconds: 12 };Keep where each racer last stood still, and since when. These are seat lanes, like the ones in
state.ts, and they are only written, never allocated, while the race runs.ts// src/respawn.ts import { Transform, type GameContext } from 'gameable'; import { CHECKPOINTS, CHECK_Z, CONTACT, TOP, FALL_Y, MAX_PLAYERS, SPAWNS, STUCK } from './course'; import { race } from './state'; import { emit } from './events'; /** Where each racer last stood still, and since when: the stuck clock. */ const stillX = new Float32Array(MAX_PLAYERS); const stillZ = new Float32Array(MAX_PLAYERS); const stillSince = new Float64Array(MAX_PLAYERS); /** The stuck clock starts again from where the racer stands now. */ function still(seat: number, x: number, z: number, now: number): void { stillX[seat] = x; stillZ[seat] = z; stillSince[seat] = now; }Start the clock again on every teleport, inside
respawnitself, so a fall, a recovery and the start line all count. Without this a racer sent back is still "stuck since" the old spot and is sent back again on the next step.ts// src/respawn.ts export function respawn(ctx: GameContext, seat: number, start = false): void { const at = start || race.checkpoint[seat] === 0 ? SPAWNS[seat] : CHECKPOINTS[race.checkpoint[seat]]; ctx.physics.teleport(race.entity[seat], at[0], at[1], at[2]); race.knockUntil[seat] = race.hitAfter[seat] = 0; still(seat, at[0], at[2], ctx.elapsed); }Run the clock in
checkpoints. In the lobby it only follows the racer, so every round starts with fresh lanes; while racing it never counts from before the race began (race.sinceis when the racing phase started), so the countdown is never held against anyone.ts// src/respawn.ts — in checkpoints(), replacing the lobby line const x = Transform.x[e]; const z = Transform.z[e]; if (race.phase === 'lobby') { still(seat, x, z, ctx.elapsed); continue; } const dx = x - stillX[seat]; const dz = z - stillZ[seat]; if (dx * dx + dz * dz >= STUCK.distance * STUCK.distance) still(seat, x, z, ctx.elapsed); else if (ctx.elapsed - Math.max(stillSince[seat], race.since) >= STUCK.seconds) { respawn(ctx, seat); emit(seat, 'respawn'); continue; }
Verify
sh
npm testAdd a test beside the fall test in tests/race.test.ts: one racer stands at z = -24 from the start of racing. They are sent back to the first checkpoint once, twelve seconds into the race, and not again in the next five seconds:
ts
const teleports = result.outputs.flatMap((out, frame) =>
moves(out, race.entity[0]).some((m) => m.teleport) ? [frame] : [],
);
expect(teleports.filter((f) => f >= 490)).toHaveLength(1);See also
- Write a game system — the fixed-step function this extends, and why it allocates nothing
- Add a physics body — why a body is moved with
physics.teleport, never by writingTransform - Add a lobby — the ready rule the round starts from