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

@rydr/game-sdk

v1.19.1

Published

Client SDK for building games on the RYDR indoor-cycling platform: the typed platform↔game wire protocol, a bridged hardware hook, RYDR UI components/tokens, and a local dev harness.

Downloads

1,839

Readme

@rydr/game-sdk

The client SDK for building games on the RYDR indoor-cycling platform.

A RYDR game runs as a sandboxed cross-origin <iframe> embedded by the platform shell. The shell owns the hardware (BLE trainer, HRM, Zwift Play, phone) and the user; the game receives scoped hardware data and identity over a versioned postMessage wire protocol and never touches BLE or PII directly.

Beyond bridging hardware and identity, the shell also backs a set of services a game can call: leaderboards, opaque run records, replays/ghosts, a scoped game-data store (dev-authored content, per-player saves, and world-readable UGC), asset hosting, and realtime rooms — see Backend services. A game rarely needs its own backend.

This package is the public contract between platform and game:

  • protocol/ — the versionless wire protocol: handshake, capabilities, scoped identity, hardware/lifecycle messages, type guards. Treat as a public API — additive changes only.
  • client/connectToPlatform() → a PlatformSession exposing a reactive hardware store, scoped identity, and trainer-control commands.
  • host/createPlatformHost(): the platform side of the protocol (used by the shell to embed a game).

Install

Public npm package (no token needed — the source repo is private, the package is public):

// package.json
"dependencies": { "@rydr/game-sdk": "^1.0.0" }

Starting a new game? Don't wire this by hand — scaffold from create-rydr-game (npx degit bdefrenne/create-rydr-game my-game), which comes with the SDK wired, a dev script, and an agent-runnable SETUP.md.

The boundary

platform shell  ──(SDK wire protocol)──▶  game iframe
  owns BLE/HRM/profile/FIT                 receives scoped power/HR/cadence/buttons + identity

The game↔engine protocol inside a game (e.g. racing's Three.js engine iframe) is the game's private business and is not part of this SDK.

Usage (game side)

import { connectToPlatform } from "@rydr/game-sdk";

// Games get FULL access — you don't pick capabilities. Just pass your gameId.
const session = await connectToPlatform({ gameId: "racing" });

session.hardware.subscribe((hw) => render(hw.power, hw.heartRate));
session.onButton(({ name, edge }) => handleInput(name, edge));
session.setSimulation(4.2); // request 4.2% grade on the trainer
session.ready();

API reference

connectToPlatform(options)Promise<PlatformSession>. Options: { gameId: string; platformOrigin?: string; target?: Window; handshakeTimeoutMs?: number }. Games get full access — there's no capability selection. (capabilities?: Capability[] exists but defaults to ALL; you don't set it.)

PlatformSession

  • identity: ScopedIdentity{ playerId, displayName: string; weightKg, ftp: number } (PII-free).
  • grantedCapabilities: readonly Capability[] · initialPath: string | undefined.
  • hardware: HardwareStorecurrent: HardwareSnapshot, and subscribe(cb) => () => void (fires immediately, then on every change).
  • ready() · reportLoadProgress(0..100) · reportError(message).
  • setSimulation(gradePercent) · setTargetPower(watts) · setErgMode(enabled) — trainer control.
  • setRoute(path) · setPowerBar(visible) · setMenu(visible) · requestExit() · requestHardwareModal().

Deep links — your host must serve them. The shell mounts the game at game.url/<tail>, so a route you project via setRoute (and the initialPath you read on load) is the iframe's real URL. A direct hit or refresh of e.g. /game/<you>/play/abc reaches your origin as /play/abc — with no rewrite it 404s before index.html loads. Add a SPA rewrite so client routes fall back to index.html (on Vercel: "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]). Vercel applies rewrites only after a filesystem miss, so real documents (index.html, editor.html) and /assets/* are still served directly; the game then routes from session.initialPath.

