
# Ronark — developer and agent guide

Ronark hosts browser games and supplies accounts, signed game identity, saves,
optional platform avatars, reviewed assets, and Multiplayer v4. This document
describes the current supported product. Use the documented SDK and multiplayer
project workflow rather than inventing additional multiplayer APIs.

## 1. Fast path

The game already exists before you start. The human creates it at
https://ronark.com (Studio -> New game): they name it, reserve its web
address, and pay for its plan there. That flow ends by handing them ONE
command, which they paste to you:

```bash
curl -fsSL https://ronark.com/cli/ronark.mjs -o ronark.mjs && node ronark.mjs connect <slug> <key>
```

Run it verbatim in the project folder before spending time on the game. It
authenticates, writes a ronark.json that matches the registered game, records
the platform in the project's AGENTS.md (don't add a duplicate note), and
prints the game's plan and your next steps. Then:

```bash
node ronark.mjs deploy --promote
node ronark.mjs publish
```

`login <key>` and `init <slug>` exist separately when you need the pieces.

The connect command carries no game description. What to build arrives as
ordinary conversation from your human, before or after you connect — ask if
you don't have it yet.

With no key, stop and ask your human for the block from
Studio -> your game -> Connect AI. `ronark login` takes a key argument and has
no browser flow, so there is nothing to bootstrap yourself: do not create an
account, a studio, or a game on their behalf.

Environment overrides:

- `RONARK_API_KEY`: bearer API key, preferred for CI.
- `RONARK_API_URL`: API origin override.

Useful commands:

- `ronark deploy [dir] [--promote]`
- `ronark promote <build_id> [--channel live]`
- `ronark rollback [--channel live]`
- `ronark history [--channel live]`
- `ronark dev-token [--sub id] [--name name]`
- `ronark logs`
- `ronark plan` (bare = show the current plan; naming a tier CHANGES the
  subscription — never name one just to check state)
- `ronark whoami`

Published keeps a static game live. Starter and Pro add Multiplayer v4 CCU;
room count is not a plan quota. One game has one subscription; changing tiers
updates it.

`ACTIVATION_REQUIRED` (upload) and `FEE_REQUIRED` (publish) both mean the game
has no plan. This is the one situation that needs the human: they pick a plan
in Studio, then you re-run the command. Do not block waiting on them.

## 2. ronark.json

The manifest lives at the uploaded bundle root. Fields that are present remain
owned by the repository; omitted store metadata remains owned by Studio.

```json
{
  "$schema": "https://ronark.com/schemas/ronark.v1.json",
  "slug": "my-game",
  "title": "My Game",
  "entry": "index.html",
  "engine": "three",
  "cross_origin_isolation": false,
  "orientation": "landscape",
  "maturity": "everyone",
  "pricing": { "type": "free" },
  "skus": [],
  "sitelock": { "frame_ancestors": [] },
  "multiplayer": {
    "version": 4,
    "default_profile": "default",
    "profiles": {
      "default": {},
      "npcs": {
        "server": "src/room-server.ts",
        "server_required": true,
        "server_tick_ms": 50,
        "server_data": {
          "version": 1,
          "kv_read": ["world/"],
          "kv_write": ["world/"],
          "player_read": ["inventory"],
          "player_write": ["inventory"],
          "leaderboard_read": ["season"],
          "leaderboard_submit": ["season"],
          "reward_tables": [],
          "player_scope": "actor"
        }
      }
    }
  }
}
```

Omit `multiplayer` for a non-multiplayer game. Relay profiles need no artifact.
The CLI bundles each declared server entrypoint to private, immutable ESM; its
source is never part of the public game build.

`server_data` is a strict least-privilege grant. KV entries are
prefixes; player entries are slot names; leaderboard entries are board names.
`player_scope: "actor"` limits a hook to the event's player. `"room"` permits
players in the active room roster captured for that event, plus the event's
player. Omit `server_data` to grant no shared data access. `reward_tables` is
reserved for a future primitive and must remain empty in V1.

Each server-plugin hook invocation has a 10ms CPU budget, eight subrequests, no outbound
networking, and a two-second wall-time limit.
Required server-plugin failures close the room; optional failures disable that server plugin
while the relay remains available.

These limits apply per invocation. **Server Plugins** is one Ronark-controlled
Studio feature. It covers plugin execution and optional `ctx.data` operations;
`server_data` is only the build profile's least-privilege operation scope.

