npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@lokiplay/sdk

v0.5.0

Published

JavaScript client SDK for Loki multiplayer games.

Readme

@lokiplay/sdk

JavaScript client SDK for authenticating players, joining Loki multiplayer rooms, sending actions and events, and subscribing to server messages.

npm install @lokiplay/[email protected]

Use FirstPartyTransport for production. It defaults to https://api.lokiplay.cc and the managed Loki multiplayer endpoint; explicit endpoint overrides are available for local and staging environments. The public API exposes Loki protocol values only—Nakama objects are never returned.

Hosted sandbox games receive a MessagePort in the loki:init message. Call createHostedLokiClient({ projectId }) once at page boot rather than hand-writing that handshake: it installs the loki:init listener immediately, replies, and requests the session as soon as the handshake arrives — not deferred until the player clicks Create/Join, which is what lets the hosted shell's own ~15s "Connecting…" timeout elapse before the game ever asked for a session. Reuse the client it returns for every subsequent createRoom()/joinRoom(). Pass fallbackTransport (e.g. a FirstPartyTransport configured for local dev) to run outside the hosted shell. Lower-level access remains available via hostedGameSessionProvider if a game needs to build its own FirstPartyTransport.

LokiClient supports rooms, invite resolution, matchmaking, actions, events, host state, snapshots, presence, chat, private scores, token refresh, reconnect, and host-migration messages. Loki creates every room and issues the shareable invite code. New rooms issue a 6-digit code. 0.2.0 16-character hex codes still join. Call createRoom() and joinRoom({ inviteCode }) and do not invent room keys. create() stays invite-only unless the game passes { visibility: "public" }. Public rooms can be listed with listPublicRooms() and joined with joinPublic({ roomId }) when the runtime advertises public_room_browser. A public summary includes only an opaque roomId, occupancy, joinable, and an optional bounded modeLabel. The SDK does not add a public lobby screen; the game must build the browser and all loading, empty, joining, full-room, waiting, readiness, and error states. The Loki overlay does not list, create, or join public rooms. Do not make every room public.

Installing the SDK does not add Match or Leaderboard UI either; both are required alongside create/join, and the Loki overlay never draws them. Add a Match control calling the room wrapper's matchmake() (SynchronizedRoom.matchmake() / RealtimeRoom.matchmake()) with searching, cancel (abort the in-flight search), timeout, waiting, and error states, and a per-game Leaderboard using LokiClient.listLeaderboard() / LokiClient.submitLeaderboardScore() (room-independent; usable before, during, or without joining a room) — or the in-room submitScore() for a mid-room board — with display-name collection or a sensible fallback plus loading, empty, pagination, submission, and error states. Player count, teams, and scoring stay creator decisions; if the game has no numeric result to store, ask the creator once what to record. Leaderboards are per-game/project and available to guest sessions without a creator account; client-submitted scores are not an anti-cheat boundary. Matchmaking is blind, per-project, and single-region; a server-owned region is stamped on every ticket and any client-sent region is ignored.

Prefer createSynchronizedRoom() for shared state. Supply opaque state, opaque actions, and a reducer. Loki sequences actions, commits only from the current authority, tracks stateVersion, deduplicates action IDs, restores snapshots, and exposes membership and connection status. Low-level sendAction() and sendHostState() remain available. Host authority is suitable for casual unranked sessions, not ranked integrity.

Generic integration steps:

  1. Install the exact published SDK version for this release.
  2. Define this project's state and action schemas.
  3. Define a synchronous, deterministic, JSON-compatible reducer.
  4. Create or join with create(), join({ inviteCode }), or, when the game enables public discovery, listPublicRooms() and joinPublic({ roomId }).
  5. Subscribe to snapshots for state, members, authority, and connection.
  6. Dispatch actions and wait for authoritative confirmation.
  7. Render connection and lastError from the snapshot.
  8. Handle rejected actions without inventing a parallel protocol.
  9. Build and deploy from this project's own repository.

Reducers must return quickly. Loki clones reducer inputs and catches thrown errors, but a synchronous infinite loop cannot be interrupted without changing the reducer API. Do not perform I/O or rendering inside reduce. Reducers must be synchronous, deterministic, and JSON-compatible. Only the current host runs reduce; members wait for an authoritative state acknowledgement keyed by (senderId, actionId).