No activity/FIT API. The platform records every session automatically from its own hardware stream — games do nothing for recording.

  • boards: readonly BoardDefinition[] · runId: string · dataHost: string — the game's leaderboard catalog (from its manifest), the run this session is recorded under, and the realtime backend host.
  • Backend services (detailed below): submitScore · getLeaderboard · saveRun/getRun · saveReplay/getReplays/getReplay · getContent/listContent · getData/listData/saveData/deleteData · saveContent/deleteContent · getUploadUrl · joinRoom.
  • onButton(cb) · onPause(cb) · onResume(cb) · onIdentityChange(cb) — each returns an unsubscribe fn.
  • dispose().

HardwareSnapshot = { power, smoothedPower, cadence, heartRate, speed: number; trainerConnected, ergSupported: boolean; updatedAt: number } — power W · smoothedPower W · cadence rpm · heartRate bpm (0 with no HRM) · speed m/s · updatedAt ms.

You get both power and smoothedPower — choose per use:

  • power — raw, last reading only (steppy between the ~1–4Hz updates). Use for the true instantaneous value: a watts readout, zone/metric math, logging, threshold checks.
  • smoothedPower — a time-based EMA over power, advanced on read so it ramps smoothly between updates and is frame-rate independent. Use for anything you drive continuously off power (a cursor, position, speed, fill bar) where raw jitter would look bad.

The smoothing time constant (seconds) defaults to DEFAULT_POWER_TAU_S (0.06) and can be overridden per game via the manifest or connectToPlatform({ powerSmoothing }); 0 disables it. Both are read the same way, e.g. session.hardware.current.smoothedPower.

ButtonEvent = { name: ButtonName; edge: "down" | "up" } (see protocol/buttons.ts for the ButtonName union).

The compiled types in dist/index.d.ts are authoritative — this section is the overview.

Backend services

The shell backs a handful of services so a game rarely needs its own backend. All calls go through the SDK; the platform stamps playerId + runId and enforces access. (dist/index.d.ts is authoritative for exact types.)

Leaderboards

Boards are declarative game config — each declares an id and how it ranks/formats, not created at runtime. They come from the game's manifest; the shell hands the catalog to the game at handshake as session.boards: BoardDefinition[]. submitScore to an unknown boardId is rejected — the board must be declared in the manifest first. (How a game declares boards in its manifest is part of its scaffolding, not the SDK.)

// session.boards = [{ id: "waves", valueType: "count", sort: "desc", aggregate: "best" }, …]
const { rank, isPersonalBest, total } = await session.submitScore("waves", wavesCleared);
const page = await session.getLeaderboard("waves", { limit: 10 }); // { entries, you? }
  • submitScore(boardId, value, { key? }){ rank, isPersonalBest, total } — for a results screen. key selects a parameterized board family member (e.g. per-track).
  • getLeaderboard(boardId, { key?, limit? }){ entries: BoardEntry[], you? } — top-N plus the requester's own row.
  • Power-source partitioning is automatic. The shell splits every board by power source (keyboard vs trainer) — a key-press effort never ranks against a pedalled one — appending the source to your key on both submit and read. Don't encode input source in key yourself; just pass your own dimension (e.g. a track id) and you'll always read back the board matching the current input.
  • formatBoardValue(valueType, value) formats a raw value for display. It is a standalone package export, not a session methodimport { formatBoardValue } from "@rydr/game-sdk", then formatBoardValue(board.valueType, entry.value).

Run records

session.saveRun({ outcome: "win", waves: 12 }); // fire-and-forget — returns void, do NOT await
const detail = await session.getRun(someEntry.runId); // read a breakdown back (e.g. leaderboard detail)
  • saveRun(breakdown) stores an opaque, game-specific object against this session's runId (which links to the FIT activity). Fire-and-forget — returns void, do not await.
  • getRun(runId)Promise<unknown | null> reads a stored breakdown back (e.g. expand a leaderboard row; BoardEntry.runId is the key). null if absent.

