Skip to content

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.ts
  • src/respawn.ts

Steps ​

  1. 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 };
  2. 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;
    }
  3. Start the clock again on every teleport, inside respawn itself, 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);
    }
  4. 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.since is 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 test

Add 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 ​