Skip to content
Developers

Make a game for RingStack

RingStack hosts your browser game and gives it player accounts, leaderboards, achievements, cloud saves, friends and chat through one script. You can sell cosmetics and keep 70% of every sale.

Quick start

<script src="https://ringstack.co/sdk/ringstack.js"></script>
<script>
  RingStack.init({ game: 'your-game-slug' }).then(async (rs) => {
    rs.mode;   // 'embedded' | 'same-origin' | 'token' | 'offline'
    rs.user;   // { id, username, displayName, avatarUrl, points, tag } or null
    rs.on('user', (user) => { /* signed in or out while playing */ });

    await rs.submitScore(4200);              // "score" leaderboard
    await rs.unlockAchievement('first_win'); // declared in ringstack.json
    await rs.data.set('progress', { level: 3 });
  });
</script>

Every method returns a promise. Calls that need a player reject with code: 'unauthorized' when nobody is signed in, so check rs.user and show a button that calls rs.signIn(). If RingStack can’t be reached, init still resolves with mode: 'offline' and your game keeps working.

Publish with the CLI

RingStack hosts your game for you. Each game gets its own Cloudflare Worker that serves your files as they are, at https://rs-game-<slug>.<account>.workers.dev. You don’t run a server, and the ringstack CLI does everything through the publishing API below.

npm i -g https://ringstack.co/cli/ringstack.tgz     # or: npx -y https://ringstack.co/cli/ringstack.tgz <command>
ringstack login             # approve in your browser; saves a publisher token
ringstack init my-game      # starter game with the SDK, AGENTS.md and a Claude Code skill
cd my-game
ringstack dev               # play it in a mock RingStack page (fake player, scores, saves)
ringstack publish           # build, upload, deploy; prints the deploy log and the URL
  • One-time fee. The first publish of each game asks for $5.00. While RingStack is in test mode the checkout is a sandbox, so no money moves. ringstack publish prints the checkout link and carries on once you’ve paid.
  • Review. The first live version goes to admin review before the game is listed. After that, new versions go live in the catalog straight away.
  • The manifest. ringstack.json holds the slug, title, description, aspect ratio, the folder to upload (dir), an optional build command, and your achievements, leaderboards and items. They sync on every publish. Schema: ringstack.schema.json.
  • Other commands. ringstack status, logs [--follow], versions, rollback <version>, whoami, logout. In CI, set RINGSTACK_TOKEN to a token from the publisher dashboard.
  • Limits. Static files only: up to 1,000 files, 25 MB per file and 100 MB in total, with index.html at the root. Hidden files and _headers, _redirects or _worker.js aren’t accepted.
{
  "slug": "star-catcher", "title": "Star Catcher", "version": "1.2.0",
  "dir": "public", "aspect": "16:9",
  "leaderboards": [{ "key": "score", "name": "High score" }],
  "achievements": [{ "key": "first_catch", "name": "First catch", "points": 5 }],
  "items": [
    { "sku": "basket_gold", "name": "Gold basket", "kind": "skin", "slot": "basket",
      "rarity": "rare", "price": 150, "preview": "previews/basket_gold.png" }
  ]
}

Already host your game somewhere? You can list it by URL from the dashboard instead (same fee and review).

Versions and changelogs

Every upload, the first one included, needs a version number and a changelog.

  • The version is semver (1.4.0, 2.0.0-beta.1) and has to be greater than every version you’ve uploaded for that game, failed ones included. Numbers are never reused.
  • The CLI reads it from --version, then ringstack.json, then package.json. --bump patch|minor|major bumps ringstack.json for you.
  • The changelog comes from -m "…", the ## [1.4.0] section of CHANGELOG.md, or a prompt. Write it for players: it’s shown on the game page.
  • Rolling back puts an earlier version live again and shows up in the history. It doesn’t need a new changelog.

Make money with cosmetics

Players buy cosmetics in your game with Rings, RingStack’s currency, and you get 70% of every sale, credited to your publisher earnings as it happens (the rest keeps RingStack running). Rings are bought with real money, about $5 for 500 (payments are simulated during the preview). Cosmetics must only change looks: no stat boosts, extra lives or shortcuts. Admins take pay-to-win items off sale.