Build limits: 100 MB compressed, 500 MB uncompressed, 10,000 files. Do not
include symlinks, nested archives, secrets, `.git`, or `node_modules`.

## 3. Game SDK

Hosted SDK entry points:

- `https://ronark.com/sdk/v4.js`
- `https://ronark.com/sdk/v4.mjs`
- `https://ronark.com/sdk/v4.d.ts`

```js
const ronark = await Ronark.init();

ronark.user; // stable per-game sub, display name, or null outside Ronark
const save = await ronark.storage.get();
await ronark.storage.set({ level: 3 });
```

`ronark.getToken()` returns an ES256 game JWT. Verify it with
`https://ronark.com/.well-known/ronark-jwks.json` and check issuer, expiry, and
audience, or call `POST /v1/sdk/verify`. Never trust a client-supplied player id
instead of the signed `sub`.

Games run on their own origin in a sandboxed iframe. They may use fetch,
WebSockets, IndexedDB/localStorage, pointer lock, fullscreen, gamepad, and
optional cross-origin isolation. Camera, microphone, modal browser dialogs,
and arbitrary popups are not part of the game sandbox contract.

## 4. Avatar SDK

Ronark controls Avatar SDK availability as a Studio feature. Games in an
enabled studio resolve engine-neutral player and NPC descriptors from the
hosted SDK:

```js
const ronark = await Ronark.init();
const localAvatar = await ronark.avatars.get();
const randomNpcAvatar = await ronark.avatars.getRandomNpc();

const activePlayerIds = [...room.players.values()]
  .filter((player) => player.active)
  .map((player) => player.id);
const roomAvatars = await ronark.avatars.getPlayers(activePlayerIds);
```

`avatars.get()` returns the current player's descriptor or `null`.
`avatars.getPlayers()` accepts at most 50 raw per-game room player ids; this
limit is checked before duplicates are removed. It returns a
descriptor-or-null entry for each unique id. Use only opaque ids supplied by
Multiplayer V4 and page larger rosters into separate calls.

`avatars.getRandomNpc()` takes no arguments and returns one random appearance
from a small Ronark-curated clothing pool, or `null`. Games cannot request a
specific preset, item, or loadout and receive no internal appearance id. Each
call is independent: resolve once per live NPC entity and treat its appearance
as local cosmetic presentation only. Different clients or reconnects may see
different clothing; never derive gameplay state from it.

Games without Avatar SDK access, standalone runs, development-token identities,
and unavailable player avatars receive `null`. Lookup may also reject on
timeout or connectivity failure. Always render a lightweight fallback and load
avatars asynchronously so they never delay joining a room or starting
gameplay.

For Three.js, use the public renderer at
`https://ronark.com/sdk/avatar-three/v1/index.mjs` and its declarations at
`https://ronark.com/sdk/avatar-three/v1/index.d.mts`. It requires exactly
Three.js r171. A browser import map must map both `three` and `three/addons/` to
the r171 jsDelivr module paths so the renderer, `GLTFLoader`, and game share
one Three.js instance.

Create one `createRonarkAvatarThreeRuntime(new GLTFLoader())` per room or scene
and load every player/NPC descriptor through `runtime.load(descriptor)`. Every
successful descriptor guarantees `idle`, `walk`, and `run`. Optional animation
ids come from `descriptor.animations`. `runtime.load()` preloads the guaranteed
three. Immediately call `instance.preload(ids)` for optional actions that may
first play later; it loads clips without changing the active action. Call
`play(id, { fade_seconds, time_scale })` only when state changes,
`stop(fadeSeconds)` when needed, and `update(deltaSeconds)` for every live
instance on every frame.

The renderer's shared asset cache uses `catalog_version` plus stable model URL
and intentionally deduplicates assets across descriptor revisions. Cache player
instances by player id plus `catalog_version` and `revision`, disposing and
reloading when either field changes. Random NPC descriptors have no stable
appearance id and currently share revision zero: mount every returned
descriptor fresh, keep it only for that live entity, and never reuse it across
a reconnect or another random request. Dispose departed instances and call
`runtime.dispose()` at room teardown.

Descriptor URLs are short-lived runtime resources: do not persist or share
them, and preload late optional actions while the URLs are fresh. Initial
`runtime.load()` (including core clips) and later optional-animation requests
can fail separately; catch
`RonarkAvatarThreeError`, keep the fallback visible, and never retry from the
render loop. Human guide and end-to-end example:
https://ronark.com/docs/avatars.

