# AGENTS.md: Your game (a RingStack game)

This is a browser game published on **RingStack** (https://ringstack.co/), a game portal where
players have one account for scores, achievements and saves across every game. These are the
rules for working on it. Follow them exactly; `ringstack publish` and the RingStack review
check most of them.

## Project layout

| Path | What it is |
| --- | --- |
| `ringstack.json` | The game manifest: `slug`, `title`, **`version`**, description, aspect ratio, which folder to upload (`dir`), an optional `build` command, and the **achievements / leaderboards / items** the game uses. Synced to RingStack on every publish. |
| `public/` | The game itself (`dir`). Uploaded as-is: static files only (HTML, JS, CSS, images, audio, WASM). `index.html` must be at its root. |
| `CHANGELOG.md` | One `## [x.y.z] - YYYY-MM-DD` section per published version, newest first. Players read it. |
| `.claude/skills/ringstack-game/` | Claude Code skill: the end-to-end "idea to published game" workflow. |

If you add a bundler (Vite, esbuild…), set `"build": "npm run build"` and `"dir": "dist"`
and make sure the built `index.html` still loads the SDK from the portal (below).

## Hard rules

1. **Static only.** RingStack hosts the game as static files on its own Cloudflare Worker
   (`https://rs-game-your-game.<account>.workers.dev`). There is no server code, no API keys and
   no environment variables. Anything secret must not be in the game. Files named `_headers`,
   `_redirects`, `_worker.js` or starting with `.` are rejected.
2. **Load the SDK from the portal**, never a copy:
   `<script src="https://ringstack.co/sdk/ringstack.js"></script>`
3. **`RingStack.init({ game: 'your-game' })`**: the `game` value must equal `slug` in `ringstack.json`.
4. **Never block on RingStack.** `init()` always resolves; if `rs.mode === 'offline'` (or the
   script failed to load: `window.RingStack` is undefined) the game must still be playable.
5. **Every publish has a new version and a changelog.**
   - Bump `version` in `ringstack.json` (semver: patch for fixes, minor for features, major
     for big changes). It must be greater than every version published before; numbers are
     never reused, even after a failed deploy.
   - Add a `## [x.y.z] - YYYY-MM-DD` section at the top of `CHANGELOG.md` describing, for
     players, what changed, and be specific ("Bombs now flash before they land" tells players
     more than "Improvements").
   - `ringstack publish` refuses to run without both. The first publish counts too.
6. **Achievements must be declared** in `ringstack.json` before the game unlocks them
   (`unlockAchievement` fails with `not_found` otherwise). Leaderboard stats should be declared
   too, so they get a proper name and order. Keys: `a-z`, `0-9`, `_`, max 32 chars.

## The SDK (window.RingStack)

```js
const rs = await RingStack.init({ game: 'your-game' });
rs.mode;                       // 'embedded' (inside RingStack) | 'token' (standalone) | 'offline'
rs.user;                       // { id, username, displayName, avatarUrl, points } or null
rs.on('user', (u) => {});      // signed in / out while playing: reload saves, refresh UI
await rs.signIn();             // call from a click/tap handler only

await rs.submitScore(1234);                 // "score" leaderboard
await rs.submitStats({ score: 1234, kills: 7 });   // up to 10 stats at once
const board = await rs.getLeaderboard('score', { limit: 10 });  // { board, me, entries[] }
await rs.unlockAchievement('first_win');    // { newlyUnlocked, achievement, points }
await rs.getAchievements();

// Saves: private to this player and this game, any JSON, 1 MB per key, 16 MB / 1000 keys total.
await rs.data.set('progress', state, { meta: { level: 3 }, ifVersion: lastVersion });
const saved = await rs.data.get('progress');      // { value, version, meta, updatedAt } | null
const { items } = await rs.data.list('slot/');    // keys with meta, for "load game" menus
await rs.data.remove('slot/2');

// Cosmetics: items declared in ringstack.json, bought with Rings (see "Cosmetics" below).
const { items } = await rs.getItems();      // [{ sku, name, price, kind, rarity, slot, preview: { url, type } }]
const inv = await rs.getInventory();        // { items: [{ sku, quantity, equipped, ... }], loadout: { slot: sku } }
await rs.purchaseItem('basket_gold');       // RingStack asks the player to confirm; { purchased, ... }
await rs.equip('basket_gold');              // -> loadout { basket: 'basket_gold' }; rs.unequip('basket')
await rs.getLoadout();                      // { slot: sku } for the signed-in player
await rs.getLoadouts([userId1, userId2]);   // other players: { userId: { slot: sku } }
await rs.cosmetics.attest();                // { token, exp, loadout, owned } for your game server to verify
await rs.consumeItem('potion');             // consumables (not cosmetics)
await rs.showAd({ type: 'rewarded' });      // { shown, completed, rewarded }: reward only if rewarded
```

- Calls that need a player reject with `code: 'unauthorized'` when nobody is signed in. Check
  `rs.user` first, and show a "Sign in" button (calling `rs.signIn()`) instead of failing.
- Errors are `RingStackError` with a `code` (`unauthorized`, `not_found`, `conflict`,
  `rate_limited`, `too_large`, …). Catch them; never let an SDK error stop the game loop.
- Use `ifVersion` on saves that can be written from two devices; on `conflict`, re-read,
  merge, retry. Keep a `localStorage` copy for guests.
- Rate limits: about 120 stat submissions and 120 saves per minute per player. Submit scores
  at the end of a round and save when something worth keeping happens (a finished level,
  a new design), so you stay well inside the limits.
- Scores come from the client; don't design rewards that make cheating worthwhile.

## Cosmetics (how the game makes money)

Design cosmetics from the start. Players buy them with Rings (RingStack's currency) and the
game's publisher earns a share of every sale (70% by default), credited to the publisher
dashboard. They must be **cosmetic only**: they can change how things look and nothing else (no stat
boosts, extra lives or shortcuts). Admins take pay-to-win items off sale.

Patterns that work:

- **Skins and colour variants** of the player's main thing (ship, car, basket, character).
  Cheapest to make: a palette swap.
- **Trails and effects**: engine exhaust, particle trails, hit sparks, a burst when you win or
  destroy something.
- **Outfits**: hats, helmets, armour looks, decals.
- **Emotes**: a short animation or sound the player triggers.
- **Titles and badges** shown next to the player's name.
- **Bundles** of a few items at a lower price, and **limited-time** items (`available.until`).

How to add them:

1. Pick **slots**, one per thing a player can customise (`ship_paint`, `engine_trail`,
   `hat`, `title`). Each item names its slot; equipping an item replaces whatever was in it.
2. Declare items in `ringstack.json` (synced on every publish):
   ```json
   { "sku": "basket_gold", "name": "Gold basket", "description": "Your basket in polished gold.",
     "kind": "skin", "slot": "basket", "rarity": "rare", "price": 150, "preview": "previews/basket_gold.svg" }
   ```
   `kind`: skin, effect, trail, outfit, emote, title, bundle or other. `rarity`: common,
   uncommon, rare, epic or legendary. `preview` is an image (or short MP4/WebM loop) in your
   build, shown in the RingStack shop. Optional: `"bundle": ["sku_a", "sku_b"]` (kind bundle),
   `"available": { "until": "2026-12-31" }`, `"featured": { "from": "…", "until": "…" }`.
   Prices are in Rings; 500 Rings cost about $5, so 80 to 300 is a good range for single items.
3. In the game, keep a table from sku to how it looks, read the loadout on start and on
   `rs.on('user')`, and draw accordingly. Offer a small in-game shop that calls
   `rs.purchaseItem(sku)` then `rs.equip(sku)` (the starter game's basket skins do exactly this).
   Players can also buy and equip from the shop on the game's RingStack page.
4. **Multiplayer**: send each player's id with their state, and fetch the others' loadouts with
   `rs.getLoadouts(ids)` (cache it; refresh when someone joins). If your game has its own server,
   don't trust what a client says it has equipped: have the client send
   `(await rs.cosmetics.attest()).token` and verify it on the server (Ed25519 JWT, public keys
   at `https://ringstack.co/api/v1/jwks.json`, valid 10 minutes; see https://ringstack.co/developers#cosmetics-verify).
5. Always have a free default look, and never hide gameplay behind a purchase.

## Mobile, touch and the RingStack frame

RingStack shows the game in an iframe on the game page (and full screen on phones). The game must:

- **Fill its box**: size to the iframe/viewport (`position: fixed; inset: 0` or 100% of the
  body), redraw on resize (`ResizeObserver`), and work at any size from ~320×480 up. The
  `aspect` in `ringstack.json` (16:9, 4:3, 1:1, 3:4, 9:16, 21:9) only sets the frame shape on desktop.
- **Work with touch**: use Pointer Events (`pointerdown/move/up`), `touch-action: none` on the
  play area, tap targets at least 44×44 px, no hover-only controls, no right-click-only actions.
  Also support keyboard (arrows/WASD/space) and mouse.
- Use `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">`
  and respect `env(safe-area-inset-*)` for HUD elements.
- Use `devicePixelRatio` (capped at 2) for crisp canvases.
- **Pause** on `visibilitychange` when hidden, and start audio only after a user gesture.
- Don't rely on: `alert/confirm/prompt` (allowed but ugly), top-level navigation, opening
  windows without a click, cookies (it's a third-party frame; use `localStorage` / `rs.data`).
- Keep the first load small (aim < 5 MB), and lazy-load large assets.

The frame is sandboxed with `allow-scripts allow-same-origin allow-forms allow-pointer-lock
allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals` and
`allow="fullscreen; autoplay; gamepad; clipboard-write; screen-wake-lock"`. RingStack serves
the game with `frame-ancestors 'self' https://ringstack.co`, so it can only be framed by RingStack.

## Test before publishing

```bash
ringstack dev            # http://127.0.0.1:5173: the game inside a mock RingStack page
```

The mock page runs the real SDK in embedded mode against a fake backend: a signed-in "Dev
Player" (toggle with Sign out / Sign in), scores, achievements from `ringstack.json`, saves,
items and ad breaks, with a log of every SDK call (errors in red). Check:

- [ ] Plays with mouse, keyboard **and** touch; try the phone portrait and landscape views.
- [ ] Works signed out (guest) and signed in; signing in mid-game reloads the player's save.
- [ ] Every achievement key it unlocks is in `ringstack.json` (no red `not_found` in the log).
- [ ] Cosmetics: buy one in the mock page (it confirms with a dialog), equip it, reload: the game
      shows it. Unequip goes back to the free default.
- [ ] Scores reach the leaderboard; saves survive a reload (Reload game button).
- [ ] No console errors. Works with the SDK blocked (offline).

Automated check (Playwright is optional): `window.rsDev` on the mock page exposes
`state`, `logs` and `errors` (failed SDK calls), plus `signIn()`, `signOut()`, `reset()`.

## Publish

```bash
ringstack login          # once per machine: approve in the browser (a human must click)
# bump "version" in ringstack.json and add the CHANGELOG.md section, then:
ringstack publish        # build, upload, deploy; prints the deploy log and the live URL
ringstack status         # review status, live version, recent deploys
ringstack versions       # history with changelogs;  ringstack rollback <version>
```

- The **first** publish of a game asks for a one-time **$5 publishing fee** (sandbox checkout
  in the browser; no real money moves while RingStack is in test mode). A human has to complete it.
- After the first deploy the game is live at its own URL but **waits for admin review** before
  it is listed on RingStack. Once approved, later versions go live immediately.
- `ringstack publish` is a client of RingStack's HTTP API (`/api/v1/publish/*`, documented at
  https://ringstack.co/developers#publish-api). In CI, set `RINGSTACK_TOKEN` to a publisher token
  created in the dashboard.
