npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@rydr/game-sdk

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() → a PlatformSession exposing a reactive hardware store, scoped identity, and trainer-control commands.
  • host/createPlatformHost(): the platform side of the protocol (used by the shell to embed a game).

Install

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

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

Resolved from the npm registry (not a git URL). To upgrade, bump the range and run npm installnever 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.mdReleasing & 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.ts already ships the block below; create-rydr-game scaffolds 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 dev

Two things make local SDK edits not show up, and this pattern kills both at once:

  1. Resolution — a bare import resolves the published @rydr/game-sdk from node_modules, not your working copy. The alias points it at the sibling source.
  2. Bundler dep cache — Vite pre-bundles node_modules deps into node_modules/.vite/deps and serves them from memory until a full dev-server restart, so a rebuilt SDK keeps serving stale (the classic TypeError: session.newThing is not a function for an API you know you added — a page reload or rm -rf .vite on 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.mdReleasing & 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 + identity

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

Usage (game side)

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

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

session.hardware.subscribe((hw) => render(hw.power, hw.heartRate));
session.onButton(({ name, edge }) => handleInput(name, edge));
session.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). ftp is live: the rider steps their playing FTP with − / + at any time (the shell owns it; the SDK forwards the keys), so subscribe with onIdentityChange rather than snapshotting it at init.
  • grantedCapabilities: readonly Capability[] · initialPath: string | undefined.
  • hardware: HardwareStorecurrent: HardwareSnapshot, and subscribe(cb) => () => void (fires immediately, then on every change).
  • ready() · reportLoadProgress(0..100) · reportError(message).
  • ~~setSimulation(gradePercent) · setTargetPower(watts) · setErgMode(enabled)~~ — 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, use setActivity() 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 calls setPowerBar(false) (see Build an in-game editor).

Routing — use createRouter, not setRoute by 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 own history.pushState entries 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: pushState on go(), a popstate listener, dispatching the boot path from the real URL / initialPath, and re-mirroring via setRoute on every dispatch (including Back/Forward). You keep your own route table — parse the path in onRoute and 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 it

