@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()→ aPlatformSessionexposing 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 + identityThe 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: HardwareStore—current: HardwareSnapshot, andsubscribe(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 viasetRoute(and theinitialPathyou read on load) is the iframe's real URL. A direct hit or refresh of e.g./game/<you>/play/abcreaches your origin as/play/abc— with no rewrite it 404s beforeindex.htmlloads. Add a SPA rewrite so client routes fall back toindex.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 fromsession.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 overpower, 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.tsare 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.keyselects 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
keyon both submit and read. Don't encode input source inkeyyourself; 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 method —import { formatBoardValue } from "@rydr/game-sdk", thenformatBoardValue(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'srunId(which links to the FIT activity). Fire-and-forget — returnsvoid, do not await.getRun(runId)→Promise<unknown | null>reads a stored breakdown back (e.g. expand a leaderboard row;BoardEntry.runIdis the key).nullif 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>— encodeReplayFrame[]and persist the blob + derived meta keyed byrunId. 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. EachReplayRef={ runId, rank, displayName, value, blob: string | null, meta: ReplayMeta | null }(blob/metaarenullfor an entry with no stored replay). Usemetafor display; this does not decode frames.topdefaults to 10;keyselects a parameterized board member.getReplay(runId)→Promise<{ body: ReplayBody, meta: ReplayMeta | null } | null>— fetch and decode one replay (a board entry'srunId, the session's own, or a shared-link id).nullif 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 contentPlayer content uses
public, notshared.saveContent/deleteContent/getUploadUrlwrite tosharedand 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 throughsaveData(collection, id, value, { scope: "public" }). ReservesaveContent/getUploadUrlfor 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 urlBuild 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:
- Gate your editor UI on
session.identity.isAdmin— show the "Edit" button / editor route only when it'strue. - Call the normal session methods —
saveContent/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:
- Your editor lives at a path on your game's origin — a route or a static page (e.g.
run-editor.html). - The shell opens it as a guest via a deep link:
/game/<your-game>/run-editormountshttps://<your-game-origin>/run-editorin the guest iframe; your own host/router resolves the path. - That page calls
connectToPlatform()exactly like the game does and receives a session withidentity.isAdminstamped by the shell. - To become admin, the user enters the secret once in the shell's
?adminflow (stored in the shell's localStorage). The editor only ever readsisAdmin.
// 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.
saveContenttakes nodraftargument — 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 sameisAdmin).
Security boundary.
ADMIN_SECRETis 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 thepublicowner-write scope (saveData(..., { scope: "public" })), not admin auth.
createAdminContentBackendis 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 propMerging 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(inrydr-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'sREADME.md— start there rather than hand-rolling a three.js editor. Convention: ship it asworld-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 viaapplyWorld.)
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
roomlands. The client + protocol are shipped, but the backendroomparty is not yet deployed —joinRoomworks 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.