## 5. Multiplayer SDK V4

V4 is a reliable ordered relay by default. Rooms expose master-client election,
transient messages, shallow revisioned room/player properties, and replicated
entities. A build profile may layer private server-plugin hooks onto the same
room. Relay traffic and selective server authority therefore coexist.

Use `createRoom`, `joinRoom`, `joinOrCreateRoom`, or `matchRoom`. Send to
`all`, `others`, `master`, selected players, or `server`. Replies are normal
messages linked by `replyTo`; there is no PUN-style object RPC or buffered RPC
history. Late joiners and reconnects receive current properties/entities.

Only the server plugin creates server-owned entities, making them suitable for NPCs,
trusted pickups, and hazards. Player-, master-, and server-owned entities can
coexist. Optional client interpolation clamps between authoritative samples and
never extrapolates or predicts.

The deployed build pins profile code, whether failure closes the room, and an
optional tick of 50..1000ms. Clients may select a declared profile at creation
or matchmaking but cannot change its code/tick/authority. Event-driven rooms
hibernate; ticked hooks run only while at least one player is active.

Relay-only projects need no compiler dependency. A custom server profile
requires game-project `esbuild`; keep server-only imported modules under
`.ronark/server/`, which the CLI reserves and removes from the public upload.

```js
const ronark = await Ronark.init();
const room = await ronark.multiplayer.joinOrCreateRoom("match-1", {
  playerProperties: { team: "blue" },
  create: { profile: "npcs", maxPlayers: 16 },
});

room.on("message", ({ type, payload }) => handle(type, payload));
room.send("emote", { id: "wave" }, { to: "others" });
room.send("buyItem", { sku: "potion" }, { to: "server" });
```

The SDK automatically resumes a reserved seat (30 seconds by default), installs
a full snapshot, then emits `reconnected`. Transient messages missed while
offline are not replayed. Room size defaults to 16 and is capped at 32. Starter
includes 100 CCU and Pro 400; reservations in reconnect grace count as CCU.

The playable relay-and-server-plugin reference game is
https://ronark.com/g/ronark-snowball-arena.
The complete copyable text starter is
https://ronark.com/multiplayer-v4-starter.txt.

For a Studio with **Server Plugins** access, profiles declaring `server_data`
receive `ctx.data` in `onPresence` and `onMessage`; `onCreate`, `onTick`, and
`onEffectResult` do not receive it. The manifest block scopes operations and
does not create another entitlement.

## 6. Multiplayer authentication

V4 room acquisition uses the signed game identity automatically. Game code
calls `joinRoom()`/`joinOrCreateRoom()` and does not handle the short one-time
WebSocket ticket or resume credential itself.

## 7. Saves, data, and leaderboards

Client cloud saves are available through `ronark.storage`. Leaderboards and
authoritative player data remain read-only from game clients. Trusted writes
can come from your own backend with an S2S key or, when the Studio has **Server
Plugins** access, from a V4 server plugin's `ctx.data` capability.

Supported V1 APIs:

- `ctx.data.kv.get/list/create/compareAndSet/delete`
- `ctx.data.players.get/create/compareAndSet/delete`
- `ctx.data.leaderboards.top/rank/submitBest`

Reads are asynchronous point-in-time observations. Await every query, and
complete all reads before declaring the first write. Writes are synchronous
declarations and return an `effectId`. `create` succeeds only when the target
does not exist. Updates and deletes require the opaque version returned by a
point read; never parse or synthesize it. `submitBest` keeps the higher score.
All writes declared by one hook apply together or none apply. Declare at most
one write per logical target in a hook: one KV key, one player-and-slot pair,
or one leaderboard-and-player pair.

Choose result delivery with `result: "none" | "hook" | "actor" | "both"`.
`hook` durably invokes `onEffectResult`; `actor` emits the initiating browser's
live `room.on("dataResult")` event and is best-effort after disconnect. `actor`
and `both` are valid only for writes declared by `onMessage`; `onPresence` may
use only `none` or `hook`. Keep pending intent in `ctx.state` and settle it from
`onEffectResult` when the outcome must survive a browser disconnect. Treat a
`duplicate` result as success-compatible with `applied`; handle it defensively
even when ordinary game flow does not expect one.