Do 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 via setRoute/createRouter (and the initialPath you read on load) is the iframe's real URL. A direct hit or refresh of e.g. /game/<you>/play/abc reaches your origin as /play/abc — with no rewrite it 404s before index.html loads. Add a SPA rewrite so client routes fall back to index.html (on Vercel: "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]). Vercel applies rewrites only after a filesystem miss, so real documents (index.html, editor.html) and /assets/* are still served directly; the game then routes from session.initialPath.

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

  • boards: readonly BoardDefinition[] · runId: string · dataHost: string — the game's leaderboard catalog (from its manifest), the run this session is recorded under, and the realtime backend host.
  • Backend services (detailed below): 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 over power, advanced on read so it ramps smoothly between updates and is frame-rate independent. Use for anything you drive continuously off power (a cursor, position, speed, fill bar) where raw jitter would look bad.

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

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

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

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"), read e.key/e.code/keyCode, poll navigator.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/buttonsDown are 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 directions RUP/RDOWN/RLEFT/RRIGHT + face diamond DIAMOND_UP/DIAMOND_DOWN/DIAMOND_LEFT/DIAMOND_RIGHT + shoulder triggers LT/RT + joystick clicks LSTICK_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 with session.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 the DIAMOND_DOWN/DIAMOND_RIGHT pair, and most controllers have no LT/RT, no second stick, no stick click, and no OPTIONS — don't require them for a flow that must work everywhere.)
  • Edges are real holds: a held button emits exactly one down and one up. For "is it held right now?" use isDown/buttonsDown rather 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 axes LX/LY/RX/RY give -1..1 (right/up = +1), and they are the only axes: the shoulder triggers LT/RT are plain clicks (no analog travel), read via isDown/onButton only. For a joystick prefer session.stick("LSTICK" | "RSTICK"), which applies the correct radial deadzone and returns { x, y, magnitude, angle }. Pick by need: stick() for 2D movement/aim, isDown/onButton for ON/OFF. The digital and analog streams run in parallelisDown("RLEFT") and stick("RSTICK") both work, so a game uses either or both. And axis()/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 at 0 until 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 both stick("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 (numpad 1/3 on 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/isDown stream 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: rotates session.runId and 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 you saveRun — 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.runId is the key). null if absent.
  • A score's key selects 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 your key on 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 methodimport { 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> — encode ReplayFrame[] and persist the blob + derived meta keyed by runId. Large blobs are chunked server-side; for truly large binaries prefer asset upload (R2) and store the URL.
  • getReplays(boardId, { key?, top? })Promise<ReplayRef[]> — the top entries' ghosts. Each ReplayRef = { runId, rank, displayName, value, blob: string | null, meta: ReplayMeta | null } (blob/meta are null for an entry with no stored replay). Use meta for display; this does not decode frames. top defaults to 10; key selects a parameterized board member.
  • getReplay(runId)Promise<{ body: ReplayBody, meta: ReplayMeta | null } | null> — fetch and decode one replay (a board entry's runId, the session's own, or a shared-link id). null if none stored.
  • encodeReplay(frames, version?) / decodeReplay(blob) — the codec, exported standalone for tooling or when you hold a raw blob.

Required route — /replay/:runId. A replay is only watchable inside the game, so if you call saveReplay you must serve this route — it is part of the contract, not optional. The platform deep-links a finished run to https://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 as replay/{runId} — the iframe's real URL and session.initialPath. The route shape is identical for every game — implement it once, the same way:

  1. Parse it at boot, in your normal route table. replay/<runId> is just another deep link alongside your play/… / level/… routes — match it against session.initialPath on load, same as the rest.
  2. 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's customData, or store it under the run via saveRun/getRun). This is why the route is the same for every game.
  3. Handle "no replay" gracefully. getReplay returns null when 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.
  4. Play it back read-only. It's a viewer, not a run: no hardware input, no startRun, no saveReplay/recording. Drive your scene purely from r.body.frames.
  5. Keep the URL stable. A replay is read-only, so hide the power bar (setPowerBar(false)) and call setRoute('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.
  6. 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 to index.html instead 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 content

Player content uses public, not shared. saveContent/deleteContent/getUploadUrl write to shared and 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 through saveData(collection, id, value, { scope: "public" }). Reserve saveContent/getUploadUrl for in-game author/admin tooling (level/chart/song/world editors).

Asset upload

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

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

Build an in-game editor

Any game can ship an editor for its own shared content (levels, tracks, charts, worlds, …). The game reads it back through the session (listContent/getContent) — one shared backend, no per-game server. Authoring is admin-gated: a write succeeds only when the user is 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:

  1. Gate your editor UI on session.identity.isAdmin — show the "Edit" button / editor route only when it's true.
  2. Call the normal session methodssaveContent / deleteContent / getUploadUrl. The shell 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.
  3. 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:

  1. Your editor lives at a path on your game's origin — a route or a static page (e.g. run-editor.html).
  2. The shell opens it as a guest via a deep link: /game/<your-game>/run-editor mounts https://<your-game-origin>/run-editor in the guest iframe; your own host/router resolves the path.
  3. That page calls connectToPlatform() exactly like the game does and receives a session with identity.isAdmin stamped by the shell.
  4. 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 reads isAdmin.
// run-editor.html — a guest page, opened in the shell at /game/<your-game>/run-editor
const session = await connectToPlatform({ gameId: "my-game", capabilities: ["identity"] });
if (!session.identity.isAdmin) {
  // Not 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. saveContent takes no draft argument — a doc's draft/published state is just a field in the content you store ({ draft: true, … }). Your editor writes it; your game and lobby read it and hide drafts from non-admins (admins keep seeing them via the same isAdmin).

Security boundary. 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 the public owner-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 as dist/three/README.md). It lists every helper — loadWorld, mergeByMaterial, PerfOverlay, ScreenAnchor, the perf* 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 prop

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

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

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

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

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

Realtime rooms

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

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

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

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 as dist/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 + countdown
  • createKeycap(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: solo vs full diamond, 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 in transform: 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 in examples/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.