@rydr/game-sdk
v6.1.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)·setTargetPower(watts)·setErgMode(enabled)~~ — inert on the RYDR shell. Trainer feel is rider-owned: the resistance level is chosen with the shift paddles and persisted per trainer, so the shell deliberately no-ops these. They remain on the wire for non-RYDR hosts. To influence resistance, usesetActivity()below (the shell eases in menus) — the game never sets a level. 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. - 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".
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 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.
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, colored vs mono, disabled, hidden.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.
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.
