Skip to content

Guide: a shooter (prediction and lag compensation) ​

You'll build: a small arena shooter where hits are fair at real-world latency. You'll use: tickRate, input.read, network.raycastAtTime, players.damage, scene effects, a locked lobby and the start countdown. Status: the server side (inputs, the 500 ms history, hit checks in the past, damage, 30 to 60 Hz) is Built (headless, and in the plots dev stack). Client code (your own prediction, muzzle flashes and HUD in a Worker) is In progress: it runs in the plots dev page. Without client exports, the engine predicts the standard controllers.

The finished code is examples/arena. Run it with two players, one shooting the other:

sh
node plot/sdk/build.mjs docs/developers/examples/arena
node docs/developers/examples/run.mjs docs/developers/examples/arena --players 2 --fire

The problem ​

When you fire, you're aiming at where your screen showed the enemy: about 100 ms in the past. If the server checks the hit against where they are now, good shots miss. The fix is lag compensation: the room keeps every lobby's player positions for the last 500 ms, and checks your shot against the past you saw.

1. Tick faster ​

json
{ "kind": "runtime", "namespace": "arena", "stashItems": true, "lobbies": { "size": 2 }, "tickRate": 30 }

20 Hz is the default; shooters want 30 to 60 (within your CPU budget). Movement stays server-authoritative, with prediction on devices, which is the fair choice for competitive games.

2. A duel: lock and an equal start ​

ts
export function onPlayerEnter(p: PlayerId) {
  ui.chat(p, "Tap to fire.");
  const lobby = lobbies.current();
  if (lobby.players.length >= lobby.size) {
    lobbies.lock();                                    // a duel: nobody else walks in mid-fight
    lobbies.startCountdown({ protectEveryone: true }); // an equal start: nobody can be hit before it ends
  }
}

export function onPlayerLeave() {
  if (players.list().length === 0) lobbies.unlock();  // empty again: open for the next pair
}

The first player in waits for the second. Without protectEveryone, the first player's entry protection would have run out, and the second could shoot them before the countdown ended.

3. Read inputs; the room decides ​

ts
export function tickLobby() {
  for (const ev of input.read()) {
    if (ev.action !== "fire" || !ev.aimRay) continue;
    const now = time.now();
    if (now - (lastShot.get(ev.player) ?? -1) < COOLDOWN) continue;   // the server decides the fire rate
    lastShot.set(ev.player, now);

Every InputEvent carries the player, a sequence number, the stick, look, buttons, a plot-defined action, and for aiming, aimRay and renderTime (the server time the shooter's screen was showing). Never trust a device about ammo, cooldowns or hits: decide them here.

4. Check the hit in the past ​

ts
    // The ray starts inside the shooter's own body, so it ignores the shooter.
    const seenAt = ev.renderTime ?? now;
    const hit = network.raycastAtTime(seenAt, ev.aimRay, { maxDistance: RANGE, players: true, blocks: true, ignore: [ev.player] });
    if (hit?.player) players.damage(hit.player, DAMAGE, { by: ev.player, cause: "shot" });
  • network.raycastAtTime(time, ray, opts) raycasts against the lobby as it was at time, clamped to the last 500 ms. ignore lists players or entities the ray passes through.
  • network.pastPositions(time) gives the players' boxes at that time as data, if you'd rather do your own hit test.
  • players.damage is refused while a player is protected (playerProtected: their entry protection, or a start countdown with protectEveryone), so spawn-camping the entrance is impossible.

5. Make it visible ​

ts
    scene.create({
      Transform: { pos: ev.aimRay.origin },
      Emitter: { particles: "inventery:spark", burst: 6, to: end, color: "#ffe08a" },
      Lifetime: { seconds: 0.2 },
    });

The tracer is a scene entity, so people outside the arena see the firefight. The muzzle flash on the shooter's own screen is a local effect: that belongs in client code (In progress).

6. Hide what players shouldn't know ​

Enemies behind walls aren't your problem to hide: give them a visibility set, or let the room's interest management cull them, and they're never sent. A wallhack can't show what never arrived.

Client code (In progress) ​

Your module runs on the devices of the lobby's players too (never onlookers'), in a Worker, with exports split by role.

ExportRoleDoes
predictStep(input)sim: both sides, deterministicadvances the predicted state one tick; the room runs the same code to check it
onFrame(dt)present: client onlylocal HUD, muzzle flashes, screen shake
onInput(ev), onServerMessage(channel, data)presentlocal reactions

The Worker host runs them in the plots dev page; the room runs the runtime's predictStep too, as the referee. That gives custom predicted movement (a Mario Galaxy gravity walk; the Hidden Roles sample, plot/samples/hidden-roles, moves its players this way) and, with "rollback" in plot.json (the room relays the lobby's inputs to its other players), rollback for fighting games. In client code only the local player's view is reachable: ui.drawOverlay, local scene nodes and sounds, your channels.

Choices ​

You wantUse
Competitive and fairserver movement (default), 30 to 60 Hz, network.raycastAtTime
Snappy co-op (Risk of Rain)"movement": "client": devices own their movement, the room sanity-checks it
A scoreboardui.setScoreboard per player, or your own full-screen board with ui.drawOverlay
Scopes, crosshairsanything you like in ui.drawOverlay; the trusted layer stays on top