create(), join({ inviteCode }), dispatch(action), leave(), and reconnect() are the shared synchronized-room API. Call dispatch() only while the snapshot connection is connected. Hidden or offline pages enter suspended, pause confirmation timers, and keep pending actions and room identity. Returning to the foreground replaces the socket, requests a snapshot, and replays unresolved actions with the same (senderId, actionId). A missed confirmation starts recovery instead of immediately failing dispatch(). If recovery cannot determine the outcome before the recovery deadline, the promise rejects as indeterminate. A failed leave() retains the room identity in leave_failed and blocks a new join until leave() succeeds or close() abandons the handle. Only leave() sends an immediate membership leave. Connection loss is an interruption and uses reconnect grace. 0.2.0 clients fail fast when a snapshot does not advertise capabilities.synchronized_rooms. and replays unresolved actions with the same (senderId, actionId). A missed confirmation starts recovery instead of immediately failing dispatch(). If recovery cannot determine the outcome before the recovery deadline, the promise rejects as indeterminate. A failed leave() retains the room identity in leave_failed and blocks a new join until leave() succeeds or close() abandons the handle. Only leave() sends an immediate membership leave. Connection loss is an interruption and uses reconnect grace. 0.2.0 clients fail fast when a snapshot does not advertise capabilities.synchronized_rooms.

Protocol numbers must be finite safe integers. Encode fractional values with stylesheets, or <form> submissions. On mobile, the SDK suspends while the page is hidden or offline, then replaces the socket when the page is visible and online again, retries with backoff, and leave. Clients replace the roster only when membersComplete is true, or when that flag is omitted and members is non-empty. snapshot.membership is ready only when the local player is present on a complete roster; otherwise it is synchronizing.

Loki-hosted games run inside a sandboxed iframe. Bundle JavaScript and fonts as same-origin files. Do not use inline scripts, Google Fonts, remote stylesheets, or <form> submissions. On mobile, the SDK defers reconnect while the page is hidden or offline, then retries with backoff and resynchronizes from an authoritative snapshot instead of failing the room.

RealtimeRoom

createSynchronizedRoom() fits turn-based and event-driven games, where state changes on discrete actions. createRealtimeRoom() is for continuously simulated, host-authoritative games (racers, shooters, anything with a physics or movement tick) that need smooth remote presentation and cannot wait for a round-trip confirmation per input. Pick one room type per game mode by how authoritative state actually progresses, not by genre or visual frame rate; do not run both for the same mode.

Responsibility boundary

RealtimeRoom owns transport, sequencing, authority/round fencing, snapshot pacing and backpressure, input delivery and coalescing, reconnect and host migration, and diagnostics. The game owns simulation, physics, collision, rendering, and interpolation/extrapolation/reconciliation of remote state. Loki does not supply game physics, collision resolution, rendering optimization, or competitive/anti-cheat integrity for realtime rooms.

Every room member must be realtime-capable before a room activates; a non-realtime-capable (legacy) member blocks activation and cannot join an already-active realtime room. Native clients cannot join realtime-mode rooms until a later parity release—use createSynchronizedRoom() for cross-client modes today.

API example

import { LokiClient, FirstPartyTransport } from "@lokiplay/sdk";

interface RacerState {
  positions: Record<string, number>;
}
interface RacerInput {
  throttle: number; // 0-100, latest-wins
}

const client = new LokiClient({
  projectId,
  transport: new FirstPartyTransport(),
});
await client.authenticate(token);

