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 publishprints 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.jsonholds the slug, title, description, aspect ratio, the folder to upload (dir), an optionalbuildcommand, 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, setRINGSTACK_TOKENto 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.htmlat the root. Hidden files and_headers,_redirectsor_worker.jsaren’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, thenringstack.json, thenpackage.json.--bump patch|minor|majorbumpsringstack.jsonfor you. - The changelog comes from
-m "…", the## [1.4.0]section ofCHANGELOG.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 runs | Mode | How it talks to RingStack |
|---|---|---|
| Inside the RingStack game page | embedded | postMessage 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-origin | Direct 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) | token | OAuth 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 / remove | Private 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.joinData | Set 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.coIf your site can’t be framed, list it as “Opens in a new tab”.
Games on other domains
- Add your page’s exact URL to OAuth redirect URIs in the dashboard (RingStack-hosted games get theirs automatically).
- Load the SDK on that page. When the player comes back with
?code=…,init()finishes signing in and tidies the address bar. - 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=…