# Onceworlds game

This is a browser game published on **Onceworlds**, a platform for AI-made games.
The platform hosts the files and provides multiplayer, saves, player identity,
text chat and a settings overlay. You write only the client: static HTML, JS,
CSS and assets. There is no server code.

## Workflow

- `onceworlds.json` names the game: `slug` (its URL, permanent), `name`,
  `description`, `dir` (the folder to publish; `"."` or e.g. `"dist"` after a build).
  It also fills in the game page: `genre`, `icon` (square, at least 128×128),
  `thumbnails` (16:9, at least 480 wide; the first is the cover) and `badges`
  (square icons, at least 64×64). Images are PNG, JPEG, WebP or GIF up to 5 MB,
  with paths relative to `onceworlds.json`:

  ```json
  {
    "slug": "star-party",
    "name": "Star Party",
    "description": "Grab stars with everyone online.",
    "dir": ".",
    "genre": "Party",
    "icon": "store/icon.png",
    "thumbnails": ["store/thumb-1.png", "store/thumb-2.png"],
    "badges": [{ "id": "first-star", "name": "First Star", "description": "Grab a star.", "icon": "store/first-star.png" }]
  }
  ```

  Genres: Action, Adventure, Fighting, Obby, Puzzle, Racing, Roleplay, Shooter,
  Simulation, Sports, Strategy, Survival, Tycoon, Horror, Party, Other. The
  creator can edit all of this (and add video previews) in the dashboard. Live
  deploys apply these fields only when they change in `onceworlds.json`, so
  dashboard edits last until then.
- Publish: push to GitHub. The Onceworlds workflow in `.github/workflows/` deploys
  every push to https://onceworlds.com. The `main` branch goes live at `onceworlds.com/play/<slug>`;
  other branches get a preview URL (printed in the Actions log). To try a change
  with real multiplayer, push a branch and open its preview.
- Before the first deploy, the repo's owner signs in once at https://onceworlds.com
  with GitHub (for an organization's repo, any member can). Until then the deploy
  job fails with a link to sign in.
- New games start private: only the repo's owner (for an organization's repo, its
  members), signed in, can play. Everyone else sees "This game is private". To
  share the game, the owner opens https://onceworlds.com/dashboard, picks the game,
  and clicks "Make public" in the Settings tab. Deploys never change this.
- With a local Onceworlds platform running, `npx @onceworlds/cli dev` deploys to it
  on every save and prints a URL that reloads itself. Open that URL again in another tab
  with `&player=2` (up to 9) to play as a second guest and test multiplayer in one
  browser (local platform only).
- `index.html` must be at the root of `dir`. If `package.json` has a `build`
  script, CI runs it first.

## Rules of the platform

- **The SDK is injected for you.** Every HTML page gets `window.onceworlds` before
  your scripts run. Don't add a script tag for it. Plain JavaScript needs nothing
  installed. With TypeScript or a bundler (Vite, webpack), `npm i @onceworlds/sdk`
  and `import onceworlds from '@onceworlds/sdk'` for types and autocomplete: it's
  the same object as `window.onceworlds` (and runs outside onceworlds too).
- **Keep the top-left ~130×56 px clear.** The platform's menu, chat and invite
  buttons sit there. If that's a bad spot, call `onceworlds.ui.setMenuPosition('top-right')`
  (or `bottom-left` / `bottom-right`).
- **Don't build your own volume or mute controls.** The platform menu has them and
  applies them to all Web Audio and `<audio>`/`<video>` automatically.
- **Don't build invite links.** The platform's invite button copies a link that
  brings friends into the player's server (public or private).
- **Don't build text chat.** The platform overlay has it (players press `/`, and
  type `@name` to message one player or `#team` / `#group` for group chats).
  Listen to `room.on('chat')` if you want chat bubbles in your game (games only
  receive public messages).
- **Network:** the game can only load and fetch its own files plus these CDNs:
  cdn.jsdelivr.net, cdnjs.cloudflare.com, unpkg.com, esm.sh, ga.jspm.io
  (plus Google Fonts). Calls to any other API are blocked. Use the SDK for
  multiplayer and saves, and `onceworlds.fetch` for AI and media APIs (see
  "Secrets" below).
