Skip to content

Events

The seven Tier-1 events are live, wired to real game functions, and verified headlessly against the actual game on every CI run.

The event table

EventPayloadFires
car.control{ carId, up, right, down, left, reset }Every frame the game steps the car controller
car.created{ carId, isReplay }When a car is created (player and ghosts)
race.started{ carId, isReplay }When a car's race starts (player: on first throttle)
checkpoint.passed{ index, carId, isReplay }When a car passes a checkpoint
checkpoint.respawn{ index, carId, isReplay }Once per reset-press, at the checkpoint respawned at
race.finished{ frames, carId, isReplay }When a car finishes (seconds = frames / 60)
track.afterLoadtrackIdWhen a track finishes loading
js
export default (api) => {
  const off = api.events.on('checkpoint.passed', ({ index, carId, isReplay }) => {
    if (isReplay === true) return;
    api.logger.log(`checkpoint ${index}`);
  });
  return off;
};

on and once return an unsubscribe function. Return it (or call it) from your disposer.

Payload shapes

The exact TypeScript interfaces, straight from @tspml/api:

ts
/** car.control: the player's input state at that frame. */
interface CarControlState {
  readonly carId: number;
  readonly up: boolean;
  readonly right: boolean;
  readonly down: boolean;
  readonly left: boolean;
  readonly reset: boolean;
}

/** Every per-car race payload extends this. */
interface CarRef {
  readonly carId: number | null;     // matches car.created / car.control ids
  readonly isReplay: boolean | null; // true = ghost/replay, false = the player
}

/** race.finished */
interface RaceFinishInfo extends CarRef {
  readonly frames: number;           // physics-sim frames; seconds = frames / 60
}

/** checkpoint.passed and checkpoint.respawn */
interface CheckpointInfo extends CarRef {
  readonly index: number;            // the checkpoint just passed / respawned at
}

Payloads are readonly. Listeners observe them; they cannot mutate what other mods see.

car.control: the per-frame event

car.control is the hot path. The game posts the player's input to its physics worker every frame, and the bridge's hook raises this event from that same call, so it fires roughly 60 times per second during a race and goes quiet in menus. Two consequences:

  • Keep the handler cheap. It runs inside the game's frame. Reading fields and updating a counter or a HUD value is fine; heavy work belongs somewhere else (batch it, or move it behind requestAnimationFrame).
  • It is input state, not car physics. The booleans are which controls are held that frame. Speed, position, and rotation are not in this payload.

The portal's Status section shows it live: bridge: car.control × N counts up while you race.

Per-car events

race.started, checkpoint.passed, checkpoint.respawn, and race.finished fire once per car, not once per race, and on any track where you have a saved record, the ghost is a car. A lap timer that ignores this double-counts everything.

The filtering idiom:

js
api.events.on('race.finished', ({ frames, isReplay }) => {
  if (isReplay === true) return;  // a ghost finished, not the player
  showTime(frames);
});

Compare with === true, not truthiness

Both fields are nullable, and null means TSPML could not determine it, not "the player". isReplay goes null only if the bridge's read of the game's own controlled-car flag fails (e.g. a game update moved it); that's the fail-soft path instead of a throw inside game code. carId is null for a car with no physics body. Treat null as unknown: a mod that treats it as falsy silently attributes a ghost's lap to the player.

Correlating across events

carId is the same physics-worker id across car.created, car.control, and every race event, so per-car state is a Map keyed by it:

js
const laps = new Map(); // carId -> checkpoint count

api.events.on('car.created', ({ carId }) => laps.set(carId, 0));
api.events.on('checkpoint.passed', ({ carId }) => {
  if (carId === null) return;
  laps.set(carId, (laps.get(carId) ?? 0) + 1);
});

Most mods never need this. If all you want is "was that me?", isReplay === false answers it without any bookkeeping.

checkpoint.respawn specifics

Fires on the reset-press edge: once per press, not per held frame. Its index is the checkpoint respawned at. It does not fire for a full restart (the game recreates the car for those) or before the first checkpoint. If a game update changes the state shape it reads, it degrades to silence rather than guessing.

Error isolation

Every listener is individually isolated: one throwing listener is caught and logged without blocking sibling listeners, other mods, or the game's own call. You still shouldn't throw, but doing so is a report, not a crash.

Lifecycle and teardown

EventFires
loader.onUnloadAt teardown, before mods unload (the bus and registries are still live)

loader.onUnload is for mods that want to observe teardown. For your own cleanup, return a disposer from your entrypoint instead; the loader calls it directly, in reverse load order, isolated per mod.

The teardown order the portal guarantees:

  1. loader.onUnload fires while everything is still live, so a handler can still call keybinds.unregister, tracks.unregister, and so on.
  2. Mod disposers run, in reverse load order. A disposer that throws is reported; every other mod still tears down.
  3. The bridge's registries are disposed last.

The portal triggers this on tab close, page navigation, and per-mod disable/remove. Returning nothing from your entrypoint is fine; the mod is reported as no-op on unload rather than unloaded.

Reserved event names

Declared in the type map but not yet emitted: loader.preInit / init / ready, track.beforeLoad / unload, car.stateUpdate, and the physics/render/input/ui/network groups. Subscribing to them is harmless today (they simply never fire), but check the table at the top for what's real before building on an event.

TSPML is a fan-made tool. It never redistributes PolyTrack; the portal transforms your own live copy of the game.