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

@genex-ai/multiplayer

v0.11.0

Published

The multiplayer client wrapper + shared room schema for genex.

Readme

@genex-ai/multiplayer

Tiny realtime multiplayer for genex games.

Relay model: whatever you write to me or shared is synced to everyone; everything else stays local. The server does no physics, no prediction — you run your own game logic, this just shares state. Remote players are smoothed automatically (client-side entity interpolation — see Smoothing remote players).

import { connect } from '@genex-ai/multiplayer';

Connect

import { waitForPlayer, getColyseusAuth, getColyseusUrls } from '@genex-ai/embed-sdk';

type State = { x: number; y: number; z: number };   // YOUR per-player state — any JSON shape

await waitForPlayer();                        // identity gate — resolves for guests AND accounts
const room = await connect<State>({
  urls: getColyseusUrls(),          // regional relays for this session — SDK joins the fastest
  room: '<game-id>',                          // everyone with the same id shares a room (use the project slug)
  name: 'ada',                                // optional display name
  auth: () => getColyseusAuth(),              // REQUIRED — resolved fresh for this connect
});

auth is required — the relay rejects tokenless joins. Get it from @genex-ai/embed-sdk: await waitForPlayer() first (never waitForAuth() — that resolves only for signed-in accounts and keeps guests out forever), then pass () => getColyseusAuth() so each explicit connect() reads it fresh — tokens rotate every ~10 min, so never cache the object across reconnects. Never log it.

Different room ids are fully isolated — players in game A never see game B.

connect() joins immediately. Use it for one ongoing drop-in world. If the game has a Play/Online/Local/Bots menu, call it only after the online click and call room.leave() when the player returns to the offline menu. Immediate connect + spawn is valid only when loading the game already means joining the always-online world; do not put a fake Play screen in front of a player who is already present. Use matchmake() below for fresh bounded sessions with queue/quorum/fair start/backfill behavior.


me — publish your own state

Replaces your state wholesale. Call on a fixed tick (~10–20Hz), not every render frame — each call is one network message.

room.me.set({ x, y, z });
room.me.snap({ x: spawnX, y: spawnY, z: spawnZ }); // respawn/teleport: hard-reset remote history

players — everyone in the room

A fresh Map each read; includes you (skip with id === room.id). For remote players p.state is auto-smoothed (interpolated); for yourself it's live. p.stateRaw is always the latest raw snapshot (use it for hit-tests / logic). A reserved reconnecting seat stays in players with p.connected === false; use room.activePlayers when gameplay needs the live transport quorum.

for (const [id, p] of room.players) {
  if (id === room.id) continue;
  draw(p.name, p.state.x, p.state.y, p.state.z);   // p.state is typed as State
}

Remote players glide instead of teleporting between network updates — see Smoothing remote players.

shared — shared room state

A key/value store (any JSON) synced to everyone. Use for world state, scores, the current round, etc.

room.shared.set('round', 3);
room.shared.get('round');   // 3
room.shared.keys();         // ['round', ...]

objects — shared objects nobody owns until claimed

For a ball, a puck, an NPC — a moving thing that isn't any one player. Exactly one client owns an object at a time (the server enforces it); the owner simulates it and sets it, and everyone else reads it auto-smoothed, on the same interpolation as remote players. Ownership survives the owner leaving — the object is reassigned to the room host.

// Legacy fire-and-forget claim: optimistic locally, corrected by the relay if rejected.
if (iTouched) room.objects.claim('ball');

// Irreversible action: wait for the authoritative result.
const result = await room.objects.claimConfirmed('ball');
if (result.accepted) applyKick();
else if (result.reason === 'held') retryWhileStillTouching(result.retryAfterMs);

if (room.objects.get('ball')?.isMine) {
  room.objects.set('ball', simulateBall());        // only the owner's writes land
}
const ball = room.objects.get('ball');             // { owner, isMine, epoch, state, stateRaw }
drawBall(ball.state);                              // smoothed for everyone (live for the owner)

