Appearance
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 --fireThe 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 attime, clamped to the last 500 ms.ignorelists 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.damageis refused while a player is protected (playerProtected: their entry protection, or a start countdown withprotectEveryone), 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.
| Export | Role | Does |
|---|---|---|
predictStep(input) | sim: both sides, deterministic | advances the predicted state one tick; the room runs the same code to check it |
onFrame(dt) | present: client only | local HUD, muzzle flashes, screen shake |
onInput(ev), onServerMessage(channel, data) | present | local 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 want | Use |
|---|---|
| Competitive and fair | server 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 scoreboard | ui.setScoreboard per player, or your own full-screen board with ui.drawOverlay |
| Scopes, crosshairs | anything you like in ui.drawOverlay; the trusted layer stays on top |