// createRealtimeRoom() marks the room realtimeCapable automatically; the
// game's own simulation (not the SDK) advances state from inputs each tick.
const room = client.createRealtimeRoom<RacerState, RacerInput>({
  predict(state, localInput, dtSeconds) {
    // Local-only prediction: applied each advanceFrame() step for the latest
    // setInput() control, and once immediately for each sendInput() command.
    return state;
  },
  interpolate(from, to, t) {
    // Blend remote state between two received snapshots (t in [0, 1]).
    return to;
  },
  extrapolate(state, dtSeconds) {
    // Optional: advance state when no newer snapshot has arrived yet.
    return state;
  },
  blendCorrection(predicted, target, t) {
    // Optional: smooth a misprediction (predicted, frozen at the moment the
    // correction started) back toward `target`, which is the room's live,
    // continuously-advancing #predictedState (not a delayed snapshot), so the
    // blend always converges on zero-latency local prediction. t rises from
    // 0 to 1 over correctionMs. If a new snapshot arrives while a correction
    // is already in flight, Loki rebases `predicted` to whatever was last
    // displayed instead of restarting, and preserves the original deadline.
    return target;
  },
  shouldCorrect(displayed, reconciled) {
    // Optional: Loki's state is opaque JSON, so it can't judge position
    // units or heading radians itself. Return false to suppress starting a
    // new correction when the drift between what's currently displayed and
    // the freshly reconciled prediction is imperceptible. Defaults to
    // always correcting when omitted.
    return true;
  },
  composeRenderState(states) {
    // Optional: compose the independent render streams (see below) into the
    // single State returned by getRenderState(), instead of one predicted-
    // or-authoritative value applying to the whole state at once. Defaults
    // to `correctedPredicted ?? interpolated` when omitted.
    return states.correctedPredicted ?? states.interpolated;
  },
});

const created = await room.create();
// or: await room.join({ inviteCode });

// Host loop: publish the latest simulated state. `snapshotHz` (default 30,
// cap 100) is a ceiling, not a delivery guarantee — Loki
// paces/coalesces calls, tracks each submission until it's accepted,
// rejected, or times out, and never lets a dropped submission permanently
// consume in-flight budget (see "Tuning defaults and diagnostics" below).
room.publishSnapshot(currentState, { simulationTick });

// Every client: continuous latest-wins input (throttle, aim, movement axis).
room.setInput({ throttle: 75 });

// Ordered, discrete commands that must not be coalesced (e.g. "place bomb").
await room.sendInput({ type: "placeBomb", cell: 12 });

// Drive rendering from the game's own requestAnimationFrame loop.
function frame(now: number) {
  room.advanceFrame(now);
  render(room.getRenderState(now));
}

Independent render streams and composition

For a single-entity game, getRenderState()'s default of "the whole state is either fully predicted or fully authoritative-interpolated" is enough. For a multi-entity game (e.g. a racer with other players' cars, or anything with collisions), predicting every entity from local input guesses other players' controls and can diverge sharply after a collision. getRenderStates(now) exposes the streams getRenderState() composes internally, so a compositor can render each entity from whichever stream fits it best:

composeRenderState(states) {
  return {
    // Local entity: zero-latency prediction (with correction blending).
    cars: {
      ...states.interpolated?.cars,
      [localPlayerId]: states.correctedPredicted?.cars[localPlayerId],
    },
  };
},
  • interpolated / latestAuthoritative: the delayed/interpolated (or extrapolated) authoritative sample, and the most recently accepted snapshot with no delay applied — use these for remote entities and as collision proxies. Both are sampled independently of local reconciliation: a local correction never resets, replaces, or snaps this stream.
  • predicted / correctedPredicted: the live local prediction, before and after blendCorrection is applied — use these for the local entity.
  • interpolatedTick / authoritativeTick / predictedTick: the simulation ticks each stream currently represents, for compositors that need to reason about relative timing (e.g. how far ahead prediction has advanced).

getRenderState() calls getRenderStates() internally and, if composeRenderState is configured, passes its result through that callback; otherwise it defaults to correctedPredicted ?? interpolated, matching prior behavior. Loki has no concept of "entities" — composeRenderState composes whatever opaque State shape the game defines; correction-magnitude/entity diagnostics (e.g. "car 3 corrected by 0.4m") are similarly game-specific and are expected to be computed inside shouldCorrect/composeRenderState rather than built into Loki's diagnostics.

createEntityCompositor / createLocalPrediction helpers

Hand-writing a composeRenderState that renders the local entity from prediction and every other entity from interpolation (or a predict that only ever touches the local entity) is the same boilerplate for most multi-entity games. createEntityCompositor and createLocalPrediction cover that plumbing; the game still supplies the selectors that know its own State shape — Loki still has no built-in concept of "entity", "position", or "heading":

import { createEntityCompositor, createLocalPrediction } from "@lokiplay/sdk";