Per hook, V1 permits at most 8 total data operations and 4 writes. Point values
are limited to 64 KiB. `kv.list` and `leaderboards.top` return at most 25 rows
per call, and the aggregate read or write payload is limited to 128 KiB. KV
`create`/`compareAndSet` `ttlSeconds` must be an integer from 1 through
31,536,000 seconds.

Manifest resource names are at most 128 UTF-8 bytes and may use only letters,
digits, underscores, dots, colons, hyphens, and slashes. Each KV, player, or
leaderboard permission list accepts at most 64 unique entries. An empty string
in `kv_read` or `kv_write` explicitly grants the full canonical KV keyspace.

Example: grant an item into the permitted `inventory` player slot:

```ts
import { defineRoomServer } from "@ronark/multiplayer/server";

type State = {
  pending: Record<string, { playerId: string; item: string }>;
  granted: Record<string, true>;
  failures: Record<string, string>;
};

export default defineRoomServer<State>({
  async onMessage(ctx, message) {
    if (message.channel !== "claim-item") return;

    const playerId = message.from.playerId;
    const current = await ctx.data.players.get(playerId, "inventory");
    const inventory = current.exists
      ? (current.value as { items: string[] })
      : { items: [] };
    const item = "sunburst_kart";
    if (inventory.items.includes(item)) return;

    const next = { items: [...inventory.items, item] };
    const effect = current.exists
      ? ctx.data.players.compareAndSet(playerId, next, {
          slot: "inventory",
          ifVersion: current.version,
          result: "both",
        })
      : ctx.data.players.create(playerId, next, {
          slot: "inventory",
          result: "both",
        });

    const state = ctx.state ?? { pending: {}, granted: {}, failures: {} };
    ctx.setState({
      ...state,
      pending: {
        ...state.pending,
        [effect.effectId]: { playerId, item },
      },
    });
  },

  onEffectResult(ctx, event) {
    const state = ctx.state ?? { pending: {}, granted: {}, failures: {} };
    const pending = { ...state.pending };
    const granted = { ...state.granted };
    const failures = { ...state.failures };

    for (const result of event.results) {
      const request = pending[result.effectId];
      if (!request) continue;
      if (result.status === "applied" || result.status === "duplicate") {
        granted[`${request.playerId}:${request.item}`] = true;
      } else {
        failures[result.effectId] = result.errorCode ?? result.status;
      }
      delete pending[result.effectId];
    }
    ctx.setState({ pending, granted, failures });
  },
});
```

A competing writer produces a conflict instead of overwriting the newer
inventory. The example records that result rather than treating it as a grant.
Handle a later player or room event and perform a fresh read before retrying.
`ctx.data.rewards.rollAndGrant` is reserved and fails closed in V1.

For `onMessage` writes requested with `result: "actor"` or `"both"`, the
initiating browser can observe the committed result on the live Room
connection:

```js
// Register before sending the server-targeted claim message.
room.on("dataResult", (event) => {
  for (const result of event.results) {
    if (
      result.status === "applied" ||
      result.status === "duplicate" ||
      result.status === "unchanged"
    ) {
      showCommitted(result);
    } else {
      showRejected(result.errorCode ?? result.status);
    }
  }
});
room.send("claim-item", {}, { to: "server" });
```

`actor`/`both` delivery is live and best-effort if the player disconnects.
`hook`/`both` durably invokes `onEffectResult`; use that hook, not the browser
event, for server-side state that must reflect the result.

Declaring `server_data` requests only the permissions in the manifest. It does
not grant another product entitlement: the Studio's Server Plugins access
covers both plugin execution and optional `ctx.data`. Relay profiles and server
plugins without `server_data` request no authoritative data operations.

## 8. World assets

Ronark-owned games may query reviewed world assets with
`ronark.assets.search()` and resolve approved runtime URLs with
`ronark.assets.getMany()`. Agent/tool adapters should receive only
`ronark.assets.forLevelDesign()`, which exposes metadata and placement/collision
hints but not raw asset bytes or signed runtime URLs.

## 9. CI

Set `RONARK_API_KEY`, download the single-file CLI, run the project's build and
tests, then run `ronark deploy --promote`. Treat any failed command as a failed
CI run.

Useful URLs:

- Store: `https://ronark.com/g/{slug}`
- Live game: `https://{slug}.ronarkusercontent.com/`
- Human docs: `https://ronark.com/docs`
- Agent guide: `https://ronark.com/llms-full.txt`
- JWKS: `https://ronark.com/.well-known/ronark-jwks.json`