Replays / ghosts

A replay is an array of frames the game interpolates over to render a ghost. Every frame is { t, power, customData? }: t (ms from start) and power (watts) are mandatory and platform-readable — so the timeline and power of any replay are legible to the platform/tooling — while customData is the game's own opaque per-frame payload (position, lean, animation…). Timing lives entirely in t, so frames need not be evenly spaced (no global sample rate / frame count to drift).

The SDK owns the wire shape: saveReplay packs the frames into a versioned, gzip+base64 blob, and derives a small ReplayMeta summary — { durationMs, avgPower, maxPower } — stored alongside it so a ghost list can render without decompressing every blob. Who/score/when are not in the meta; they're on the leaderboard entry sharing the same runId. Because the leaderboard stamps runId on every entry, a replay is also the ghost for that standing.

// After a run: store the ghost against this session's runId. The SDK encodes + derives meta.
await session.saveReplay(session.runId, frames); // frames: { t, power, customData? }[]

// Cheap ghost list — meta only, no blob decode:
const ghosts = await session.getReplays("lap", { key: trackId, top: 5 });
for (const g of ghosts) {
  if (g.meta) showRow(g.displayName, g.rank, g.value, g.meta.durationMs, g.meta.avgPower);
}

// Race against a specific ghost — decode its frames:
const r = await session.getReplay(ghosts[0].runId);
if (r) spawnGhost(r.body.frames); // r = { body: ReplayBody, meta: ReplayMeta | null }
  • saveReplay(runId, frames, { version? })Promise<void> — encode ReplayFrame[] and persist the blob + derived meta keyed by runId. Large blobs are chunked server-side; for truly large binaries prefer asset upload (R2) and store the URL.
  • getReplays(boardId, { key?, top? })Promise<ReplayRef[]> — the top entries' ghosts. Each ReplayRef = { runId, rank, displayName, value, blob: string | null, meta: ReplayMeta | null } (blob/meta are null for an entry with no stored replay). Use meta for display; this does not decode frames. top defaults to 10; key selects a parameterized board member.
  • getReplay(runId)Promise<{ body: ReplayBody, meta: ReplayMeta | null } | null> — fetch and decode one replay (a board entry's runId, the session's own, or a shared-link id). null if none stored.
  • encodeReplay(frames, version?) / decodeReplay(blob) — the codec, exported standalone for tooling or when you hold a raw blob.

Game-data store (opaque docs)

Three scopes. data is opaque to the platform — the game owns the shape. Docs are GameDoc ({ id, data, updatedAt, ownerId?, draft? }).

| Scope | Who can read / write | Methods | |-------|----------------------|---------| | player (default) | the player only — private saves | getData · listData · saveData · deleteData | | public | owner writes, world-readable — player UGC | same methods, with { scope: "public" } | | shared | world-readable, admin-gated write — dev-authored content | getContent · listContent (read) · saveContent · deleteContent (write) |

await session.saveData("saves", "slot1", { level: 4, hp: 80 }); // player-private (default scope)
const slot = await session.getData("saves", "slot1");           // → GameDoc | null
const tracks = await session.listContent("tracks");             // dev-authored shared content

Player content uses public, not shared. saveContent/deleteContent/getUploadUrl write to shared and are admin-gated: they succeed only when the player is in the platform's admin mode (session.identity.isAdmin === true) — the shell relays the admin secret for you; a normal player calling them is rejected. Route player-generated content through saveData(collection, id, value, { scope: "public" }). Reserve saveContent/getUploadUrl for in-game author/admin tooling (level/chart/song/world editors).

Asset upload

For binaries (MP3s, images, glbs) backing shared content. Admin-gated (same as saveContent).

const { uploadUrl, url } = await session.getUploadUrl({ collection: "songs", filename: "track.mp3" });
await fetch(uploadUrl, { method: "PUT", body: file }); // PUT the bytes directly
await session.saveContent("songs", "track-1", { title: "…", audioUrl: url }); // store the public url