const room = client.createRealtimeRoom<RacerState, RacerInput>({
  predict: createLocalPrediction<RacerState, CarState, RacerInput>({
    localEntityId: () => client.playerId,
    getLocalEntity: (state, id) => state.cars[id],
    predictLocal: (car, input, dtSeconds) => stepCar(car, input, dtSeconds),
    replaceLocalEntity: (state, id, car) => ({ ...state, cars: { ...state.cars, [id]: car } }),
  }),
  composeRenderState: createEntityCompositor<RacerState, CarState>({
    listEntityIds: (state) => Object.keys(state.cars),
    getEntity: (state, id) => state.cars[id],
    setEntity: (state, id, car) => ({ ...state, cars: { ...state.cars, [id]: car } }),
    isLocalEntity: (id) => id === client.playerId,
  }),
});

createLocalPrediction guards against a common mistake — a predict() that closes over the whole state and accidentally advances every entity (guessing other players' controls) instead of just the local one.

For many entities, prefer setEntities(state, entities) (a Map) over setEntity(state, id, entity): setEntities is called at most once per frame with every entity being overlaid, so the game copies its entity collection once instead of once per entity. setEntities is used instead of setEntity whenever both are supplied.

Confirmed effects

Speculative local effects (collision particles, impact audio) need to be reconciled against the host's authoritative outcome without the game building its own event-confirmation channel. Host-only sendEffect() confirms an event with a stable, runtime-assigned effectId (unique even across host migrations), reliably broadcast to every member — including the host itself, through the same onConfirmedEffect() path everyone else uses — plus replayed to a reconnecting member from a short server-side retained-effect ring so it isn't missed. Loki never interprets payload:

// Host, e.g. on detecting a collision:
room.sendEffect({ kind: "collision", entities: ["car-1", "car-2"] }, { simulationTick });

// Every member, including the host:
room.onConfirmedEffect((effect) => {
  // effect.effectId is stable and delivered at most once per subscriber;
  // dedupe a speculative local effect already played for the same event.
  playConfirmedCollision(effect.payload);
});

Lifecycle and round flow

Rooms use protocol v1 for membership, presence, and host-migration control messages, and protocol v2 for realtime input/snapshot/sync data traffic; both run over the same connection. authorityEpoch increments on host migration; roundSequence increments when a host calls beginRound(). The runtime and SDK reject stale-round or stale-authority snapshots and inputs so a reconnecting or migrating host cannot roll back state that members already rendered. On host migration, RealtimeRoom requests a sync from the runtime before the newly elected host is allowed to publish, so the new host starts from the latest known state instead of a blank one.

Tuning defaults and diagnostics

snapshotHz (default 30 Hz, cap 100 Hz) is a ceiling, never a promise of delivery. simulationHz (default 60) is the game's own local step rate and is independent of it — a game commonly runs simulation at 60 Hz while publishing snapshots far less often. inputHz (default/cap 20) is the network transmission rate for setInput()'s continuous control, also independent of both. None of simulationHz/snapshotHz/inputHz need to match the deployed game.json tickRate (a separate, deployment-time Nakama room-loop setting).

A higher configured snapshotHz is not automatically better. A room configured for 30 Hz that only actually gets ~8 Hz delivered (a slow guest, a saturated in-flight budget, sustained rate limiting) presents worse than a room honestly configured near what it can sustain, because interpolation/extrapolation assume the configured cadence unless enough arrival samples exist to correct that assumption (see below). Start with a conservative rate (adaptiveRate: true, or an explicit low snapshotHz) and raise it only with diagnostic evidence — ideally from calibrateRealtimeRoom() (see "Adaptive rate and calibration" below) — rather than assuming the cap is the right default for every game.

Every publishSnapshot() submission is tracked individually (by a runtime-assigned hostSnapshotSequence) until it is accepted, explicitly rejected, or times out; a submission that never resolves either way (the runtime's own "late/duplicate echo, dropped silently, no rewind, no response" behavior for out-of-order in-flight submissions) still releases its in-flight slot once its ack timeout elapses, so it can never permanently saturate the in-flight budget. Input queues are also bounded (REALTIME_ROOM_MAX_IN_FLIGHT_SNAPSHOTS, REALTIME_ROOM_MAX_ORDERED_INPUTS) so a latency spike cannot grow memory unboundedly; oldest-first entries are dropped once a bound is hit. RealtimeRoomError reports backpressure and capability failures (e.g. joining a realtime room with a non-realtime-capable transport). A runtime RATE_LIMITED response to a snapshot submission backs the next flush off for retryAfterMs instead of retrying immediately.

setInput() updates local prediction immediately (safe to call every render frame) but paces network transmission to at most once per inputHz interval; calls faster than that only replace the pending value (inputsCoalesced) and the most recent one is sent once the interval elapses, and held controls are still periodically refreshed at the same cadence. A game no longer needs its own input-rate throttle in front of setInput().

Use the room's diagnostics to observe RTT, jitter, acceptance/rejection, and reconnect/migration duration when tuning simulation and snapshot rates for a specific game; report the rates actually used along with this evidence rather than assuming defaults are sufficient for every game.

diagnostics: true also exposes: renderClockRate (the current ±5% playback-rate nudge applied to keep the guest's render clock aligned with the host's tick cadence — see REALTIME_ROOM_MAX_CLOCK_NUDGE), renderClockDriftTicks (the error observed at the last nudge), framesRendered / extrapolatedFrames (compare these to see how often rendering had to extrapolate past the newest snapshot), heldAuthoritativeFrames (frames that held the last snapshot instead — a freeze), and reconciliations / correctionsStarted / correctionsCompleted / correctionsSuppressed (every reconcile against a prior prediction counts as a reconciliation; it becomes a started correction unless shouldCorrect returns false, in which case it's counted as suppressed instead; a started correction is completed once its blend reaches t = 1).

Snapshot cadence is measured at three independent stages so unevenness can be attributed to its source: lastSnapshotHostIntervalMs (interval between the host's own send timestamps — host pacing), lastSnapshotRelayIntervalMs (interval between the runtime's accept/broadcast timestamps — relay/ backpressure), and lastSnapshotIntervalMs (the wall-clock gap between snapshots as this client actually observed them arriving — the network). snapshotArrivalJitterMs is a smoothed measure of how much that arrival interval deviates from its own running average. Loki folds that, together with 1.25 times the measured arrival interval, into the default interpolation delay. The delay rises on the same snapshot when arrivals get uneven, and eases down when they stay steady. There is no fixed minimum. An explicit interpolationDelayMs replaces that delay. snapshotSequenceGaps counts missing runtimeSnapshotSequence numbers between consecutive accepted snapshots, and snapshotsCoalescedOnReceive counts snapshots that were superseded by a newer one before the game ever called getRenderState() to sample them.

publishSnapshot() defers its flush to a microtask, so if a host calls it more than once within the same synchronous turn (e.g. a catch-up frame that simulates several steps before rendering), only the newest state is ever transmitted — every call after the first just overwrites the pending state and increments snapshotsCoalesced, matching the same-turn coalescing a game would otherwise have to implement itself.

diagnostics: true also exposes host-only publish-cadence diagnostics measured from publishSnapshot()'s own call times (distinct from the runtime/guest-side cadence numbers above, which measure the wire): snapshotPublishCalls, lastSnapshotPublishIntervalMs, effectiveSnapshotHz (the smoothed rate of attempted sends), missedSnapshotWindows, hostFrameStallCount, and lastHostFrameStallMs. These flag problems Loki cannot fix itself — the host's own frame loop stalling, or calling publishSnapshot() faster than its configured rate needs — via an optional onDiagnosticWarning(event) callback:

const room = client.createRealtimeRoom<RacerState, RacerInput>({
  onDiagnosticWarning(event) {
    // event.type: "back_to_back_publish" | "host_frame_stall"
    //           | "missed_snapshot_window" | "low_effective_snapshot_rate"
    //           | "snapshot_ack_timeout" | "snapshot_backpressure"
    //           | "runtime_rate_limited" | "high_extrapolation_ratio"
    console.warn("[loki]", event);
  },
});

Host-only, per-submission accounting: snapshotsAttempted (equivalent to snapshotsSent, named for clarity: attempts, not runtime acceptance), snapshotsAccepted, snapshotsRejected (explicit runtime errors), snapshotAckTimeouts (submissions that never resolved either way and were released speculatively), snapshotBackpressureDurationMs (cumulative time spent with the in-flight budget fully saturated), maxInFlightObserved, effectiveAcceptedSnapshotHz (smoothed rate of accepted echoes — falls behind effectiveSnapshotHz when the runtime is dropping/rejecting submissions), snapshotAcceptanceRatio, and snapshotAckP95Ms. Input accounting: inputCalls (every setInput() call), inputsTransmitted (paced network sends of that continuous control), and inputRateLimited. Guest-only: guestEffectiveSnapshotHz (this client's own observed accepted arrival rate, independent of what the host is configured/targeting to send).

Adaptive rate and calibration

By default snapshotHz is a fixed target (matching prior behavior). Opt into an AIMD controller instead — start conservative, climb slowly on sustained clean acknowledgements, back off immediately and further on rejection, timeout, or sustained backpressure — with adaptiveRate: true:

const room = client.createRealtimeRoom<RacerState, RacerInput>({
  snapshotHz: 30, // ceiling: never exceeded
  adaptiveRate: true,
  initialSnapshotHz: 12, // start conservative
  minSnapshotHz: 8, // floor
});

currentTargetSnapshotHz/configuredMaxSnapshotHz, and adaptiveRateReductions/adaptiveRateIncreases, report the controller's live target and how often it has adjusted. A guest also sends the host a bounded, low-frequency (~1 Hz) transport-health report (arrival rate, jitter, missing sequence numbers, extrapolation ratio); a struggling guest can trigger a reduction (and blocks further increases) even when the host's own send/ack cadence looks clean, without the host ever seeing guest state or controls.

Rather than guessing a starting rate, use calibrateRealtimeRoom() to try candidate rates against a real two-client RealtimeRoom pair and recommend the highest rate that is actually delivered without a Loki-owned cliff (acceptance, delivery match, extrapolation, held authoritative frames, ack latency, backpressure). Optional evaluate() can only veto a rate:

import { calibrateRealtimeRoom } from "@lokiplay/sdk";

const { recommended, samples } = await calibrateRealtimeRoom<RacerState, RacerInput>({
  snapshotHzCandidates: [8, 12, 15, 20, 25, 30],
  simulationHz: 60,
  durationMsPerCandidate: 20_000,
  createHostRoom: (candidate) =>
    hostClient.createRealtimeRoom<RacerState, RacerInput>({
      snapshotHz: candidate.snapshotHz,
      adaptiveRate: false,
      diagnostics: true,
    }),
  createGuestRoom: () => guestClient.createRealtimeRoom<RacerState, RacerInput>({ diagnostics: true }),
  driveHost: (host, tick) => host.publishSnapshot(simulateOneStep(tick), { simulationTick: tick }),
  driveGuest: (guest) => guest.setInput(representativeControl()),
});

calibrateRealtimeRoom() is game-agnostic: it only reads diagnostics from the rooms createHostRoom/createGuestRoom build (which must pass diagnostics: true and run each candidate at a fixed snapshotHz) and calls the game's own driveHost/driveGuest/evaluate callbacks — it never inspects State. hostClient and guestClient need two distinct authenticated identities (two real players, or two isolated test/browser sessions); two tabs sharing one signed-in player are not two players. The result is one RealtimeProfile (schemaVersion, simulationHz, snapshotHz as the ceiling, inputHz, interpolationDelayMs, correctionMs, adaptiveRate: true, minSnapshotHz: 8, initialSnapshotHz 12 or the ceiling if lower) to write into createRealtimeRoom() only. If no candidate qualifies, the profile is the Loki floor (8 Hz, adaptive). If the sweep is incomplete, calibration throws. game.json tickRate stays a separate Nakama room-loop setting and must not be copied from snapshotHz.

For calibrating against a hosted build (the real player path, including createHostedLokiClient()'s handshake) rather than a headless pair, drive createHostRoom/createGuestRoom from two isolated browser contexts instead of two in-process clients — the mechanics above are identical, only where the two RealtimeRooms live changes.

Migrating from hand-rolled racer networking

If a game already ships its own input queue, RTT estimator, snapshot pacer, stale-round rejection, input replay ledger, interpolation buffer, or reconnect netcode, replace that code with RealtimeRoom rather than running both. Keep the game's existing simulation, physics, and rendering code: read input from setInput()/sendInput() on the host to advance simulation each tick, publish results with publishSnapshot(), and wire remote presentation into predict/interpolate/extrapolate/blendCorrection, driven by one game-owned requestAnimationFrame loop calling advanceFrame() and getRenderState().