Ideas that work in most games:

  • Skins and colour variants of the player’s main thing: ship, car, character, basket. A palette swap is the cheapest cosmetic you can make.
  • Trails and effects: engine exhaust, particle trails, hit sparks, a burst when you win or destroy something.
  • Outfits: hats, helmets, decals. Emotes: a short animation or sound. Titles next to the player’s name.
  • Bundles of a few items for less, and limited-time items. Featured items also show on the RingStack home page.

Each item has a slot your game defines (ship_paint, trail, hat). Equipping an item replaces whatever was in its slot, and the loadout is stored by RingStack, so it follows the player to every device. Players can buy and equip from your game, or from the shop tab on your game’s RingStack page.

const SKINS = { basket_gold: '#ffd166', basket_neon: '#39ff88' };   // sku -> how it looks
let basket = '#ffb02e';                                                  // the free default

async function applyLoadout() {
  const loadout = rs.user ? await rs.getLoadout() : {};                  // { basket: 'basket_gold' }
  basket = SKINS[loadout.basket] ?? '#ffb02e';
}
rs.on('user', applyLoadout);

// In your shop button:
const r = await rs.purchaseItem('basket_gold');   // RingStack asks the player to confirm
if (r.purchased) await rs.equip('basket_gold');
await applyLoadout();

Showing other players’ cosmetics

// Everyone in the room sends their RingStack id with their state.
const loadouts = await rs.getLoadouts(players.map((p) => p.userId));
// { u_123: { ship_paint: 'vs_paint_ember', engine_trail: 'vs_trail_ion' }, ... }
for (const p of players) p.paint = PAINTS[loadouts[p.userId]?.ship_paint] ?? DEFAULT_PAINT;

Verify cosmetics on your server

If your game has its own server, don’t trust what a modified client says it has equipped. Have the client send a signed token, and check it on the server. The token is a JWT signed with Ed25519 (alg: EdDSA), valid for 10 minutes, and only lists items the player owns in their server-stored loadout.

// Client: get a signed statement of what this player has equipped (valid 10 minutes).
const { token } = await rs.cosmetics.attest();
socket.send(JSON.stringify({ type: 'join', cosmetics: token }));
// Game server (Cloudflare Worker or Durable Object): verify without calling RingStack.
const JWKS = 'https://ringstack.co/api/v1/jwks.json';
const b64u = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));

export async function verifyCosmetics(token, game) {
  const [h, p, s] = token.split('.');
  const header = JSON.parse(new TextDecoder().decode(b64u(h)));
  const { keys } = await (await fetch(JWKS, { cf: { cacheTtl: 3600 } })).json();
  const jwk = keys.find((k) => k.kid === header.kid);
  if (header.alg !== 'EdDSA' || !jwk) throw new Error('unknown key');
  const key = await crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, false, ['verify']);
  const ok = await crypto.subtle.verify('Ed25519', key, b64u(s), new TextEncoder().encode(h + '.' + p));
  const claims = JSON.parse(new TextDecoder().decode(b64u(p)));
  if (!ok || claims.aud !== game || claims.iss !== 'https://ringstack.co' || claims.exp < Date.now() / 1000) throw new Error('bad token');
  return claims;   // { sub: player id, name, loadout: { slot: sku }, owned: [sku], iat, exp }
}

Friends, invites and joining

Players can add friends, see what they’re playing and invite them in. Make your multiplayer game joinable and RingStack does the rest: friends get a “Join” button, and accepting an invite opens your game with the joinData you set.

// Tell friends what you're doing; joinable + joinData puts a "Join" button on you.
await rs.presence.set({ status: 'In room ABCD · Capture & Hold', joinable: true, joinData: { room: 'ABCD' } });

// Joining: set when the player accepted an invite or pressed Join on a friend.
if (rs.launch.joinData) joinRoom(rs.launch.joinData.room);
rs.invites.on('accepted', (e) => { if (e.role === 'invitee') joinRoom(e.joinData.room); });

// Inviting from inside the game (your joinData is used if you leave it out).
const friends = await rs.friends.list();          // with presence: online, game, status, joinable
await rs.invites.send(friends[0].id, { message: 'Come help!' });
rs.invites.on('received', (inv) => showMyOwnPrompt(inv));   // RingStack shows one too
rs.friends.on('change', () => refreshFriendList());

Players under 18 can only befriend, see and invite other players under 18, and adults can’t find or contact them. Token-mode games need the social scope, which the SDK asks for by default.

