@rydr/game-sdk
v9.6.0
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.
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" }Resolved from the npm registry (not a git URL). To upgrade, bump the range and run npm install
— never hand-edit package-lock.json (npm ci in CI/pre-push demands an exact match and rejects a
drifted lock). A new SDK version is installable only after its tag's publish CI has run; a pushed
git tag alone is not enough (see the SDK repo CLAUDE.md → Releasing & how consumers get it).
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.
Developing the SDK against a game locally (without publishing) — DO THIS
This is the canonical way. If you're an agent or dev unsure how to test an SDK change in a game, follow this exactly — do not invent an alternative. Every RYDR game's
vite.config.tsalready ships the block below;create-rydr-gamescaffolds it into new games.
The one step: check out the SDK repo as a SIBLING folder of the game (../rydr-game-sdk). That's
it. The game's vite.config.ts detects the sibling and aliases every @rydr/game-sdk[/subpath]
import to the SDK's source, so your edits appear on the next reload with no publish, no version
bump, no tsc -w, no npm link, and no cache to clear. Remove/rename the sibling (CI, prod, a
standalone clone) and it silently falls back to the published npm package — so committing the block
is safe.
your-projects/
├─ rydr-game-sdk/ ← check this out as a sibling…
└─ your-game/ ← …and this game auto-uses its source in devTwo things make local SDK edits not show up, and this pattern kills both at once:
- Resolution — a bare import resolves the published
@rydr/game-sdkfromnode_modules, not your working copy. The alias points it at the sibling source. - Bundler dep cache — Vite pre-bundles
node_modulesdeps intonode_modules/.vite/depsand serves them from memory until a full dev-server restart, so a rebuilt SDK keeps serving stale (the classicTypeError: session.newThing is not a functionfor an API you know you added — a page reload orrm -rf .viteon a running server does nothing). Aliasing to source means it's never pre-bundled, so there's nothing to go stale.
The block every game ships — a single regex alias covers all subpaths (/three, /ui,
/conversations, …), so you never enumerate them:
// vite.config.ts
import { defineConfig } from "vite";
import { resolve } from "node:path";
import { existsSync } from "node:fs";
const sdkSrc = resolve(__dirname, "../rydr-game-sdk/src");
const linkSdk = existsSync(sdkSrc); // sibling present → use its source; absent → published package
export default defineConfig({
resolve: {
alias: linkSdk ? [{ find: /^@rydr\/game-sdk(\/.*)?$/, replacement: `${sdkSrc}$1/index.ts` }] : [],
dedupe: ["three"], // if you use @rydr/game-sdk/three, avoids two THREE instances (no-op otherwise)
},
});// tsconfig.json — keep `tsc` resolving the same source the bundler runs (the `/*` covers all subpaths)
"paths": {
"@rydr/game-sdk": ["../rydr-game-sdk/src/index.ts"],
"@rydr/game-sdk/*": ["../rydr-game-sdk/src/*/index.ts"]
}Then, in two terminals: cd rydr-game-sdk && npm run dev (optional — only needed to typecheck the
SDK itself; the game reads its source directly, so no build is required) and cd your-game && npm run
dev. Edit the SDK → the game hot-reloads.
Only publish when the change is ready to ship — see the SDK repo CLAUDE.md → Releasing & how
consumers get it (npm version <patch|minor|major>; a pushed tag alone is not installable until
publish CI runs — confirm with npm view @rydr/game-sdk version before bumping the consumer).
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.setActivity("playing"); // full resistance while actually being ridden
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).ftpis live: the rider steps their playing FTP with − / + at any time (the shell owns it; the SDK forwards the keys), so subscribe withonIdentityChangerather than snapshotting it at init.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)~~ — inert on the RYDR shell. Grade simulation would fight the rider's chosen feel mode for the shape of the load, and trainer feel is rider-owned: the resistance level is chosen with the shift paddles and persisted per trainer. Still on the wire for non-RYDR hosts. To influence resistance, usesetActivity()below (the shell eases in menus) — an ordinary game never sets a level. setErgMode(enabled)·setTargetPower(watts)— ERG, the one exception, and almost certainly not for your game. A game built around a prescribed target (a structured workout) may take the trainer's control point withsetErgMode(true)and then push watts withsetTargetPower(w)as the target moves;setTargetPoweralone does nothing, so the grab stays explicit. Gate it onhardware.current.ergSupportedand always draw the target too — a dumb trainer, the trainerless power slider and the keyboard have no ERG, and that rider must still be able to ride the workout by holding the number themselves. The shell releases the control point on exit/crash (nothing to clean up), but drop ERG on your own pause screen: the shell can't tell "paused" from "recovery interval". Every other game shapes effort by creating demand, not by prescribing watts.setRoute(path)·createRouter({ onRoute })·setPowerBar(visible)·requestExit()·requestHardwareModal().setPowerBar: default visible — leave it during gameplay and menus so a keyboard rider can always feed power. The one exception is an editor/authoring tool, which has nothing to pedal and callssetPowerBar(false)(see Build an in-game editor).
Routing — use
createRouter, notsetRouteby hand.setRoute(path)only mirrors your current route into the shell's top URL (shareable/refreshable). It does nothing for browser Back/Forward — those are driven by the history stack, and an iframe's ownhistory.pushStateentries join that stack, so a guest gets Back/Forward for free the moment it pushes one entry per navigation.session.createRouter({ onRoute })owns that wiring:pushStateongo(), apopstatelistener, dispatching the boot path from the real URL /initialPath, and re-mirroring viasetRouteon every dispatch (including Back/Forward). You keep your own route table — parse the path inonRouteand mount your screen:const router = session.createRouter({ onRoute: (path) => mountScreenForPath(path), // your table: "" → home, "play/:id" → play, … }); router.start(); // dispatch the boot route (deep link / refresh) router.go("play/abc/normal"); // in-app nav — pushes history; Back returns here router.go("results/abc/normal", { replace: true }); // transient state — Back skips itDo not switch screens purely in memory (Back has no entry to pop → it exits the game) or
location.reload()per navigation (re-runs the handshake/asset load, not shareable). Make deep-linkable states reconstructable from their path; for a transient state whose context is in memory (a post-run results screen),go(..., { replace: true })and, on a URL hit you can't reconstruct,go()to a sane sibling (that item's leaderboard).
Deep links — your host must serve them. The shell mounts the game at
game.url/<tail>, so a route you project viasetRoute/createRouter(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):
startRun/saveRun/getRun·getLeaderboard·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.
Controller input — the one right way
Consume input only through the session. Never attach your own input listeners. The SDK is the single source of controller input; a game reads it three ways and nothing else:
// Discrete actions (menus, fire, jump) — react to the press/release edge:
session.onButton(({ name, edge }) => { if (edge === "down") confirm(name); });
// Continuous actions (steer, hold-to-brake, charge) — poll held-state in your game loop:
const dx = (session.isDown("RIGHT") ? 1 : 0) - (session.isDown("LEFT") ? 1 : 0);
const anyHeld = session.buttonsDown().length > 0;
// Analog (hall-effect joysticks) — poll the value in your game loop:
const move = session.stick("LSTICK"); // { x, y, magnitude, angle }, radially deadzoned
player.x += move.x * speed; // smooth on hall sticks, clean 8-way on a d-pad
const aim = session.stick("RSTICK"); // the SECOND stick: aim / camera (RX/RY)
// The right stick is also readable digitally, and clicking a stick is a plain button:
const aimX = (session.isDown("RRIGHT") ? 1 : 0) - (session.isDown("RLEFT") ? 1 : 0);
session.onButton(({ name, edge }) => { if (name === "RSTICK_PRESS" && edge === "down") recentreCamera(); });- Do NOT add
window.addEventListener("keydown"/"keyup"), reade.key/e.code/keyCode, pollnavigator.getGamepads(), open your own phone/WebSocket controller bridge, or write your own key→action map. Every one of those is a bug: it bypasses the canonical vocabulary, the house confirm/back convention, leaderboard power-source partitioning, and — critically — it breaks the moment the game iframe has focus or loses it, because raw DOM key events are delivered per-frame and per-focus.onButton/isDown/buttonsDownare immune to all of that: the SDK normalises keyboard, phone, and Zwift Play into one stream and delivers it whether the shell or the game canvas holds focus. - Canonical names:
UP/DOWN/LEFT/RIGHT(D-pad and left stick) + right-stick directionsRUP/RDOWN/RLEFT/RRIGHT+ face diamondDIAMOND_UP/DIAMOND_DOWN/DIAMOND_LEFT/DIAMOND_RIGHT+ shoulder triggersLT/RT+ joystick clicksLSTICK_PRESS/RSTICK_PRESS+OPTIONS(the game's own menu/options button — distinct from the platform's own overlay menu, which never reaches a game). House convention:DIAMOND_DOWN= confirm/primary,DIAMOND_RIGHT= back/cancel (as on Xbox/PlayStation/Nintendo). Never print a letter — resolve it per pad withsession.buttonLabel(name); every other name is contextual (no built-in meaning). Show the actual glyph in prompts; never hard-code a device. (Phones expose at least theDIAMOND_DOWN/DIAMOND_RIGHTpair, and most controllers have noLT/RT, no second stick, no stick click, and noOPTIONS— don't require them for a flow that must work everywhere.) - Edges are real holds: a held button emits exactly one
downand oneup. For "is it held right now?" useisDown/buttonsDownrather than tracking edges yourself. - A modal screen can take the controller:
const release = session.grabInput(handler)routes every edge tohandleralone — your otheronButtonlisteners go quiet,isDown/buttonsDownread empty,axis/stickread centred, and anything held is released to the game as an ordinaryupfirst (so a held brake can't stick). Grabs stack;release()restores. This is the guard rail behindmountOptionMenu, and it's not a pause: the SDK cannot stop your loop — freeze that yourself. - Analog is opt-in and additive: when a controller has hall-effect joysticks, read the
continuous position with
session.axis(name)— the stick axesLX/LY/RX/RYgive-1..1(right/up = +1), and they are the only axes: the shoulder triggersLT/RTare plain clicks (no analog travel), read viaisDown/onButtononly. For a joystick prefersession.stick("LSTICK" | "RSTICK"), which applies the correct radial deadzone and returns{ x, y, magnitude, angle }. Pick by need:stick()for 2D movement/aim,isDown/onButtonfor ON/OFF. The digital and analog streams run in parallel —isDown("RLEFT")andstick("RSTICK")both work, so a game uses either or both. Andaxis()/stick()are always readable: on a plain, non-hall controller the value is quantized to the endpoints (a d-pad snaps-1/0/+1), so you never branch on "does this controller have hall?" — it rests at0until a sample arrives. The keyboard emulates axes too (for local dev): the arrow keys drive the left stick and the numpad 8/4/5/6 keys drive the right stick, so bothstick("LSTICK")/stick("RSTICK")work without a controller. The right stick is also readable digitally (RUP/RDOWN/RLEFT/RRIGHT) and each stick's click is a plain button (numpad1/3on the keyboard). - The only legitimate exception is a non-gameplay authoring tool — an admin-gated world / level editor or a dev-only showcase page that runs outside the rider flow. There, raw keyboard (Ctrl+Z, arrows to nudge, Delete) is the correct desktop UX. Gameplay never uses it.
Why this works across focus: the platform shell captures keyboard at its own window and the SDK captures it inside the game iframe (cross-origin iframes receive their own key events once focused). Both normalise through the same map and feed one idempotent pipeline, so your game sees an identical
onButton/isDownstream no matter where focus is — you write zero code for it.
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.)
Runs (attempts) + leaderboards
A run is one play attempt — the single unit that owns a named effort window (on the shell's
ride recording), a breakdown, and its leaderboard score(s), all under one runId. startRun
opens it; saveRun completes it.
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[]. A score for an unknown boardId is rejected — the board must be declared in the manifest first.
// session.boards = [{ id: "waves", valueType: "count", sort: "desc", aggregate: "best" }, …]
const { runId, startedAt } = await session.startRun(`Wave ${n}`); // opens a named effort window
// …play…
const [{ rank, isPersonalBest, total }] = await session.saveRun({
breakdown: { outcome: "win", waves: 12 }, // opaque, read back via getRun
scores: [{ boardId: "waves", value: wavesCleared }], // a run can hit several boards
});
const page = await session.getLeaderboard("waves", { limit: 10 }); // { entries, you? }
const detail = await session.getRun(someEntry.runId); // read a breakdown back (e.g. leaderboard detail)startRun(name?)→{ runId, startedAt }— begins a fresh attempt: rotatessession.runIdand opens a named effort window the shell records (duration / avg+peak power / avg HR). Calling it again discards a still-open unsaved window (a restart).saveRun({ breakdown?, scores? })→SubmitScoreResult[]— completes the run: stores the breakdown, closes the window, and submits each score, returning{ boardId, rank, isPersonalBest, total }per board (for a results screen). A run is recorded only if yousaveRun— unsaved attempts are dropped.getLeaderboard(boardId, { key?, limit? })→{ entries: BoardEntry[], you? }— top-N plus the requester's own row.getRun(runId)→Promise<unknown | null>reads a stored breakdown back (BoardEntry.runIdis the key).nullif absent.- A score's
keyselects a parameterized board family member (e.g. per-track). Power-source partitioning is automatic — the shell splits every board by source (keyboard vs trainer) and appends it to yourkeyon both submit and read, so don't encode input source yourself. 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".
The rider's difficulty badge — @rydr/game-sdk/difficulty
BoardEntry.ftpDifficulty is the watts the rider had their difficulty dial set to. To draw it as
a badge, don't derive a scale of your own — the ladder is published so the badge on your board and
the one in the chrome around it are the same badge:
import { levelForWatts, visualForLevel } from "@rydr/game-sdk/difficulty";
const level = levelForWatts(entry.ftpDifficulty); // watts → 1 … 50 ("22" ≈ 220 W)
const v = visualForLevel(level); // { fill, accent, glow, ink } — CSS strings
pill.style.background = v.fill; // translucent glass; wants v.accent as a keyline
pill.style.color = v.ink; // the level's hue, pre-solved to 4.2:1 on that fillAlso there: wattsForLevel / snapWatts / stepWatts (stepping the dial has no watts constant — a
press moves 5 W in the low band, 10 W above it), levelAt / FTP_LEVELS for a picker, wattsLabel /
rangeLabelForTier for labels, and colourForLevel → one flat hex for a gradient stop or canvas
fill. Pure module: no lit, no three, no DOM.
Two things the number is not: it isn't a measurement of the rider's fitness (it's a setting they
chose), and it isn't the rank of a score — BoardEntry.rank is the placing. Don't label it with a
tier name; the level is a dial, not a club.
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.
Required route —
/replay/:runId. A replay is only watchable inside the game, so if you callsaveReplayyou must serve this route — it is part of the contract, not optional. The platform deep-links a finished run tohttps://rydr-platform.vercel.app/game/{slug}/replay/{runId}(run-finished Telegram notifications, "watch" buttons, and shared links all point here); the shell projects it to your game asreplay/{runId}— the iframe's real URL andsession.initialPath. The route shape is identical for every game — implement it once, the same way:
- Parse it at boot, in your normal route table.
replay/<runId>is just another deep link alongside yourplay/…/level/…routes — match it againstsession.initialPathon load, same as the rest.- The URL carries only the
runId— fetch everything else. Do not gate replay entry on a level/track/difficulty/song param: you won't have one.getReplay(runId)returns the frames, and whatever the run was (which level, the chart, the player) must come from inside that replay (put it in the first frame'scustomData, or store it under the run viasaveRun/getRun). This is why the route is the same for every game.- Handle "no replay" gracefully.
getReplayreturnsnullwhen nothing was stored for that id (e.g. a pre-feature or non-PB run). Fall back to a menu — never hang on a blank screen.- Play it back read-only. It's a viewer, not a run: no hardware input, no
startRun, nosaveReplay/recording. Drive your scene purely fromr.body.frames.- Keep the URL stable. A replay is read-only, so hide the power bar (
setPowerBar(false)) and callsetRoute('replay/' + runId)so a refresh stays on the replay. Derive the exit/back target from the fetched replay (e.g. that level's leaderboard), not from the URL.- Serve the path from your origin. Like every projected route, a hard refresh of
…/replay/<id>hits your origin as/replay/<id>— add the SPA rewrite so it falls back toindex.htmlinstead of 404ing.// At boot, in the same router that handles your other deep links: const path = (session.initialPath ?? location.pathname).replace(/^\/+/, ""); const m = /^replay\/(.+)$/.exec(path); if (m) { const runId = m[1]; const r = await session.getReplay(runId); // null ⇒ nothing stored for this run if (!r) return goToMenu(); // graceful fallback, no blank screen session.setPowerBar(false); // read-only viewer — no live power bar session.setRoute('replay/' + runId); // keep the URL stable across refresh playReplayReadOnly(r.body.frames); // no input, no startRun, no recording return; // skip your normal "start a run" boot path }
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 an admin (session.identity.isAdmin === true) — the shell performs the write as the signed-in admin (Supabase RLS on their platform admin role); 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 a platform admin (the shell authorizes it with their signed-in session — the game never handles any credential).
The crystal-clear rule: your game never handles a credential. 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 performs the write as the signed-in admin (Supabase RLS on their admin role; your iframe never authenticates). For a non-admin player these reject; for an admin they succeed. - Hide the power bar — an editor is a full-screen authoring tool with nothing to pedal, so on boot call
session.setPowerBar(false)to drop the trainerless power slider. (The shell draws no other per-game chrome — its platform menu is summoned on demand — so there's nothing else to hide.) This is the one place a guest should hide the power bar; gameplay and game menus always leave it visible.
// in-game editor (embedded in the shell — the normal case)
if (session.identity.isAdmin) {
showEditorButton();
}
// …on editor boot, drop the power slider — nothing to pedal in an editor:
session.setPowerBar(false);
// …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 credential in your game, no per-game server. A user is an admin simply by being signed in to the RYDR platform with an admin-role account (there is no ?admin URL and no secret to enter) — 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 any credential. It doesn't have one and doesn't need it — the shell performs the authenticated write as the signed-in admin. If you're pasting a Bearer or a secret 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. - The user is an admin by being signed in to the platform with an admin-role account (no
?admin, no secret). 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 an admin — show "sign in to the platform as an admin", author nothing.
return;
}
// Drop the power slider — an editor has nothing to pedal.
session.setPowerBar(false);
// Admin: author through the session. The shell writes as the signed-in admin; this iframe never authenticates.
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. A game never authenticates admin writes.
shared-scope writes are authorized by Supabase RLS on the signed-in admin's account (app_metadata.role === 'admin'); the shell performs them on behalf of the logged-in admin. No credential is ever shipped to a game or guest or entered into editor code. Player-generated content uses thepublicowner-write scope (saveData(..., { scope: "public" })), not admin auth.
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.
@rydr/game-sdk/three helpers — read src/three/README.md first (MANDATORY)
Before hand-rolling world loading, mesh merging, a frame profiler, or projecting a 3D point to a screen-space UI position, read
src/three/README.md(shipped asdist/three/README.md). It lists every helper —loadWorld,mergeByMaterial,PerfOverlay,ScreenAnchor, theperf*marks — and how to use them. Reinventing one of these in a game is a bug.
Batch draw calls
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: presence + relay + opaque shared state + trusted peer telemetry. Your game never opens a socket — it drives the room through the shell over postMessage, and the shell (which owns the hardware and the rider's identity) is the sole socket writer. That is what makes presence and telemetry trustworthy: a game cannot forge another player's identity or wattage. The server is dumb about gameplay — it never parses your send/setState payloads, so the game defines what they mean. In standalone dev (no shell) it falls back to a local single-member loopback room, so room code runs without a backend.
Events, each on(...) returning an unsubscribe fn:
| Event | Argument | |
|---|---|---|
| message | (data, from) | a relayed peer message; from is server-stamped |
| presence | (members) | roster changed, de-duped by playerId |
| state | (state) | shared state changed (shallow last-write-wins) |
| telemetry | (reading) | a peer's REAL hardware — playerId, power, cadence, heartRate |
| event | (e) | a server-stamped orchestration event — act at e.at |
| open | — | connected, or RE-connected: the shell may rebuild the socket under you |
| close | (reason) | see below |
room.scheduleEvent(name, payload?, at?) is the genre-neutral referee whistle. The server stamps from and clamps at to a sane future window, then broadcasts to everyone including the scheduler, so all clients act on the same instant with no host head start — use it for countdowns, round transitions and grace windows rather than a host announcing "go" (which arrives at each peer at a different time). Your own watts are injected into the room by the shell automatically; you only ever READ opponents' telemetry.
Why a room closed — close carries a reason, and the distinction that matters is transient vs permanent:
| reason | Do |
|---|---|
| "dropped" | Nothing. The shell is already reconnecting with backoff and will re-open the same room — hold your state. Do NOT start your own rejoin loop, or two reconnect layers race through one socket. |
| "full" | The room is at capacity and the SDK has stopped retrying on purpose. Recoverable only when someone leaves, so re-joining is a deliberate decision. |
| "refused" | The shell will not host rooms for this game (its registry row says multiplayer: false, or the host has no room support). Permanent — never retry. Tell the rider multiplayer is unavailable and carry on single-player. |
| "left" | You called leave(). Nothing failed. |
| undefined | A shell older than protocol 32, which cannot say. Treat as unknown, never as transient. |
Note joinRoom returns its handle synchronously, before anything is connected: members is empty and state is {} until the server's hello arrives, so wait for open/presence/state rather than reading the handle on the next line. A join a shell never answers produces no error and no close either — if you need to know, time it out yourself.
Sequence stages (optional) — being part of a mash up
A sequence is an authored run of several games back to back: a short race, then a song, then a survival wave. The platform owns the order and the rider's progress; your game is handed one stage and told nothing else — not what came before, not what comes next, not how many there are. That is what lets a mash up be re-authored without touching a single game.
Two halves, and you need both. Declare the modes you offer, so a sequence can be authored
against them, and implement onStage, so you can play one when asked.
1. Declare your offerings
In your package.json, beside rydr.boards:
"rydr": {
"offerings": [
{ "id": "one-song", "label": "One song", "ends": "natural",
"description": "A single track at the chosen difficulty, about 4 minutes" },
{ "id": "endless", "label": "Endless survival", "ends": "open" }
]
}An offering is one directly launchable mode: this game, this preset, go. Not a screen the rider then has to choose something on — a sequence exists to stop a rider re-picking a mode at every stage.
| Field | |
| --- | --- |
| id | The offeringId a stage will name. Stable: renaming it breaks every authored sequence that used it, exactly as renaming a boardId orphans a leaderboard. |
| label? | Rider-facing name on the sequence's progress card (defaults to id). |
| ends | "natural" if gameplay reaches its own end and you report it without being asked (a finish line, the end of a song). "open" if it runs until something outside it intervenes (endless survival) — no terminal report is ever coming on its own, and the rider's Skip is the only way on. |
| description? | One line for whoever authors a sequence: what this is and roughly how long. Never shown to the rider. |
ends is the one field a sequence cannot work without: a mash up made entirely of "open" stages
would strand a rider on stage one forever, and the platform needs to know that while the sequence is
being authored rather than mid-ride.
Declaring reaches the platform when you register (see the template's SETUP.md), the same path
rydr.boards takes — the platform has to know what you offer before it opens your iframe, and your
code is behind an iframe it has not opened yet.
Declaring is a promise, not an implementation. The registry records what you said at registration; the deployed build is what actually answers. So the platform pre-checks a sequence against your declaration but always defers to the guest's own verdict at launch.
2. Implement onStage
session.onStage(async (stage) => {
if (stage.offeringId !== "one-song") {
return session.finishStage("unavailable", { reason: "This game has no such mode" });
}
const { songId, difficulty } = stage.settings ?? {}; // opaque to the platform; YOU validate
const result = await playSong(String(songId), String(difficulty));
session.finishStage(result.won ? "completed" : "failed", { runIds: [result.runId] });
});Registering the handler is also how you declare support at runtime. The SDK acks the platform on
your behalf as soon as your handler runs, and a game with no handler is auto-reported
"unavailable" — so an ordinary game needs no sequence code at all and can never strand a mash up
by failing to answer.
Three outcomes, and the difference is load-bearing:
| Outcome | Meaning |
| --- | --- |
| "completed" | Played to its own end. |
| "failed" | Played and lost: out of lives, eliminated, timed out. A real attempt, just not a win. |
| "unavailable" | Nothing was played — unknown offeringId, settings this build rejects, missing or unpurchased content. |
"completed" and "failed" both advance the sequence, because the rider played the thing either
way. "unavailable" does not: it produces an actionable error instead of marching the rider past a
stage they never saw. Never report "failed" for a launch problem, or "unavailable" for a lost
race.
Reach a finishStage on every path, including the ones where you give up. Nothing else ends a
stage: not saveRun, not a route change, not setActivity("menu"), because a game legitimately does
all three mid-stage, so none of them can honestly mean "advance". A handler that throws is reported
"unavailable" for you, but a sentence you wrote is a better explanation for the rider.
Settings are yours to validate. The platform round-trips whatever the sequence author wrote and never looks inside. You own your content, so only you know that a track was removed or a song is not unlocked for this rider.
You are also launched at your own deep link. The platform navigates you there as well as sending
the stage, so trust stage over the URL — the message carries what a URL cannot express (pacing
targets have no path segment, and never will). It is also why the URL alone never makes a game
sequence-compatible.
Only the rider leaves a sequence. requestExit() is refused while one is running — the shell's
platform menu owns Skip stage and Stop the sequence — so do not build your own way out, and never
call finishStage just to escape one. The ride recording spans the whole sequence and continues
afterwards; finishing a sequence is not finishing a ride, and neither is yours to end.
session.stage is the live StageSpec or null. Outside a sequence onStage never fires and
finishStage is a no-op, so no gameplay code needs to branch on whether one is running.
UI components (optional @rydr/game-sdk/ui) — read src/ui/README.md first (MANDATORY)
Before building any in-game prompt, dialogue, choice menu, keycap, or card, read
src/ui/README.md(shipped asdist/ui/README.md). It lists every component, the state methods, and the hard rule against restyling (sizes are tuned for a trainer screen read from a few feet away). Hand-building one of these — or copy-pasting it from another game — fragments the look and is a bug.
A self-contained DOM/CSS kit for the controller-button UI — showing "which button to press" in one consistent visual language across the library. Like the three helper it's a separate subpath export; unlike it, it needs no peer dep (no three.js). It injects its own scoped .rydr-ui-* CSS once into <head>, depends on no global styles or CSS variables, and renders pure DOM, so a game gets the look just by constructing a component.
import { createKeycap, mountActionDiamond, mountDialogueCard } from "@rydr/game-sdk/ui";
// a single A keycap that reflects real presses — pass the session (it satisfies `ButtonSource`)
host.appendChild(createKeycap("DIAMOND_DOWN", { press: session }).el);
// the full face-diamond move diamond, wired to input
const dia = mountActionDiamond(host, { press: session, cards: { A: { label: "Séisme" } } });
dia.cards.A.setActive(true);
dia.cards.Z.keycap.setCooldown(0.4, 3); // depleting ring + countdowncreateKeycap(button, opts)/createDpadKeycap(dir, opts)— a keycap of the face-diamond face buttons or the UP/DOWN/LEFT/RIGHT d-pad. A small state machine:solovsfulldiamond, pulse, pressed, cooldown ring + countdown, disabled, hidden. Mono by default (white face, dark letter — the RYDR look); the per-button role hues are opt-in withcolored: true.mountCard— the dark glass container with a content slot.mountActionCard— that card + a keycap straddling its bottom edge.mountActionDiamond— the full face-diamond move diamond.mountDialogueCard— an NPC dialogue box (name + typewriter line + continue keycap).mountChoiceCard— a player menu (prompt + selectable list, ▲▼ + confirm).mountLabeledDiamond— a pip cluster with a card-seated label radiating from each active button.createButtonKeycap(button, opts)— a keycap for the buttons that carry neither a face letter nor a direction, drawn the way the pad draws them:OPTIONSas a round pip with the pad's icon (☰on Xbox/DualSense,+on a Switch Pro — it's a small round key like the others),LT/RTand the stick clicks as a text pill ("LT"/"L2"/"LS"), since those really are printed with a word. Same state machine either way.mountOptionMenu(host, opts)— the shared in-game menu, opened by the game's ownOPTIONSbutton: the game's name big at the top in the game's own font, a freeResumerow plus the game's own rows, each able to advertise theshortcutthat does the same thing during play. It takes the controller over while up (session.grabInput) but leaves the trainer alone — the rider keeps the resistance they were riding — and it cannot pause your loop; freeze that yourself inonOpen/onClose. Don't hand-roll an options screen:src/ui/README.mdhas the full contract.- The controller family — for showing a rider their whole control scheme.
mountControllerPaddraws the controller in their hands and lights it as they press (stick caps follow the real axes).mountControllerMapsurrounds that pad with action callouts, each joined by a line to its button — the "Commandes" screen.mountComboDemoloops an animation of the combination one action requires. Describe your controls once, declaratively, and the SDK owns the rest:
import { mountControllerMap, type ControlAction } from "@rydr/game-sdk/ui";
const CONTROLS: ControlAction[] = [
{ label: "Se déplacer", combo: ["LSTICK"] }, // a whole stick = free movement
{ label: "Freiner", combo: ["LT"] },
{ label: "Tir chargé", combo: ["RT", "DIAMOND_DOWN"], hint: "Maintenir" },
{ label: "Dash", combo: ["LSTICK_PRESS", "LT"] }, // the line lands on the trigger
];
mountControllerMap(pauseOverlay, { press: session, actions: CONTROLS, title: "Commandes" });You never name a letter: you say DIAMOND_DOWN and the rider sees A, ✕ or B on the correct
physical button — on a Switch Pro the highlight stays on the bottom button while only the lettering
shifts — and it re-letters itself if they swap pads mid-screen. Six silhouettes ship, including a
Zwift Play / Ride shape whose absent controls (no joysticks, no triggers) simply aren't drawn.
Read src/ui/README.md → "Showing your controls" before building a legend by hand.
Wire any keycap to real input by passing the session — it structurally satisfies ButtonSource, and the keycap then shows its pressed sink while its button is held. The kit speaks the canonical ButtonName/ButtonEdge vocabulary.
Don't restyle the components — the sizing is deliberate. RYDR games run on a trainer screen viewed from a few feet away while pedaling, so the type sizes, paddings, and dimensions are tuned to stay legible at that distance. Fill the content slot (a card/action card takes any DOM; the diamond takes a label or custom node) and drive the state methods — but don't override the
.rydr-ui-*font sizes/paddings/widths or wrap a component intransform: scale(). If a label doesn't fit, shorten the text, don't shrink the type.
Live catalog. Every component, in every state, is rendered by mountUiShowcase(host, { session? }) from the separate @rydr/game-sdk/ui/showcase entry — the SDK owns what the catalog shows (so it versions with the components and never drifts), a host owns only where. It's a separate subpath so a game importing the components never pulls the catalog into its bundle. Two hosts, one function:
import { mountUiShowcase } from "@rydr/game-sdk/ui/showcase";
mountUiShowcase(document.body, { session }); // pass the session → live watts + real button reflection- Locally:
npm run showcase(in this repo) serves the standalone dev page inexamples/ui— no shell needed; keycaps are driven by the keyboard. - In the platform: the shell mounts the same function at a permanent admin route (e.g.
/game-ui), so the catalog is always live and reviewed in the real trainer-screen chrome.
Versioning
RYDR_PROTOCOL_VERSION is the wire version. The shell supports a range and adapts older messages. Breaking shape changes are forbidden; evolve additively.