Build an in-game editor

Any game can ship an editor for its own shared content (levels, tracks, charts, worlds, …). The game reads it back through the session (listContent/getContent) — one shared backend, no per-game server. Authoring is admin-gated: a write succeeds only when the user is in the shell's admin mode (the shell holds the secret — the game never does).

The crystal-clear rule: your game never handles the secret. You only:

  1. Gate your editor UI on session.identity.isAdmin — show the "Edit" button / editor route only when it's true.
  2. Call the normal session methodssaveContent / deleteContent / getUploadUrl. The shell attaches the admin secret on your behalf (it holds it; your iframe never sees it). For a non-admin player these reject; for an admin they succeed.
// in-game editor (embedded in the shell — the normal case)
if (session.identity.isAdmin) {
  showEditorButton();
}
// …when the author saves:
const { uploadUrl, url } = await session.getUploadUrl({ collection: "songs", filename: "track.mp3" });
await fetch(uploadUrl, { method: "PUT", body: file });
await session.saveContent("songs", "track-1", { title: "…", audioUrl: url });
// players read it back with no special rights:
const tracks = await session.listContent("songs");

That's the whole contract. No author allowlist, no secret in your game, no per-game server. To become admin, a user enters the secret once via the shell's ?admin flow (stored in the shell's localStorage) — your game just reads isAdmin.

There is exactly one way: open the editor in the shell and gate on isAdmin

An editor is not a standalone app — it is itself a guest the shell loads. It is always opened inside the platform shell, and it authors through the session, gated on session.identity.isAdmin. There is no "outside the shell" editor, and a game never prompts for, stores, or sends the ADMIN_SECRET. It doesn't have the secret and doesn't need it — the shell holds it and relays the authenticated write. If you're pasting a Bearer into a page, that page has stopped being a guest and you've broken the security boundary. Don't.

How it works end to end:

  1. Your editor lives at a path on your game's origin — a route or a static page (e.g. run-editor.html).
  2. The shell opens it as a guest via a deep link: /game/<your-game>/run-editor mounts https://<your-game-origin>/run-editor in the guest iframe; your own host/router resolves the path.
  3. That page calls connectToPlatform() exactly like the game does and receives a session with identity.isAdmin stamped by the shell.
  4. To become admin, the user enters the secret once in the shell's ?admin flow (stored in the shell's localStorage). The editor only ever reads isAdmin.
// run-editor.html — a guest page, opened in the shell at /game/<your-game>/run-editor
const session = await connectToPlatform({ gameId: "my-game", capabilities: ["identity"] });
if (!session.identity.isAdmin) {
  // Not in admin mode — show "unlock admin mode in the shell", author nothing.
  return;
}
// Admin: author through the session. The shell attaches the secret; this iframe never sees it.
const { uploadUrl, url } = await session.getUploadUrl({ collection: "levels", filename: "bg.png" });
await fetch(uploadUrl, { method: "PUT", body: file });
await session.saveContent("levels", "level-1", { name: "…", bgUrl: url, draft: false });
const levels = await session.listContent("levels"); // includes drafts for an admin; players see only published
await session.deleteContent("levels", "old");

Drafts are your data, not a platform flag. saveContent takes no draft argument — a doc's draft/published state is just a field in the content you store ({ draft: true, … }). Your editor writes it; your game and lobby read it and hide drafts from non-admins (admins keep seeing them via the same isAdmin).

Security boundary. ADMIN_SECRET is the platform owner's key — full write to any game's shared content. It lives only in the shell, is never shipped to a game or guest, and is never entered into editor code. Player-generated content uses the public owner-write scope (saveData(..., { scope: "public" })), not admin auth.

createAdminContentBackend is not for games. The SDK exports a low-level Bearer client (createAdminContentBackend) for the platform owner's own out-of-band tooling — a first-party admin console on the shell's origin, a seed/migration script — code that legitimately holds the secret. It is never bundled into a game or a game's editor. If you're in a game, use the session.