Chat

Every game page has a chat sidebar with a Global tab and an In room tab. Put players in a room and they get a room channel next to the global one. You can also show the chat in your own UI.

await rs.chat.setRoom('ABCD', { label: 'Room ABCD' });   // the sidebar's "In room" tab
rs.chat.on('message', ({ channel, message }) => {
  // channel: 'global' | 'room'; message: { user: { displayName, style }, text, at }
  showInGameChat(message);
});
await rs.chat.leaveRoom();

// Match RingStack's look in your own chat: supporter tags and chat cosmetics per player.
const styles = await rs.players.styles([userId1, userId2]);
// { u_1: { tag: { tier: 'gold', name: 'Gold' }, cosmetics: { chat_name: 'cc_name_sunset' } } }

RingStack moderates chat for you: players under 18 only see each other and can only send emoji, adults’ swearing is masked, links are blocked, and there are rate limits, slow mode, reports, mutes and blocks.

AI agents: AGENTS.md and the skill

ringstack init writes an AGENTS.md with the rules for a RingStack game (SDK, touch and phones, the iframe, cosmetics, testing, publishing) and adds a Claude Code skill in .claude/skills/ringstack-game/. The skill takes a game idea all the way to a published game: scaffold, build, wire in the SDK and cosmetics, test in the mock page, write the version and changelog, publish. A person still has to approve ringstack login and pay the fee in the browser.

To use the skill outside a scaffolded game, unzip it into ~/.claude/skills/ (all your projects) or a project’s .claude/skills/.

How sign-in works

Where the game runsModeHow it talks to RingStack
Inside the RingStack game pageembeddedpostMessage to the page around it. RingStack checks the message came from your frame and your registered origin, then calls the API for the player. No tokens reach your game.
On ringstack.co itself (like the demo game)same-originDirect API calls with the session cookie plus a CSRF token. Sign-in opens a small popup.
On another domain (including RingStack-hosted games opened on their own)tokenOAuth 2.0 with PKCE. signIn() opens RingStack’s consent page, then the SDK keeps a short-lived token for your game only.

SDK reference

RingStack.init({ game, portal?, redirectUri?, scope? })Connects and resolves with the client.
rs.user, rs.mode, rs.on('user', fn)Current player (with tag, their supporter tag), connection mode, sign-in changes.
rs.signIn() / rs.signOut()Ask the player to sign in (call it from a click). signOut only forgets your game’s token.
rs.submitScore(value, board?), rs.submitStats({…})Up to 10 stats at once. New keys become “highest wins” leaderboards.
rs.getLeaderboard(board, { limit })Top entries plus the player’s own rank.
rs.unlockAchievement(key), rs.getAchievements()Keys must be declared first. RingStack shows the unlock toast when embedded.
rs.data.set / get / list / removePrivate saves per player and game: any JSON, 1 MB per key, 16 MB and 1,000 keys per player. Pass ifVersion to avoid overwriting a newer save from another device.
rs.getItems(), rs.getInventory()Your shop (with kind, rarity, slot, preview) and what the player owns (plus their loadout).
rs.purchaseItem(sku)RingStack shows the price and asks the player. Resolves with { purchased } or { cancelled: true }.
rs.equip(sku), rs.unequip(slot), rs.getLoadout(), rs.getLoadouts(ids)What’s equipped, for the player and for others.
rs.cosmetics.attest()Signed token of the player’s equipped cosmetics, for your server.
rs.friends.list(), rs.friends.on('change')Friends with presence.
rs.presence.set({ status, joinable, joinData }), rs.presence.clear()What friends see you doing in this game.
rs.invites.send(id, { message, joinData }), rs.invites.on('received' | 'accepted')Invites; accepted ones bring the joinData.
rs.launch.joinDataSet when the game opened from an invite or a friend’s Join button.
rs.chat.setRoom(id, { label }), rs.chat.leaveRoom(), rs.chat.on('message')The In room chat tab, and chat messages for your own UI.
rs.players.styles(ids)Supporter tags and chat cosmetics of other players.
rs.consumeItem(sku, n), rs.getBalance(), rs.showAd({ type })Consumables, the wallet balance (embedded and same-origin only), ad breaks (reward only when rewarded is true).

Embedding and phones