room.objects.release('ball');                      // give it up (stays until reclaimed)
room.objects.remove('bullet');                     // destroy transient objects when done

await room.objects.releaseConfirmed('ball');       // acknowledged + idempotent under reconnect
await room.objects.removeConfirmed('bullet');      // acknowledged owner-only removal

// Deliberate discontinuity: one hard reseed, never an old-to-new interpolation sweep.
room.objects.snap('ball', { x: 0, y: 1, z: 0, q: [0, 0, 0, 1] });

Keep object state flat (top-level numbers + a 4-number quaternion) so it smooths, same as a player. state is render-only and smoothed; stateRaw is the raw latest for every gameplay read (hit-tests, reach, pickups, physics adoption, discrete values). objects.ids() lists them. objects.set() and objects.snap() are owner-only: SDK 0.10.2+ ignores an unowned call and warns once per object/operation. Await claimConfirmed() and check accepted before an irreversible write; legacy claim(); set() remains valid because claim() is optimistic locally.

The relay enforces a short minimum ownership hold to prevent contact thrash. A current-owner reassert is a true no-op: it does not extend that hold. Legacy claim() remains optimistic for 0.8.x compatibility; use the confirmed methods when feedback, impulses, seats, scoring, release, or removal must not lie. Confirmed requests have stable request ids internally and are resent after a reconnect, so an accepted operation is idempotent. A rejected result reports the current owner and, for held, retryAfterMs.

A timeout result means the outcome is unknown, not "failed": the relay may have applied the operation and only the answer was lost. Shared owner truth (objects.get(id)?.isMine / owner) converges through the state mirror regardless — treat it, not the timed-out promise, as authoritative before tearing anything down. On a relay that predates protocol v2, me.snap / objects.snap degrade gracefully: the discontinuity write itself is not stored (peers converge on your next ordinary set, without the history reset) — which is why the relay must be upgraded before games ship an SDK that relies on snaps (see the migration note).

claimConfirmed(id) is an ordinary fair player claim, including when the caller happens to be host. Only host-owned lifecycle work such as initial seeding or a goal reset should request claimConfirmed(id, { authority: 'host' }); the relay accepts that bypass only from the current host. When it actually bypasses an active hold the result reason is host-authority; a non-host request is rejected as not-host. Do not use host authority for ordinary host-player collisions.

me.snap and objects.snap are for discontinuities only: respawn, teleport, reset, or a player mode edge such as exiting a vehicle. Ordinary movement must use set, otherwise motion will visibly snap.

isHost / host — the elected room authority

One client is the host — the earliest signed-in player still present (guests host only while no account is in the room), re-elected automatically. The host can change on join as well as leave: the first account entering a guest-hosted room takes over. Use it to pick a single writer for scores/rounds or a single simulator for NPCs.

if (room.isHost) room.shared.set('round', nextRound);   // only the host advances the round
room.on('host', (id) => {});                             // host changed (migration)

isHost/host settle within the first patch after connect() — read them in your loop (or react to on('host')), not once at startup.

on — events

room.on('join',   (id, state) => {});  // a player appeared (also fires for YOU on connect)
room.on('change', (id, state) => {});  // a player updated their me-state
room.on('leave',  (id)        => {});  // a player left
room.on('shared', (key, value) => {}); // a shared key changed
room.on('object', (id)        => {});  // an object's owner changed — the render glides across it (soft handoff)
room.on('host',   (id)        => {});  // the room host changed
room.on('hit',    (payload)   => {});  // a custom event someone sent (see `send`)

room.on('reconnecting', ({ attempt }) => {}); // connection dropped — auto-reconnect underway
room.on('reconnected',  ()            => {}); // back in the SAME seat: id, objects, host intact
room.on('disconnect',   (code)        => {}); // terminal — connect() fresh to play again

on(...) returns an unsubscribe function.

Reconnection (automatic)