Shared worlds

The platform has a first-party world editor that authors reusable 3D environments (terrain + props + lighting). Any game can load one — the world is pure environment; your game layers gameplay on top (spawns, a track, …), keyed by the world id in your own game-data.

applyWorld is renderer-agnostic and pulls in no three dependency from the SDK — you bring your own three.js + GLTFLoader:

import { applyWorld } from "@rydr/game-sdk";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";

const loader = new GLTFLoader();
const worlds = await session.listWorlds();           // pick one, or use a known id
const world  = await session.getWorld(worlds[0].id);
await applyWorld(scene, world, { loadGlb: (url) => loader.loadAsync(url).then((g) => g.scene) });

Authoring worlds happens in the platform's editor (admin-gated), not in your game; your game only reads them.

Batch draw calls (optional @rydr/game-sdk/three helper)

three.js submits one draw call per visible mesh, so a platform world (often ~1800 props) is usually the dominant render-CPU cost. The optional @rydr/game-sdk/three entry point (requires the three peer dep) loads a world and can collapse it to ≈one draw call per material — you don't write any merge code, you flip a flag:

import { loadWorld } from "@rydr/game-sdk/three";

const world = await loadWorld(await session.getWorld(id), { mergeStatics: true });
world.attach(scene);   // ≈ one draw call per material instead of one per prop

Merging bakes each prop's transform into shared vertex data, so a subtree you animate/toggle must opt out with userData.__noMerge = true (no current static platform world needs this).

For articulated objects built from many small parts (an enemy with a rotating turret, a car with spinning wheels), use the same primitive directly and declare the animated sub-nodes as boundaries — the SDK still owns the merge; you only tell it what moves:

import { mergeByMaterial } from "@rydr/game-sdk/three";

// Parts cloned a shared palette material per-instance → group by source material, not instance.
mergeByMaterial(unitGroup, {
  groupBy: "source",                       // each part has its own material clone
  boundaries: [turretYaw],                 // keeps the turret animating; bakes the rest
  keepIndividual: (m) => m.userData.unit?.recoilDist > 0,  // leave recoiling barrels alone
  disposeConsumed: true,                   // owned, private clones — safe to free
});

Building a 3D world/level editor for your game? There's a shared editor core — @rydr/core-world-editor (in rydr-platform/src/core-world-editor) — that gives you the map editor, camera, gizmo, and undo; your game adds a small capability for its own gameplay layer (spawns, a grid, a route). The canonical how-to is that package's README.md — start there rather than hand-rolling a three.js editor. Convention: ship it as world-editor.html → the canonical route /world-editor (deep link /game/<your-game>/world-editor); a scenario/timeline editor is the sibling /run-editor. (This SDK section is only about loading a world at runtime via applyWorld.)

Realtime rooms

const room = session.joinRoom("lobby");
const off = room.on("message", (data, from) => render(data, from));
room.on("presence", (members) => updateRoster(members));
room.send({ kick: true });          // relay to other members
room.setState({ phase: "racing" }); // merge into shared opaque state (last-write-wins)
// room.members · room.state · room.leave()

joinRoom(roomId)RoomHandle over a direct WebSocket (presence + relay + opaque shared state; the server is dumb — the game defines what messages/state mean). Events: message, presence, state, open, close; each on(...) returns an unsubscribe fn. In standalone dev (no shell) it falls back to a local single-member loopback room, so room code runs without a backend.

Status: when room lands. The client + protocol are shipped, but the backend room party is not yet deployedjoinRoom works in standalone-dev loopback today, and goes live against the shared backend with the realtime/multiplayer follow-up. Build against it; just don't expect cross-client presence in production until then.

Versioning

RYDR_PROTOCOL_VERSION is the wire version. The shell supports a range and adapts older messages. Breaking shape changes are forbidden; evolve additively.