Embedded games load in a sandboxed iframe (allow-scripts allow-same-origin allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals). On phones, pressing Play makes the game cover the whole screen, with a bar for chat and exit. Fill whatever box you get, support touch and keyboard, and pause when the page is hidden. RingStack-hosted games already send the right header. If you host the game yourself, let RingStack frame it:

# Remove X-Frame-Options, or allow just RingStack:
Content-Security-Policy: frame-ancestors 'self' https://ringstack.co

If your site can’t be framed, list it as “Opens in a new tab”.

Games on other domains

  1. Add your page’s exact URL to OAuth redirect URIs in the dashboard (RingStack-hosted games get theirs automatically).
  2. Load the SDK on that page. When the player comes back with ?code=…, init() finishes signing in and tidies the address bar.
  3. Call rs.signIn() from a click. Tokens are stored for your site, refresh themselves, and only work for your game. They can never spend the player’s Rings.

Endpoints, if you’d rather do it yourself: https://ringstack.co/oauth/authorize, /oauth/token, /oauth/userinfo, /oauth/revoke, and metadata at /oauth/metadata. PKCE (S256) is required, and refresh tokens rotate.

Hosting and isolation

  • Your game runs on its own address (rs-game-<slug>.<account>.workers.dev), never on ringstack.co, so game code can’t reach anyone’s RingStack session. It talks to RingStack through the SDK; its address and sign-in redirect are registered when it deploys.
  • The Worker only serves your static files: no server code and no access to RingStack’s data. RingStack sets the headers, including frame-ancestors, so only RingStack can frame your game.
  • Every file is checked against its SHA-256 and kept, so any earlier version can be rolled back.

Security and age rules

  • RingStack only accepts bridge messages from your game’s frame and registered origin, and only answers that origin.
  • Spending always needs a confirmation that RingStack shows, so game code can’t buy anything on its own.
  • Scores come from the game, so they’re only as trustworthy as your game. Check them on your server if it matters.
  • Players under 18 are kept in their own audience for chat, friends and invites, and can only send emoji in chat. RingStack enforces this on its servers, so your game doesn’t need to.
  • Changing a listed game’s play address, embedding or sign-in redirect sends it back to review.

Publishing API