- **Never put API keys in game code.** Everything in the game is public. Keys
  live in the dashboard as secrets and are added server-side by `onceworlds.fetch`.
- **Uncaught errors are reported** to the creator's dashboard (Overview → Errors)
  automatically. There is nothing to set up.
- `alert()`, `confirm()`, `prompt()`, popups, page navigation, clipboard writes and
  service workers are blocked. Draw your own UI. The game only runs inside its
  Onceworlds play page; a direct link to a game file opens the play page.
- `localStorage` works but stays on one device. Use `onceworlds.save` for progress
  that should follow the player.
- Fullscreen: call `onceworlds.ui.requestFullscreen()` (or the normal
  `element.requestFullscreen()`, which the SDK forwards to the platform).
- **Every game runs on phones and tablets too.** On a touch screen the play page
  goes fullscreen with the first tap (where the browser allows it) and draws the
  movement controls you ask for with `onceworlds.controls`: a thumbstick in the
  lower left and up to 4 buttons in the lower right, like Roblox. Prefer them to a
  joystick of your own, and keep your own buttons out of those corners while
  they're on. Size the canvas to the window and handle `resize` (players rotate their
  phones), keep text readable at 360 px wide, and make everything work by tapping
  (no hover or right-click). When the on-screen keyboard opens (a text field in
  the game), the game frame shrinks to the space above it, so handle `resize`
  there too. If the game only works one way round, call
  `onceworlds.ui.setOrientation('landscape')` (or `'portrait'`). Start play from
  a tap inside the game (a title screen's Play button): iPhones only let sound
  start from a tap in the game itself, and taps on the platform's controls
  don't count.
- Limits: 100 MB per game, 25 MB per file, 2,000 files.

## SDK reference (`window.onceworlds`)

```js
const ow = window.onceworlds;

// Player: a guest until they sign in to an Onceworlds account (their saves carry over)
const player = await ow.player.get();       // { id, name, guest }
// Guests: the platform's name box (the platform menu has it too). Resolves with
// the player after a change, or null (cancelled, signed in, or standalone).
// Signed-in players change their name in Settings, so offer it only to guests.
if (player.guest) await ow.player.rename();

// Avatars: every player has one (dressed up on Onceworlds). Draw it instead of a
// plain shape: an SVG URL for anyone's id ('head' for a headshot), or null
// outside the platform. It works on canvases too.
const url = await ow.player.avatarUrl(someId, 'head');
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = url;                              // then ctx.drawImage(img, x, y, 48, 48)

// The full avatar ('full', 120×210 SVG units) stands facing you with its arms
// and legs apart, so a game can cut it into parts and animate them (walk, wave,
// sit). Each part is [x0, y0, x1, y1, pivotX, pivotY]; the pivot is the joint.
// Draw the image at 1.5× or 2× on a canvas, then drawImage each box, rotated
// around its pivot: legs, then arms, torso and head. Left/right as you look.
const RIG = {
  head: [0, 0, 120, 88, 60, 86],        // (hats and hair included)
  torso: [38, 88, 82, 142, 60, 140],
  armLeft: [22, 90, 38, 144, 31, 96],   armRight: [82, 90, 98, 144, 89, 96],
  legLeft: [40, 136, 60, 198, 51, 142], legRight: [60, 136, 80, 198, 69, 142],
};                                       // the ground shadow is below y 198

// Public variables from the dashboard (Environment tab): feature flags, tuning.
// Available immediately, change without a redeploy, and players can see them.
const doubleXp = ow.env.DOUBLE_XP === 'true';

// Saves: per player, per game, JSON values up to 64 KB, 100 keys, 1 MB in total
// (128 KB until a guest signs in).
// Save on meaningful events (level done, every few seconds at most), never every
// frame: each player gets about 2 writes a second before saves start failing.
await ow.save.set('progress', { level: 3 });
const progress = await ow.save.get('progress');   // null if never set
await ow.save.delete('progress');
const keys = await ow.save.list();

// Badges: listed in onceworlds.json (`badges`) or made in the dashboard, each with an id.
// award() resolves true the first time (the platform shows a notification) and
// false if the player already has it, the id doesn't exist, or the player is a
// guest (guests can't earn badges or post scores; the platform asks them to sign
// in). Awards come from the player's browser, so don't gate anything valuable on them.
await ow.badges.award('first-win');
const badges = await ow.badges.list();  // [{ key, name, description, icon, earned, earnedAt }]
const has = await ow.badges.has('first-win');

// Leaderboards: each signed-in player's best score per board (top 100).
await ow.leaderboards.submit('wins', 12);                         // { best, rank } or null for guests
await ow.leaderboards.submit('fastest-lap', 41.2, { lowerIsBetter: true });
const { entries, me } = await ow.leaderboards.top('wins', { limit: 10 });
// entries: [{ rank, player: { id, name }, score }]

// The platform's clock (ms): same for every player, not the device clock.
// Use it for anything that runs while nobody plays: growth, cooldowns, restocks.
const now = ow.now();

// Phones and tablets: on-screen controls, drawn by the platform while the player
// plays by touch. The stick and buttons press keys in your game, so keyboard
// controls work unchanged.
ow.controls.set({
  stick: 'wasd',                                   // or 'arrows', or 'analog' (presses no keys)
  buttons: [                                       // up to 4; the first is the biggest
    { id: 'jump', label: 'Jump', key: ' ' },
    { id: 'use', label: 'Use', key: 'e' },
  ],
});
ow.controls.set(null);          // none, e.g. on a title screen; set() again when play starts
ow.controls.stick;              // { x, y } from -1 to 1 (y points down), 0 when let go
ow.controls.pressed('jump');    // true while held
ow.controls.touch;              // true while the player plays by touch (controls on screen)
ow.ui.setOrientation('landscape');   // or 'portrait'; the default 'any' plays either way

// Platform events
ow.on('pause', () => {});    // player opened the platform menu
ow.on('resume', () => {});
ow.on('audio', ({ volume, muted }) => {});   // already applied; for your own UI only
ow.on('player', (player) => {});             // your name changed (a guest picked or changed it)
```

### Multiplayer rooms

Matchmaking puts each player in the fullest public server that still has room
and opens new servers as needed, so there's no limit on players per game.
Players are only matched with others who asked for the same `mode`,
`maxPlayers` and `teams`. Signed-in players can also start a **private server**
from the game's page and send its link to friends: on a page opened from that link,
`rooms.join()` (without a `name`) joins the private server, with no code needed.

```js
const room = await ow.rooms.join();                           // a public server (16 players)
const room = await ow.rooms.join({ mode: 'duel', maxPlayers: 2 });      // matched 1v1 (queues are per mode)
const room = await ow.rooms.join({ mode: 'tdm', maxPlayers: 8, teams: 2 }); // 4v4, balanced teams
const room = await ow.rooms.join({ private: true, maxPlayers: 6 });     // new private server
const room = await ow.rooms.join({ name: 'lobby-7f3k' });      // a named room your game manages

room.me              // { id, name, presence, team }: you (name: the player's account name, or
                     // the name a guest picked; the platform asks guests before their first room)
room.players         // Map<id, { id, name, presence, team, guest? }>, including you
room.host            // id of the longest-connected player
room.isHost          // true if that's you
room.state           // shared key/value object: read it any time
room.kind            // 'public' | 'private' | 'named'
room.invite          // private servers: the invite link
room.teams           // number of teams (0 = none); player.team is 1..teams
room.on('rename', (player) => {});  // someone (maybe you) changed their name: redraw name tags
ow.ui.showInvite();  // opens the platform menu at its Invite button (copies the link)

// Host only:
room.setTeam(playerId, 2);   // move a player (room.on('team', (player) => {}))
room.setOpen(false);         // match started: matchmaking stops sending players here
room.setOpen(true);          // back to accepting players (room.on('open', (open) => {}))
room.kick(playerId);         // private servers and named rooms: remove a player; they can't come back

// Anyone: vote to remove a player. It takes a majority of the others (at least
// 2 votes). Public servers have no real host, so this is how they remove people.
room.voteKick(playerId);
room.on('votekick', (player, votes, needed) => {});

// Parties: friends in a private server (or any room) queue together. The host
// calls this; everyone in the room lands in one server (one team, if it fits).
const match = await ow.rooms.join({ mode: 'tdm', maxPlayers: 8, teams: 2, party: true });
ow.rooms.on('moved', (match) => {});  // everyone else: the platform moved you
// The old room closes with reason 'moved'.

// Ranked: matched with players of similar rating (the range widens the longer a
// server waits for players). The host calls room.setOpen(false) when the match
// starts; from then on, players who leave still count as playing it. When it
// ends, EVERY player's game reports the same result; ratings change only when
// the reports agree (a quitter's report isn't needed).
const room = await ow.rooms.join({ mode: 'duel', maxPlayers: 2, ranked: true });
room.reportResult({ winners: [winnerId] });   // or { winningTeam: 1 }, { draw: true },
                                               // { ranking: [[1st ids], [2nd ids], ...] }
room.on('rated', (changes, disputed) => {});  // [{ id, rating, delta }]; player.rating too
await ow.ratings.get('duel');                 // { rating, games } or null (guests aren't rated)
await ow.ratings.top('duel', { limit: 10 });  // [{ rank, player, rating, games }]

// 1. Presence: your per-player data (position, color, score). Call as often as
//    you like; the SDK sends at most 20 updates/s. Keep it under 1 KB.
room.setPresence({ x, y, hue, score });
room.on('presence', (player) => { player.presence; });

// 2. Shared state: world data everyone sees. Last write wins. The server keeps
//    it until everyone leaves. Values under 16 KB, at most 256 keys.
room.setState('doors', { red: 'open' });
room.setState('doors', null);                 // delete
room.on('state', (key, value, fromId) => {});

// Each player can send about 60 messages and 128 KB a second to a room (short
// bursts above that are fine); anything beyond is dropped, and a client that
// keeps flooding is disconnected.

// 3. Messages: one-off events. Not stored.
room.send({ type: 'boom', x, y });             // to everyone else
room.send({ type: 'hit' }, { to: playerId });  // to one player
room.on('message', (data, fromPlayer) => {});

room.on('join', (player) => {});
room.on('leave', (player, kicked) => {});      // kicked: removed by the host or a vote
room.on('host', (hostId) => {});               // the host left; a new one took over
room.on('chat', ({ from, name, text }) => {}); // public overlay chat (direct and group
                                               // messages stay private to their people)
room.on('close', (reason) => {});              // 'replaced' | 'disconnected' | 'left' | 'moved' | 'kicked'
room.leave();
```

### Secrets: calling AI and media APIs

The creator adds API keys in the dashboard (Environment → Secrets), each bound
to one API host. Game code refers to a key as `{{SECRET_NAME}}` in a header or
the query string and sends the request through `onceworlds.fetch`, which has the
same shape as `fetch`. The platform fills in the key server-side, so it never
reaches the browser. Only these hosts are reachable: api.anthropic.com,
api.openai.com, generativelanguage.googleapis.com, api.mistral.ai, api.groq.com,
openrouter.ai, api.together.xyz, api.elevenlabs.io, api.replicate.com, fal.run.

`onceworlds.fetch` also works as the `fetch` option of API SDKs. For Claude, use
the official SDK:

```js
import Anthropic from 'https://esm.sh/@anthropic-ai/sdk';

// The SDK only ever sees the placeholder; Onceworlds adds the real key.
// (dangerouslyAllowBrowser is safe here because no real key is in the browser.)
const claude = new Anthropic({
  apiKey: '{{ANTHROPIC_API_KEY}}',
  fetch: onceworlds.fetch,
  dangerouslyAllowBrowser: true,
});

const reply = await claude.beta.messages.create({
  model: 'claude-opus-5',
  max_tokens: 16000,
  betas: ['server-side-fallback-2026-07-01'],
  fallbacks: 'default', // if Claude declines, Anthropic retries on a fallback model
  messages: [{ role: 'user', content: 'Greet the player in one short line.' }],
});
if (reply.stop_reason !== 'refusal') {
  const line = reply.content.find((block) => block.type === 'text')?.text;
}
```

- Only generation endpoints work (e.g. `POST /v1/messages`, `POST /v1/chat/completions`),
  and every call must use a secret.
- Limits: 30 calls a minute per player and 2,000 calls a day per game. Bodies up
  to 1 MB, responses up to 10 MB. Responses arrive complete (no token streaming yet).
- `onceworlds.fetch` throws if the host isn't allowed, the secret doesn't exist,
  or the secret belongs to a different host. API errors (like 401 or 429) come
  back as normal responses.
- It only works on the platform (previews and local platforms included), not when the page is
  opened on its own.

### Designing multiplayer for this game

Build the game the creator asks for. Everything below is a default for when they
haven't said otherwise, never a reason to change their idea. When asked to "add
multiplayer", first work out what playing together means in *this* game, then
answer these questions and build the answers.

**1. What kind of multiplayer is it?**
- **Drop-in world** (hangouts, sandboxes, obbies, tycoons, .io arenas): players
  come and go at any time and play right away. No lobby, no rounds, one pool of
  public servers (`rooms.join()`), private servers for friends.
- **Rounds or matches** (party games, races, battles, minigames): players play
  a game together, see results, then play again. These need a lobby.
- **Turns** (board, card and word games): a table of friends, usually a private
  server, a turn order and a host who starts.
- **Head-to-head** (duels, 1v1 or 2v2, ranked): matchmaking by mode
  (`rooms.join({ mode: 'duel', maxPlayers: 2 })`), often `ranked: true`.
- **Solo with a social layer**: leaderboards, badges, shared world events and
  other players' ghosts or gardens, maybe without rooms at all.

**2. Who plays together?** Pick the server size the game feels best at (2 for
duels, 6-12 for party games, 16-50 for worlds), free-for-all or `teams`, and
whether friends need private servers (they always have them through the
game's page and invite links).

**3. Does a host make sense?** A host is a player who runs the game for the
others: starts it, picks settings, moves people between teams, removes
troublemakers in private servers (`room.kick`). Rounds, matches and turns
usually want one; drop-in worlds usually don't (there `room.host` only runs the
game logic, and a private server's host might get a few toggles). In public
servers the host is a stranger (whoever has been there longest): let them start
the game and pick settings, but show a visible auto-start countdown so an idle
host never blocks everyone, and never give them power over other players
(removing someone takes a vote, `room.voteKick`).

**4. When does play start and stop?** For rounds and matches, the default is a
lobby between games: results, then back to the lobby, where the host starts the
next one (a player can take a break, the host can change settings, newcomers
can settle in). A 3-2-1 countdown before play, timed with `ow.now()`. Starting
the next round automatically is right only when that's the game's point (a
nonstop minigame hub with intermissions, a drop-in arena); then show the
intermission timer. In private servers let the host end a game early.

**5. What if there aren't enough players?** Bots, a practice or solo mode, or a
waiting screen that says how many players are needed and has an Invite button
(`ow.ui.showInvite()`). A player alone should never face a dead screen.

**6. What happens when people join or leave mid-game?** Drop-in games let them
play at once. Round games show newcomers what's happening and let them play
from the next round, or right away if that's fair (a team game can drop them on
the smaller team). A player leaving never stalls the game: skip their turn,
rebalance teams, recount "everyone is done". When the host leaves,
`room.on('host')` fires and the new host carries on without a reset, so keep the
game's authoritative state in room state, not only in the host's memory.

**Details players notice**
- Names and avatars on lobbies, scoreboards and results (`room.players`,
  `ow.player.avatarUrl`), a host marker and "You". Names come from the platform
  (guests pick one before their first room and can change it any time): never
  ask for a name yourself, and follow `room.on('rename')`.
- Settings the host picks live in room state, so everyone sees them change.
- `room.on('close', reason)`: 'kicked' shows "You were removed" with a way to
  find another server; 'disconnected' offers to rejoin. Nothing freezes.
- The host checks every request (distances, cooldowns, guesses, scores); other
  players only ask.

### Patterns that work

- **Movement:** put positions in presence. Draw other players by easing toward
  their latest presence, since updates arrive at up to 20 Hz, not every frame.
- **Authority:** the host owns shared state (spawning, scores, rounds). Other
  players send requests with `room.send(..., { to: room.host })`, and the host
  validates them and writes state. On `room.on('host')`, the new host takes over.
  `game.js` shows this with stars.
- **Your own events aren't echoed.** `send` doesn't come back to you, and
  `setState` / `setPresence` apply locally right away.
- **Single-player games** can skip rooms entirely. The platform then hides chat.
- **Guests** (not signed in) can play, save and join rooms, but can't chat, earn
  badges or post scores.
- **Badges:** award one where the achievement happens, once per session
  (`game.js` awards `first-star`). Add each one to `badges` in `onceworlds.json`;
  until a live deploy creates it, `award` just resolves false.
- Outside the platform (`onceworlds.mode === 'standalone'`), saves use localStorage,
  badges are remembered on the device, and `rooms.join()` gives you a solo room,
  so the same code still runs.

### Building common game types

These are patterns for common kinds of games, to adapt to what the creator
wants. There is no server code: one player's browser, the **host**, runs the rules, and
everyone else follows it through state and messages. This covers most
multiplayer games; a determined player can still cheat, so keep rewards cosmetic.

- **Team battles** (capture the flag, team deathmatch): `rooms.join({ mode, maxPlayers: 8, teams: 2 })`.
  The host runs rounds in state (`round`, `scores`, `endsAt: ow.now() + 180000`),
  calls `room.setOpen(false)` while a round is running and `setOpen(true)` between
  rounds, and can rebalance with `setTeam`. Positions go in presence, hits and shots
  as messages to the host, which decides and writes scores.
- **Party games** (draw-and-guess, trivia, word games): public servers of 8-10
  (`rooms.join({ mode: 'party', maxPlayers: 10 })`) start on a countdown; private
  servers wait in the lobby for the host, who picks rounds, time per turn and word
  lists. The host rotates turns, checks guesses and awards points for speed.
  Late joiners play from the next turn; a drawer who leaves ends that turn.
- **Board and turn-based games** (Monopoly-style): friends use a private server
  (`rooms.join({ private: true, maxPlayers: 6 })` plus `ow.ui.showInvite()`). The
  host owns the board in state (`turn`, `board`, `players`); players send moves to
  the host, which validates and applies them. For games that last longer than a
  session, the host also saves the board with `ow.save.set('match-<room.id>', ...)`
  and restores it when the table reopens.
- **Farming and idle games** (Grow a Garden-style): each player's garden, coins
  and inventory live in `ow.save`, with timestamps from `ow.now()` (`plantedAt`), so
  plants keep growing while the game is closed. Other players' gardens can be
  shown by putting a small summary in presence. Shared events like shop restocks
  or weather come from `ow.now()` (e.g. `Math.floor(ow.now() / 300000)` as the
  restock number, used to seed the stock) or from dashboard variables
  (`ow.env.WEATHER`). Trades: both players send an offer to the host, which
  confirms, and each player updates their own save.
- **Real-time strategy**: `rooms.join({ mode: '1v1', maxPlayers: 2 })` or `teams: 2`
  for 2v2, then `setOpen(false)` at match start. Run a lockstep simulation: players
  send commands to the host, the host sends everyone numbered turns (10-20 a second)
  with the commands for that turn, and every client runs the same deterministic
  simulation (seed randomness from state). Keep messages small.
- **Fast shooters and duels** (Rivals-style): `rooms.join({ mode: 'duel', maxPlayers: 2, ranked: true })`,
  or `mode: '2v2', maxPlayers: 4, teams: 2`; friends queue together with `party: true`
  from a private server. Send your own position, aim and
  animation 20-30 times a second with `room.send` (or presence), predict your own
  movement locally, and interpolate others about 100 ms behind. The host confirms
  hits and keeps score. At the end, every player calls `room.reportResult(...)` for
  ratings; use leaderboards for totals and badges for milestones.
- **Solo tutorial, then multiplayer**: run the tutorial without joining a room
  and store `tutorialDone` in `ow.save`. When it ends (or right away for returning
  players), `rooms.join({ mode: 'main' })`.
- **Lobbies and parties**: gather players in a room (a private server for friends,
  or a public room as a lobby), and when the host is ready, `rooms.join({ mode, party: true })`
  moves everyone there into one matchmade server together. Handle
  `ow.rooms.on('moved', ...)` on every player to pick up the new room.