A dropped socket (Wi-Fi blip, brief signal loss) is not immediately a leave: the relay holds your seat for a grace window (~30s) and the SDK reconnects with backoff. A short blip keeps the same session id, owned objects, and host status. Simulation availability is a separate lease: if the disconnected seat is host for longer than the relay's short host-failover grace, an active peer becomes host and host-owned objects move once. A returning old host keeps its seat but does not steal authority back. Host-tick callbacks pause while their socket is reconnecting and resume only if that session still owns the host role. After a long reconnect, remote interpolation histories are rebased to current truth so they cannot draw a whole-map catch-up streak.

Render reconnecting/reconnected as an overlay and keep your loop running. disconnect is terminal (server restart, revoked session, or the link never recovered): read a fresh token and connect() again. A deliberate leave() never reconnects. The relay also broadcasts server:restart right before a deploy — flush saves when you see it.

One seat per player: the relay enforces a single session per identity per room — a second tab / device / reloaded page supersedes the previous session (old one gets disconnect, code 4409), so the same player never appears twice and reloads never leave a ghost. Reload/close sockets ("going away") get only a ~5s seat-hold; genuine network drops keep the full reconnection grace.

inputs + onHostTick — host-authoritative physics (contested objects)

Claim-on-touch fits any ownable body (a ball, a box, a prop) — even when players take turns bumping it, because the ownership handoff glides (a soft handoff, since 0.8.4) instead of teleporting; give the object a Rapier proxy that's dynamic while you own it and a kinematicPosition follower of state when you don't. Reserve host-authoritative for a genuine simultaneous contest (two players pushing one crate against each other, sumo): let ONE neutral simulation own it — everyone sends inputs, the relay routes them to the current host only, and the host applies them on a fixed tick and publishes results via objects (auto-smoothed for everyone):

if (pushing) room.inputs.send({ obj: 'crate', push: dir });   // any client → host only

room.inputs.on((fromId, payload) => queue.push(payload));      // fires only while YOU host
room.onHostTick(30, (dtMs) => {                                // runs only on the host,
  // adopt objects + seed from stateRaw on first tick, apply    //  auto-migrates with the
  // queued inputs to YOUR one physics world, then:             //  host election
  room.objects.set('crate', { x, y, z, q });
});

onHostTick auto-starts on election, pauses during reconnect, resumes after reconnect only if still host, and stops on demotion, deliberate leave(), or terminal disconnect. The disposer is still useful when the game removes that subsystem earlier. A new host must confirm an explicit host-authority claim and seed its world from each object's stateRaw, so simulation survives host migration without a snap.

send — custom one-off events

Fire-and-forget to all other clients (not stored in state). For shots, emotes, chat, pings.

room.send('hit', { from: room.id, target: 'xyz', dmg: 10 });

leave

room.leave();

Smoothing remote players

Remote players are smoothed automatically — nothing to configure. room.players gives each remote player's state rendered slightly in the past and interpolated between snapshots, so motion glides between network updates (you send ~10–20Hz but render at 60). Interpolation runs on the sender's clock (each update is timestamped and mapped to your local clock), the way Valve/Unity/Mirror do it — so jitter doesn't distort the motion. The delay self-tunes to both the live snapshot rate and the link's measured jitter, so it stays smooth whether you send at 5Hz or 30Hz and whether the connection is a clean LAN or a jittery mobile link — a deeper buffer is used only where the jitter demands it.

Do not add another lerp, snapshot buffer, or remote physics predictor on top. state is already the render value. This relay does not implement classic client-side prediction plus server reconciliation: your own player is client-authoritative and renders locally; remote entities use interpolation and bounded extrapolation.

  • player.state is the smoothed value (what you draw); player.stateRaw is the latest raw snapshot — use it for hit-tests / discrete logic.
  • Your own player is never delayed — you always render yourself live.
  • Numbers are interpolated; a 4-element [x, y, z, w] quaternion is slerped (shortest-arc); anything else snaps.
  • Send rotation as a quaternion: state.q = mesh.quaternion.toArray(), then on the remote mesh.quaternion.fromArray(p.state.q). The package slerps it and output-smooths it so it glides through turns.
  • Late/dropped packets keep moving: position dead-reckons forward from its last velocity (capped ~0.25s, Source-style) until fresh snapshots arrive; rotation also continues the turn but on a tighter cap (~0.15s), so a remote's heading keeps moving through a brief gap instead of freezing and then snapping when the next packet lands — the short cap plus output-smoothing bound any overshoot. This is what holds up on high-latency links.