The CLI only calls these endpoints, so anything it does works from curl or CI too. Send Authorization: Bearer rsp_… with a publisher token. Tokens are stored hashed, can be revoked in the dashboard, and only work on /api/v1/publish/*: they can’t spend, read wallets or touch player data. Errors look like { "error": "code", "message": "…" }.

GET    /api/v1/publish/whoami
POST   /api/v1/publish/cli-login                     { clientName }  (no auth; then poll)
POST   /api/v1/publish/cli-login/poll                { deviceCode } -> { status } | { token }
DELETE /api/v1/publish/token                         revoke the token you send
GET    /api/v1/publish/games
POST   /api/v1/publish/games                         manifest: slug, title, tagline, description, tags,
                                                     aspect, achievements[], leaderboards[], items[]
GET    /api/v1/publish/games/{slug}                  status, fee, live version, URLs, recent deploys
POST   /api/v1/publish/games/{slug}/checkout         -> { paid } | { url } (one-time fee)
POST   /api/v1/publish/games/{slug}/thumbnail        raw PNG/JPEG/WebP/GIF
POST   /api/v1/publish/games/{slug}/uploads          { version, changelog, spa?, files: [{ path, size, sha256 }] }
                                                     -> { uploadId, missing: [sha256] }
PUT    /api/v1/publish/uploads/{uploadId}/files/{sha256}   raw bytes
POST   /api/v1/publish/uploads/{uploadId}/deploy     -> 202 { deployId, state: "queued" }
GET    /api/v1/publish/deploys/{deployId}?after={logId}    state, version, changelog, logs
GET    /api/v1/publish/games/{slug}/deploys
GET    /api/v1/publish/games/{slug}/versions
POST   /api/v1/publish/games/{slug}/rollback         { version }
GET    /api/games/{slug}/versions                    public version history (no auth)

Deploys go queued, building, then live or failed. Upload errors include version_required, invalid_version, version_not_greater (with latest), changelog_required, payment_required (402), reserved_path, no_index, file_too_large and build_too_large. Items in the manifest take sku, name, description, price, kind, slot, rarity, preview (a path in your build), bundle, available and featured (from/until), and consumable.

A full publish with curl (needs jq and sha256sum):

P=https://ringstack.co/api/v1/publish
H="Authorization: Bearer $RINGSTACK_TOKEN"
SLUG=my-game

# 1. Register the game (or update it) with its achievements and leaderboards
curl -sf -H "$H" -H 'Content-Type: application/json' "$P/games" -d '{
  "slug": "'$SLUG'", "title": "My Game", "aspect": "16:9",
  "leaderboards": [{ "key": "score", "name": "High score" }],
  "achievements": [{ "key": "first_win", "name": "First win", "points": 10 }]
}'

# 2. First time only: pay the fee (open the returned url in a signed-in browser)
curl -sf -H "$H" -X POST "$P/games/$SLUG/checkout"

# 3. Describe the build: path, size and sha256 of every file in public/
FILES=$(cd public && find . -type f ! -name '.*' | sed 's|^\./||' | while read -r f; do
  printf '{"path":"%s","size":%s,"sha256":"%s"},' "$f" "$(wc -c < "$f" | tr -d ' ')" "$(sha256sum "$f" | cut -d' ' -f1)"
done | sed 's/,$//')
UP=$(curl -sf -H "$H" -H 'Content-Type: application/json' "$P/games/$SLUG/uploads" \
  -d '{"version":"1.0.0","changelog":"First release.","files":['"$FILES"']}')
UPLOAD_ID=$(echo "$UP" | jq -r .uploadId)

# 4. Upload the files RingStack doesn't have yet
for sha in $(echo "$UP" | jq -r '.missing[]'); do
  f=$(cd public && find . -type f ! -name '.*' -exec sha256sum {} + | awk -v s="$sha" '$1==s{print $2; exit}')
  curl -sf -H "$H" -X PUT --data-binary "@public/${f#./}" "$P/uploads/$UPLOAD_ID/files/$sha" > /dev/null
done

# 5. Deploy and wait for it
DEP=$(curl -sf -H "$H" -X POST "$P/uploads/$UPLOAD_ID/deploy" | jq -r .deployId)
until curl -sf -H "$H" "$P/deploys/$DEP" | jq -e '.state == "live" or .state == "failed"' > /dev/null; do sleep 3; done
curl -sf -H "$H" "$P/deploys/$DEP" | jq '{state, version, url, logs: [.logs[].msg]}'

The same reference as Markdown: publish-api.md.

SDK HTTP API

What the SDK calls, under https://ringstack.co. With a token, send Authorization: Bearer …; CORS is open to approved game origins, without cookies.

GET  /api/v1/session
POST /api/v1/games/{slug}/stats                    { "stats": { "score": 4200 } }
POST /api/v1/games/{slug}/achievements/{key}/unlock
GET  /api/v1/games/{slug}/achievements
GET  /api/v1/games/{slug}/leaderboards/{key}?limit=10
GET  /api/v1/games/{slug}/items                     cosmetics: kind, rarity, slot, preview
GET  /api/v1/games/{slug}/inventory                 { items, loadout }
GET  /api/v1/games/{slug}/loadout                   PUT /loadout/{slot} { sku } · DELETE /loadout/{slot}
GET  /api/v1/games/{slug}/loadouts?users=u_1,u_2    other players' equipped items (no sign-in)
POST /api/v1/games/{slug}/cosmetics/attest          signed token for your game server
GET  /api/v1/jwks.json                              public keys for that token
GET  /api/v1/games/{slug}/players?ids=u_1,u_2       supporter tags + chat cosmetics
POST /api/v1/games/{slug}/inventory/{sku}/consume   { "quantity": 1 }
GET    /api/v1/games/{slug}/data?prefix=blueprint/
GET    /api/v1/games/{slug}/data/{key}
PUT    /api/v1/games/{slug}/data/{key}            { "value": …, "meta": …, "ifVersion": 3 }
DELETE /api/v1/games/{slug}/data/{key}?ifVersion=3
GET  /api/v1/social/friends                         (scope: social)
POST /api/v1/games/{slug}/invites                   { to, message?, joinData? }
WS   /api/v1/social/ws?game={slug}&access_token=…   presence (send { type: 'activity', … })
WS   /api/v1/chat/{slug}/ws?room={id}&access_token=…