Multiplayer
Host ticks. Loki delivers.
Use this when the host simulation advances every tick. JavaScript only. Calibrate the rate. Do not guess 30 Hz.
Every member must be realtime-capable before the room activates. Native clients cannot join. A turn-based game with smooth animation is not a realtime room.
Responsibility
| Loki | The game |
|---|---|
| Transport, sequencing, authority/round fences | Simulation and physics |
| Snapshot pacing and backpressure | Collision |
| Input delivery and coalescing | Rendering |
| Reconnect and host migration | Prediction you configure |
| Diagnostics and calibration | Competitive integrity — not provided |
Loop
const room = client.createRealtimeRoom<RacerState, RacerInput>({
snapshotHz: 30,
adaptiveRate: true,
initialSnapshotHz: 12,
minSnapshotHz: 8,
diagnostics: true,
predict(state, localInput, dtSeconds) { return state; },
interpolate(from, to, t) { return to; },
});
await room.create();
room.publishSnapshot(currentState, { simulationTick });
room.setInput({ throttle: 75 });
await room.sendInput({ type: "placeBomb", cell: 12 });
function frame(now: number) {
room.advanceFrame(now);
render(room.getRenderState(now));
}
Drive rendering from one game-owned requestAnimationFrame loop. Call advanceFrame() and getRenderState() at most once per frame. setInput() is safe every frame; the SDK paces the network send.
Rates
snapshotHz is a ceiling (default and cap 30), not a delivery guarantee. Start conservative with adaptiveRate: true. Do not copy snapshotHz into game.json tickRate.
Calibrate
Prefer calibrateRealtimeRoom() over guessing. It needs two distinct player identities — two isolated browser profiles or two real players, not two tabs of one signed-in account. Write only the returned RealtimeProfile into createRealtimeRoom().
import { calibrateRealtimeRoom } from "@lokiplay/sdk";
const { recommended } = await calibrateRealtimeRoom({
snapshotHzCandidates: [8, 12, 15, 20, 25, 30],
simulationHz: 60,
durationMsPerCandidate: 20_000,
createHostRoom: (candidate) =>
hostClient.createRealtimeRoom({
snapshotHz: candidate.snapshotHz,
adaptiveRate: false,
diagnostics: true,
}),
createGuestRoom: () =>
guestClient.createRealtimeRoom({ diagnostics: true }),
driveHost: (host, tick) =>
host.publishSnapshot(simulateOneStep(tick), { simulationTick: tick }),
driveGuest: (guest) => guest.setInput(representativeControl()),
});
const room = client.createRealtimeRoom({ ...recommended, predict, interpolate });
If calibration throws, fix the harness and rerun. If no candidate qualifies, use the floor profile (8 Hz, adaptive). Fine-tuning a racer after install means running this sweep on the real wiring, not copying another game’s Hertz.
Render streams
Predict the local entity. Interpolate remotes. Use getRenderStates() / composeRenderState, or createEntityCompositor / createLocalPrediction with bulk setEntities. Use sendEffect() / onConfirmedEffect() for authoritative events such as collisions.