No client-side prediction/reconciliation: in this relay you're authoritative over yourself (you render instantly, the server never corrects you), so there's nothing to predict — smoothing remote players is the piece that matters.


Complete minimal example (move a cube, see others as cubes)

import { connect } from '@genex-ai/multiplayer';
import { waitForPlayer, getColyseusAuth, getColyseusUrls } from '@genex-ai/embed-sdk';
type S = { x: number; z: number };

await waitForPlayer();   // identity gate (guest OR signed-in) — see Connect above
const room = await connect<S>({
  urls: getColyseusUrls(),
  room: GAME_ID,
  auth: () => getColyseusAuth(), // REQUIRED — resolved fresh for this connect
});

const me = { x: 0, z: 0 };

// publish my position ~15Hz (NOT per frame)
setInterval(() => room.me.set(me), 66);

// render loop (your engine / three.js): draw every player at their latest position
function frame() {
  for (const [id, p] of room.players) {
    const cube = cubeFor(id);                 // create-or-reuse a mesh per id
    cube.position.set(p.state.x ?? 0, 0, p.state.z ?? 0);   // auto-smoothed
  }
  requestAnimationFrame(frame);
}
frame();

// drop a player's cube when they leave
room.on('leave', (id) => removeCube(id));

// input just mutates `me`; the tick above syncs it
onKey('right', () => { me.x += 1; });

matchmake — competitive matches (server-owned)

When players should be matched into separate capped rooms instead of sharing one big room (1v1, FFA, teams, invite lobby), use matchmake() instead of connect(). The queue, roles, winner-stays, forfeit, timeout, and win condition are all server-owned — the client declares nothing and runs no matchmaking logic.

import { matchmake } from "@genex-ai/multiplayer";

// Pass `auth` as a FUNCTION — a handle re-joins many times (re-search/requeue) and tokens rotate
// (~10 min); a function is read fresh each join, a static object goes stale mid-session.
const mm = await matchmake<State>({ urls, room: slug, auth: () => getColyseusAuth() });

// `session` is null WHILE SEARCHING — render your own "finding a match…" HUD until it goes live.
// each frame:
if (mm.session) renderGame(mm.session);        // a live Session (same API as connect())
else renderSearchingHud(mm.matchmaking);

mm.on("matched", () => {/* session is live */});
mm.on("matchStart", ({ roundId }) => {/* round began */});
mm.on("matchEnded", ({ winnerId, scores, draw }) => {/* result screen */});
mm.on("spectate", () => {/* DEPRECATED — never fires: there are no server-side spectators anymore.
                            An overflow searcher now waits in the queue instead of watching. */});
mm.on("error", (e) => {/* queue join failed even after the SDK's automatic retries — persistent
                          (broken auth / server unreachable); fix the cause, then mm.retry() */});

// Report ONLY your own outcome — the server adjudicates. Pick the call that matches the win condition:
mm.eliminated();   // I'm out              (lastStanding)
mm.score(1);       // I scored             (firstToScore / highScoreInTime)
mm.finish();       // I finished the race  (firstToFinish)

mm.retry();        // re-enter the queue after a terminal 'error' (no-op in any other state)
mm.cancel();       // stop searching / leave the match

A failed queue join is retried automatically with backoff (~10s total), so transient failures (a token mid-rotation, identity still settling on page load) never surface. Only a persistent failure emits error; the handle then idles until you call mm.retry().

mm.matchmaking (read every frame for your HUD): status (searching | waiting | countdown | playing | ended; the legacy spectating is never emitted — server-side spectators were removed), queue ({ position, size }), players (reserved seats), connectedPlayers (live transports), opponents, teams, myTeam, scores, winCondition, config (numeric knobs), roundId, winnerId, message. championId/challengerId are 1v1 aliases. During an automatic reconnect, the SDK retains the last coherent roster and match view until the replacement room hydrates, so status does not transiently regress to waiting.

Configure once in the game's package.json (reported at genex preview AND genex publish; removing it clears the stored config on the next preview/publish — never sent from the client):

"genex": {
  "matchmaking": {
    "preset": "arena",                 // open | duel | arena | teams | private
    "winCondition": "firstToScore",    // lastStanding | firstToScore | highScoreInTime | firstToFinish
    "config": { "scoreTarget": 20, "maxPlayers": 8 }   // numeric knobs, optional
  }
}

Presets: open (a room of N players, your rules — see below), duel (1v1 winner-stays), arena (N-player FFA, join-anytime), teams (balanced N-v-N), private (invite-code lobby). Win conditions (for the batteries-included presets): lastStanding, firstToScore, highScoreInTime, firstToFinish; a round that hits the time cap undecided is a draw.

open — bring your own rules. The minimal preset: the server owns only seating, capacity, and refill; you build teams, rounds, scoring, and win logic yourself on session.shared + host election. It runs no match loop, so status only goes waitingplaying (never ended), scores/winnerId stay empty, and nobody is ever evicted (no matchEnded, no requeue). Config knobs: minPlayers (quorum to flip to playing; default 1), maxPlayers (seat cap, clamped 2–64), fill (1 = keep the room full — grow to max and refill a seat a leaver frees, the default; 0 = lock the room for good once it is simultaneously full (max players at once) — a match with no substitutes; a freed seat stays closed). Pick open when you want your own rules; pick duel/arena/teams for a ready-made match loop.

Private lobbies — createPrivate / joinPrivate

For preset: 'private' games, skip the queue: a host mints an invite code, friends join it, and everyone lands in the SAME persistent lobby (rounds replay, nobody is evicted). Both handles have the same shape as matchmake(), but session is live on return (no searching phase).

import { createPrivate, joinPrivate } from "@genex-ai/multiplayer";

// host
const lobby = await createPrivate<State>({ urls, room: slug, auth: () => getColyseusAuth() });
share(lobby.code);                 // e.g. show it in the UI

// friend (elsewhere)
const joined = await joinPrivate<State>(code, { urls, room: slug, auth: () => getColyseusAuth() });

// then identical to matchmake(): .session, .matchmaking, eliminated()/score()/finish(), cancel()

Rules of thumb

  • me / shared are synced; everything else is local. Movement input → mutate a local object → me.set it on the tick.
  • Tick me.set at ~10–20Hz, render at your own framerate. One me.set = one network message.
  • room (game id) must be stable per game — use the project slug. Same id = same room; different id = isolated.
  • Remote players are smoothed automatically — read stateRaw when you need the raw latest (hit-tests). No server-side prediction — you render yourself live.
  • me.set replaces your whole state object — send the full object each time, not a partial.

Persistent worlds (optional)

The relay is in-memory — room state is gone when everyone leaves or the server restarts. To persist world state across restarts, use @genex-ai/embed-sdk's state helpers (they own auth tokens, guest handling, and write-conflict decoding):

import { loadWorldState, saveWorldState } from '@genex-ai/embed-sdk';

// load on boot
const { data, version } = await loadWorldState();
initWorld(data ?? defaultWorld());

// save (from ONE authority — the elected host — debounced, with ifVersion so
// races surface as { conflict: true } instead of silently clobbering)
if (room.isHost) await saveWorldState(world, { ifVersion: version });

The relay prefers signed-in players when electing the host (guests cannot write saves), so host-driven persistence works whenever any account is in the room. Per-player progression belongs in savePlayerState() (each player's own slot), never in the shared world blob.