# Remix developer documentation

Build, publish and operate games on Remix. Each section below is one page of https://remix.gg/docs.

## Get Started

### Introduction

Source: https://remix.gg/docs/introduction

What Remix gives a game developer, how a game runs on the platform, and the ways you can build and ship one.

Remix is a platform for web games. A game is a single HTML document. Remix runs it inside a player frame on the mobile app, on remix.gg and on Remix Desktop, and it supplies the player, the scoring, the social layer and the progression features around it.

You bring the game. The platform brings players, identity, scores, named leaderboards, achievements, cloud saves, turn-based and realtime multiplayer, purchases and distribution.

#### How a Game Runs

Every game is one HTML document that loads the Remix SDK. Uploads through Remix Desktop, the CLI and the HTTP API add the SDK script tag for you, and Remix serves the game inside a frame on each surface. The SDK talks to the host through messages, so a game never needs to know which surface it is on.

The lifecycle is short:

1. The game boots and calls `sdk.ready()`. The host answers with the player, the safe area and any saved state.
2. The player plays. The game writes saves, leaderboard records and achievement unlocks as the moment happens.
3. The run ends. The game calls `gameOver` exactly once with the score. The host draws the results screen.
4. The host sends `play` when the player wants to go again. The game resets its run without reloading the page.

Leaderboards and achievements are declared once in the project's `remix.json` and served by the platform everywhere. An achievement declared there shows up on the game page on every surface with no extra UI work in the game. Named leaderboards are ranked by the platform and shown by the game itself through `sdk.leaderboards`.

#### Ways to Build

- **Remix Desktop** is the creator app. Describe a game and the built-in agent writes it, previews it and publishes it, with the platform features wired in. Its Details page is where you manage achievements, and its agent can draw achievement icons in your game's art style.
- **Bring your own game.** Any HTML5 game that follows the SDK contract can be uploaded. Use the Studio on remix.gg or the [HTTP API](/docs/http-api) to create the game and push versions.
- **Three.js games** can use `@remix-gg/three`, a thin wrapper that owns the renderer, the viewport, the loop, input and the platform handshake. See [Three.js](/docs/three-overview).

#### What to Read Next

- [Quickstart](/docs/quickstart) takes a blank page to a playable, published game.
- [SDK Overview](/docs/sdk-overview) covers the handshake and the three calls every game makes.
- [Leaderboards](/docs/leaderboards), [Achievements](/docs/achievements) and [Cloud Saves](/docs/cloud-saves) are the progression features.
- [Publishing](/docs/publishing) explains what happens to an upload before it goes live.

### Quickstart

Source: https://remix.gg/docs/quickstart

Go from a blank page to a game that boots, scores and restarts on Remix, then publish it.

This page builds the smallest game that is complete on Remix: it boots, reports a score once per run and restarts when the host asks. Everything else on the platform is optional and layered on top of this.

#### 1. Pick How You Want to Build

**Remix Desktop** does all of this for you. Open the app, describe a game, and the agent scaffolds a project, wires the SDK, previews it and publishes it. Come back to these docs when you want to understand what it built or extend it by hand.

**By hand** means one HTML file. You do not need a bundler, a package manager or a framework. The rest of this page is the by-hand path.

#### 2. Write the Game

Save this as `index.html`. The game reads the Remix SDK as `window.RemixSDK`; step 3 covers the script tag that loads it.

```html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, viewport-fit=cover"
    />
    <style>
      html, body { margin: 0; height: 100%; background: #060b0f; color: #fff; }
      body { font-family: system-ui; }
      #tap { position: fixed; inset: 0; display: grid; place-items: center; }
      #tap { font-size: 48px; user-select: none; }
    </style>
  </head>
  <body>
    <div id="tap">0</div>
    <script>
      const sdk = window.RemixSDK
      const el = document.getElementById('tap')
      let score = 0
      let deadline = 0
      let over = true

      function startRun() {
        score = 0
        over = false
        deadline = performance.now() + 5000
        el.textContent = '0'
        requestAnimationFrame(tick)
      }

      function tick(now) {
        if (over) return
        if (now >= deadline) {
          over = true
          // Exactly once per run. The host draws the results screen.
          sdk.singlePlayer.actions.gameOver({ score })
          return
        }
        requestAnimationFrame(tick)
      }

      el.addEventListener('pointerdown', () => {
        if (over) return
        score += 1
        el.textContent = String(score)
        sdk.hapticFeedback()
      })

      // The host asks for another run; reset state, never reload the page.
      sdk.onPlay(() => startRun())

      sdk.ready().then(() => startRun())
    </script>
  </body>
</html>
```

Three things make it a Remix game:

- `sdk.ready()` waits for the host handshake before the first run starts.
- `gameOver` is called exactly once when the run ends. The host scores the first call it sees.
- `onPlay` resets the run in place. A reload would lose the host connection.

#### 3. Upload It

Sign in to remix.gg, open the Studio and create a game with this file as its code. The Studio does not add the SDK, so first put the SDK script tag from [SDK Overview](/docs/sdk-overview#installation) in the file's `<head>`. Or upload from a terminal with an API key from your [API console](/api), which adds the tag for you:

```sh
curl -X POST "https://remix.gg/api/v1/games" \
  -H "Authorization: Bearer sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg code "$(cat index.html)" '{ name: "Tap Five", code: $code }')"
```

The response's `game.id` is the new game's id. Each later upload is a new version:

```sh
curl -X POST "https://remix.gg/api/v1/games/GAME_ID/versions" \
  -H "Authorization: Bearer sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg code "$(cat index.html)" '{ code: $code }')"
```

#### 4. Test and Go Live

Open the game in the Studio. The preview runs your latest version inside the real player frame, so `ready`, `gameOver` and `play` behave the way they will in production. Play it once yourself, then launch it from the game page. A launch needs a name and an icon, and the version must pass an automated review first.

#### Next Steps

- Add a [named leaderboard](/docs/leaderboards) for a second thing worth ranking.
- Keep lifetime numbers in a [save](/docs/saving-progress) and unlock a few [achievements](/docs/achievements) when they cross a milestone.
- Read [Publishing](/docs/publishing) for what an upload does and what a launch needs.

### Project Structure

Source: https://remix.gg/docs/project-structure

The flat game folder Remix Desktop and the CLI work with, and the fields of remix.json.

A Remix project is a flat folder. There is no `package.json`, no `node_modules`, no bundler config and no vendored engine. The platform owns preview and compilation; the folder holds source, assets and agent context.

#### The Folder

```text
game/
├── index.html              # the document the player frame loads
├── game-main.ts            # root entry module (Three.js projects)
├── remix.ts                # the game's own link to the Remix host
├── systems/
├── objects/
├── ui/
├── remix.json              # the project's declaration
├── remix-three.d.ts        # editor-only types entry point
├── tsconfig.json
├── assets/                 # images, models, audio, achievement icons
├── AGENTS.md               # context for coding agents
├── CLAUDE.md -> AGENTS.md
└── .remix/
    ├── panels/             # optional creator tools, self-contained HTML
    └── types/              # generated platform declarations
```

A folder is a Remix project when it has `remix.json`, `index.html` and `game-main.ts`. Remix Desktop gives an imported single-file HTML game this shape, and a single-file game you upload through the Studio or the HTTP API needs no folder at all.

Reference media with literal paths such as `'assets/ship.png'`. Publish scans the built code for those strings and hosts each file; a path assembled at runtime is invisible to that scan and returns a 404 in production while working in preview.

#### remix.json

`remix.json` is the file the platform reads. Everything a game declares about itself lives here and is sent with every version.

```jsonc
{
  "name": "Harbor Sprint",
  "gameId": "2fd7f6fb-…",          // written on create; never edit
  "presentation": {
    "target": "mobile",            // "mobile" | "desktop"
    "orientation": "portrait",     // "portrait" | "landscape"
    "preferredAspectRatio": "16:9",// desktop only
    "input": ["touch"],            // "touch" | "keyboard" | "pointer"
    "compatibility": ["MOBILE"]    // optional; see below
  },
  "lifecycle": "scored",           // "scored" | "levels" | "custom" | "portal"
  "levelCount": 12,                // levels games only, 5 to 99
  "leaderboards": [ /* see Leaderboards */ ],
  "achievements": [ /* see Achievements */ ]
}
```

| Field | Meaning |
| --- | --- |
| `name` | The display name, 5 to 25 characters and unique for your account. |
| `gameId` | The platform id, written by `remix create` or your first publish. Do not change it. |
| `presentation` | Which surface the game targets and how it is played. Omitted means mobile, portrait, touch. |
| `lifecycle` | The game type. `scored` (Arcade) calls `gameOver` with a score for the Remix leaderboard, `levels` reports an attempt per level, `custom` is any other style of game: it runs its own flow and never calls `gameOver`, and `portal` is a portal world. For a desktop game, Remix reads the type from the build each time it launches: a Custom game becomes Arcade or Levels by launching a build that uses that SDK, and an Arcade game whose build stops calling `gameOver` becomes Custom. Only Arcade and Levels games get Remix's built-in achievements (First Play and trophies); a Custom game declares its own. `open-ended`, Custom's former name, still works. |
| `levelCount` | Required with `lifecycle: "levels"`. The authored level total, 5 to 99. A launched game's count can grow but never shrink. |
| `leaderboards` | Named boards beside the main score. See [Leaderboards](/docs/leaderboards). |
| `achievements` | Declared achievements and their icons; the game unlocks them. See [Achievements](/docs/achievements). |

##### Presentation and Compatibility

`presentation.target` decides where the game is listed. A mobile game plays on phones and on every other surface. A desktop game plays on Remix Desktop and on wide web windows, and is shown as a preview on phones. Games that predate the field are treated as mobile.

`presentation.compatibility` is optional and names the platforms directly: `["MOBILE", "DESKTOP"]` lists a game on every surface, and `["LANDSCAPE"]` marks a phone game played sideways, which `orientation` alone does not do. A game takes `MOBILE` or `LANDSCAPE`, never both. When you launch from Remix Desktop, the platforms you choose there apply instead.

The design box follows the target: portrait phone games compose for 720 by 1080, sideways phone games for 1080 by 720, and desktop games for a 16:9 frame of 1280 by 720. The real viewport is any size and can change mid-session, so read the viewport when you lay out, never once at boot.

#### AGENTS.md

`AGENTS.md` is read by coding agents working in the folder. The scaffolded one explains the host contract, the lifecycle rules and where to put things. Keep it current when you change how the project is organised; it is the cheapest way to make the next agent session productive.

### Publishing

Source: https://remix.gg/docs/publishing

What happens to an upload before it goes live, how versions and launches work, and how to keep the checks green.

Publishing has two halves. An **upload** creates a version and resets its checks. A **launch** makes a checked version the live one. Uploads are cheap and frequent; launching is a deliberate step you take after playing the version yourself.

#### What an Upload Does

Remix Desktop and the CLI publish a project folder in five steps. An upload through the HTTP API sends a finished document and starts at step 3:

1. **Compile.** A project with `game-main.ts` is compiled into one HTML document. Imports of `three` and `@remix-gg/three` are mapped to the pinned browser engine; nothing is inlined. A single-file game is taken as is.
2. **Host assets.** Every literal `assets/<path>` reference in the built code is uploaded and rewritten to its hosted URL. Achievement icons declared in `remix.json` are hosted the same way.
3. **Inject the SDK.** The platform adds the Remix SDK script tag, or replaces the one the document already has. The Studio on remix.gg saves a pasted document as written, so a game made there includes the tag itself. Never bundle a second copy into your code: two copies run the handshake twice.
4. **Sync declarations.** `leaderboards` and `achievements` from `remix.json` are created or updated on the platform right away, so they apply to a live game before you launch the new version. The response lists what was created, what changed and any warnings.
5. **Reset checks.** Every upload clears the version's checks. The automated review runs again when you launch it.

Uploads overwrite the latest unpublished version. If the latest version has been launched, the upload creates a new draft instead.

#### Declaration Warnings

Boards and achievements are declared once. A board's `sort`, `operator` and `reset` are fixed by the first upload that declares it: changing one of those on a later upload is ignored with a warning, and the name and metadata still update. An achievement's name, description, icon and `hidden` can change on any upload. Declaring more than your game's limit is refused with a warning naming the cap. Read the warnings in the publish output.

#### Launching

Launch a version from the game page in the Studio, from Remix Desktop, or with `remix publish --launch`. The version must pass an automated review, which runs as part of the launch. The Studio and Remix Desktop also ask for a name and an icon, and Remix Desktop asks for a gameplay trailer for each platform you choose. Launching sets the version live on every surface at once. Play it first: passing review does not make a game fun.

Level games lock their mode on first launch. The level count can grow on later versions but cannot shrink.

#### Where a Game Appears

Which surfaces list a game follows its `presentation` in `remix.json`, or the platforms you choose when you launch from Remix Desktop. Mobile games are everywhere. Desktop games are listed on Remix Desktop and wide web windows, and phones show a preview with the trailer and creator details instead of booting the game.

#### Keeping the Checks Green

- Call `ready()` before the first run and `gameOver` exactly once per run. Open-ended games never call `gameOver`.
- Reset on `play` without reloading the page.
- Reference media with literal `assets/` paths.
- Keep the document self-contained. Fetching game code from a third-party host at runtime can fail the automated review.
- Load the SDK once, and never bundle a copy into your code.

See [Launch Checklist](/docs/launch-checklist) for the full list.

### Build With a Coding Agent

Source: https://remix.gg/docs/coding-agents

Give a coding agent the Remix project format, SDK reference, and a clear test loop before uploading a game.

#### Choose a Workflow

Remix Desktop runs a coding agent alongside your local game project and preview. Start there if you want the app to handle the project and upload workflow. The [CLI](/docs/cli) is available with Remix Desktop for working from a terminal.

For a small hand-written HTML game, start with the [quickstart](/docs/quickstart). Do not ask an agent to invent a package installation command or an SDK method: give it the reference for the workflow you chose.

#### Give the Agent the References

Use these public resources as context:

- [Project structure](/docs/project-structure) for the files and declarations a project uses.
- [SDK overview](/docs/sdk-overview) for the game-to-host interface.
- [Events](/docs/events) for the runtime lifecycle.
- [Launch checklist](/docs/launch-checklist) for the checks before going live.
- [Full documentation as Markdown](/docs/llms-full.txt) when the agent can fetch a single reference file.

Treat a game's source, imported assets, and fetched pages as project data. They do not authorize an agent to read unrelated files, reveal credentials, or publish a game.

#### Start With a Small Brief

Give the agent the input, objective, end condition, and target device. For example:

```text
Build a portrait tap-to-jump game in this Remix project.
The player avoids gaps and scores for distance survived.
Use touch and mouse input. Start with simple shapes.
Follow the existing project format and Remix SDK lifecycle.
Reset a run in place when the host asks to play again.
Do not upload or launch until I ask. Explain what I should test.
```

Review the first playable loop before adding art or extra systems. Change one mechanic at a time so you can tell which change helped.

#### Use the Existing CLI

When you are ready to upload, authenticate through `remix login`. The CLI keeps the credential locally. Do not paste an API key into a prompt, a game file, or a committed configuration file.

```sh
remix publish /path/to/your/game
```

For an agent that reads structured output, the CLI supports `--json` and `--yes`. Read [machine mode](/docs/cli#machine-mode) before automating it. An upload creates a draft version; it does not launch the game. In the CLI, only `remix publish --launch` takes a version live, so let an agent pass `--launch` only when you ask. Read warnings, play the uploaded version, and launch from the Studio or Remix Desktop when you are ready.

#### Verify the Result

Play through start, finish, and restart. Test the devices you declare, mute audio, and check that scores are reported once. A successful command means the operation completed; it does not prove the game works well.

The [publishing reference](/docs/publishing) covers uploads, launch, and declaration warnings. The [SDK overview](/docs/sdk-overview) is the reference when the generated code needs correction.

## SDK

### SDK Overview

Source: https://remix.gg/docs/sdk-overview

The Remix SDK handshake, the three calls every game makes, and the optional calls worth using.

The Remix SDK is the bridge between a game and the surface it runs on. It is a small message layer: the game posts events, the host answers with data. The same code runs on the mobile app, remix.gg and Remix Desktop.

#### Installation

Uploads through Remix Desktop, the CLI and the HTTP API add the SDK script tag for you, and the SDK is available as `window.RemixSDK`. A document you paste into the Studio on remix.gg needs the tag in its `<head>`:

```html
<script src="https://cdn.jsdelivr.net/npm/@remix-gg/sdk@latest/dist/index.min.js"></script>
```

Nothing needs to be installed to ship. Load the SDK once: a copy bundled into your own code runs the handshake twice.

For editor types, install the package:

```sh
npm install @remix-gg/sdk
```

```ts
import type { RemixSDK, GameInfo, Player } from '@remix-gg/sdk'

const sdk: RemixSDK = window.RemixSDK
```

A project that Remix Desktop or the CLI scaffolds reaches the same surface through its own `remix.ts`. A game built on `@remix-gg/three` reaches it through `game.platform`, which `createGame` hands it already connected. See [Three.js](/docs/three-overview).

#### The Three Obligations

A game that does not do all three is not finished, whatever it looks like.

1. **Wait for the handshake.** `await sdk.ready()` resolves with the `GameInfo` the host sent: the player, the safe area, any saved state, and the game's declared achievements. Never start a run before it resolves.
2. **Call `gameOver` exactly once per run.** The host scores the first call and draws the results screen. Guard your update loop so nothing scores or spawns afterwards.
3. **Handle `play`.** The host calls it for a replay and, in level games, with the level to start. Reset the run in place. A page reload drops the host connection.

```ts
const sdk = window.RemixSDK

sdk.onPlay((data) => startRun(data?.levelIndex))
sdk.onToggleMute(({ isMuted }) => audio.muted = isMuted)

const info = await sdk.ready()
startRun(info.levelBased?.progress.currentLevelIndex)

function endRun(score: number) {
  sdk.singlePlayer.actions.gameOver({ score })
}
```

#### Optional Calls Worth Using

- `sdk.hapticFeedback(type?)` buzzes the device on `'light' | 'medium' | 'hard' | 'success' | 'error'`. A tap that buzzes feels twice as responsive. It is a no-op where there is nothing to buzz.
- `sdk.singlePlayer.actions.saveGameState({ gameState })` persists progress between sessions. See [Saving Progress](/docs/saving-progress).
- `sdk.leaderboards`, `sdk.achievements` and `sdk.saves` are the platform's progression surfaces. Each resolves `null` instead of rejecting when a host cannot answer, so a game runs unchanged everywhere.

#### What the Host Does for You

The host owns mute, the results screen, play-again, level selection and best stars, the achievement popup, the main score's leaderboard and the game page. A game that draws its own results screen or its own main-score leaderboard fights the platform on every surface. Report, then wait for `play`. Named leaderboards are the exception: the platform ranks them, and your game draws them from `sdk.leaderboards`.

#### Reference

- [Game Info and Players](/docs/game-info)
- [Game Over and Levels](/docs/game-over-and-levels)
- [Events](/docs/events)
- [Purchases](/docs/purchases)
- The raw SDK README is served as markdown at [/docs/llms.txt](/docs/llms.txt).

### Game Info and Players

Source: https://remix.gg/docs/game-info

What sdk.ready() resolves with, and the getters that read the player, the safe area and the view context.

`sdk.ready()` resolves with a `GameInfo` object. It is the host telling the game who is playing and where. The same data is available afterwards through getters on the SDK.

#### GameInfo

```ts
type GameInfo = {
  player: Player
  players: Player[]
  viewContext: 'feed' | 'full_screen' | 'challenge' | 'tournament'
  contentSafeAreaInset: { top: number; right: number; bottom: number; left: number }
  contentLayout?: ContentLayout
  initialGameState: { id: string; gameState: Record<string, unknown> } | null
  usersTurnId?: string | null
  levelBased?: { levelCount: number; progress: LevelProgressState }
  achievements?: AchievementsSnapshot
  shopItems?: ShopItem[]
}
```

| Field | Meaning |
| --- | --- |
| `player` | The local player: `id`, `name`, `imageUrl`, `avatarTraits` and `purchasedItems`. |
| `players` | Everyone in the session. One entry in single player; every seat in a challenge. |
| `viewContext` | Where the game is showing. `feed` means it is one card in a scrolling feed and should be instantly legible, not a tutorial. |
| `contentSafeAreaInset` | Space at each edge, in CSS pixels, that device cutouts and the host's own controls cover. Keep anything the player must see or tap inside it. |
| `contentLayout` | A finer layout, where the host sends one: the device safe area plus each host control and cutout as a rectangle. Read it through `sdk.contentLayout`, which falls back to `contentSafeAreaInset`, and test a HUD or touch target with `sdk.isUiRectSafe({ x, y, width, height })`. |
| `initialGameState` | The last `saveGameState` payload, or `null`. See [Saving Progress](/docs/saving-progress). |
| `usersTurnId` | In turn-based challenges, the player the platform is waiting on. Trust it over your own state. |
| `levelBased` | Present only when the platform runs this session as a level game. See [Game Over and Levels](/docs/game-over-and-levels). |
| `achievements` | Declared achievements with the keys this player has earned. Read them through `sdk.achievements`. |

#### Getters

After `ready()` resolves, the same data is one property away:

```ts
sdk.isReady          // boolean
sdk.player           // Player | undefined
sdk.players          // Player[] | undefined
sdk.gameInfo         // GameInfo | undefined
sdk.gameState        // the saved state; null or undefined when there is none
sdk.contentLayout    // ContentLayout, or one built from the safe area
sdk.purchasedItems   // string[]
sdk.inventory        // { slug, quantity }[]
```

Level games also get `sdk.isLevelBased`, `sdk.levelCount`, `sdk.currentLevelIndex`, `sdk.levelStars`, `sdk.highestUnlockedLevel` and `sdk.totalStars`.

#### Avatars

`player.imageUrl` is a hosted image, which may be an SVG, or absent. Render it with an `<img>` and a letter fallback; do not feed it to a texture loader that expects a bitmap.

To draw the player as a character, read `player.avatarTraits`: their equipped Remix avatar as trait data (body, colors, clothing, face and hair). Every entry in `players` carries its own. It is absent for players without a Remix avatar, so keep a fallback look.

#### Guests

A signed-out player can still play. `player` is a guest record, and every write that needs an account (leaderboards, saves, achievements) resolves `null`. Show nothing extra on `null` and never block gameplay waiting on an answer.

### Game Over and Levels

Source: https://remix.gg/docs/game-over-and-levels

Reporting a run's score, and how level-based games report attempts while the host owns progression.

Every scored run ends with one call. The host takes it from there: results screen, best score, crowns, achievements that ride the score, and the play-again button that brings the player back to you.

#### Scored Games

```ts
sdk.singlePlayer.actions.gameOver({ score: finalScore })
```

Call it once, when the run is over, then freeze. The host scores only the first call of a run, so a stray early call, such as one from a death animation's callback, records a stale score and the real one is ignored. Guard your update loop on a `running` flag so nothing scores or spawns afterwards.

Then wait. `sdk.onPlay` fires when the player wants another run. Reset your run state in place; everything built at boot survives.

Custom games (`lifecycle: "custom"` in `remix.json`) never call `gameOver`: they run their own flow and endings. Only Arcade and Levels games get Remix's built-in achievements; a Custom game declares its own.

#### Level Games

Set both fields in `remix.json`, with the real authored count:

```jsonc
{ "lifecycle": "levels", "levelCount": 16 }
```

At runtime, level mode is on only when `gameInfo.levelBased` is present. The platform can turn it off, so key level behaviour off that block, never off your own level content.

The host owns progression. It stores unlocks and best stars, draws the level select and the results, and picks which level plays next. The game only does three things:

**Start from the host's level.** On boot, read `levelBased.progress.currentLevelIndex` (1-based) and build that level.

**Report every attempt.**

```ts
sdk.singlePlayer.actions.gameOver({
  score: 0,                      // ignored for level games
  levelAttempt: {
    levelIndex,                  // 1-based level just played
    stars,                       // 0 = failed, 1 | 2 | 3 = completed
  },
})
```

**Honour the host's restart.**

```ts
sdk.onPlay((data) => {
  const level = data?.levelIndex ?? currentLevelIndex   // omitted data is a replay
  buildLevel(level)
})
```

Never advance the level yourself, never save unlocks or stars in game state, and never treat the boot index as a new selection. The host records the first report of each attempt and ignores repeats.

Read progression through the getters when you need it for a HUD: `sdk.levelStars`, `sdk.highestUnlockedLevel`, `sdk.totalStars`.

A launched level game's count can grow on later versions but cannot shrink, and its mode is locked once launched.

### Saving Progress

Source: https://remix.gg/docs/saving-progress

The autosave blob, what belongs in it, and where local-only data should live instead.

Every game gets one autosave document per player. It comes back on the next boot, on any device, and needs no declaration.

#### Autosave

```ts
// Persist. Call it when something worth keeping changes, not every frame.
sdk.singlePlayer.actions.saveGameState({ gameState: { unlockedShips, bestCombo } })

// Read it back on the next boot.
const info = await sdk.ready()
const saved = info.initialGameState?.gameState ?? {}
// or, after ready(): sdk.gameState
```

The shape is yours, up to 1 MiB of JSON. Version it: put a `v` field in the object and migrate old shapes on read, because a player's save can be older than your latest upload.

#### What Belongs in It

Progress a player would be upset to lose: unlocks, settings, a campaign position, lifetime counters such as total kills. Not run state, and not anything the platform already owns:

- Level unlocks and best stars are stored by the host from your `levelAttempt` reports.
- Best scores ride `gameOver`.
- A best worth ranking belongs on a [leaderboard](/docs/leaderboards), and a milestone worth showing off is an [achievement](/docs/achievements) your game unlocks.

#### More than One Document

Games that need several save files per player, or a run in progress beside a settings profile, use named [cloud save slots](/docs/cloud-saves). Autosave stays the default; slots are the upgrade.

#### Local-Only Data

Replays, ghosts and large binary records do not belong in the autosave. Use `localStorage` for small values and IndexedDB for large ones. Browser storage stays on one device and surface, is not always separate per account, and is not cloud-synced; treat it as a cache and handle it being empty.

#### Guests

Signed-out players have no saved state: `initialGameState` is `null` and saves are dropped. Read "nothing saved" and "guest" the same way.

### Events

Source: https://remix.gg/docs/events

Every host event the SDK exposes, when it fires, and what to do in the handler.

The SDK exposes typed listener methods. Register them early, before `ready()`, so no host message is missed. The top-level `sdk.on` methods return nothing; `sdk.achievements.onUnlocked`, `sdk.realtime.onRoom` and `sdk.realtime.onRoomError` return a function that removes the listener.

#### Lifecycle

##### `sdk.onPlay(callback)`

The host starts play: a replay after game over, or a host-directed level start in level games. `callback(data?)` receives `{ levelIndex?: number }` (1-based); a plain restart omits it. Reset the run in place.

`sdk.onPlayAgain` is an alias.

##### `sdk.onToggleMute(callback)`

The player toggled sound in the host chrome. `callback({ isMuted })`. Games must not draw their own mute button.

##### `sdk.onGameInfo(callback)`

A fresh `GameInfo` arrived. `ready()` already resolves with the first one, so most games never need this. It is where a changed safe area or content layout, or an updated player record, would show up.

#### Multiplayer

##### `sdk.onGameStateUpdated(callback)`

Another player pushed shared state in a turn-based challenge. `callback({ id, gameState })`, or `callback(null)` when state is cleared. Validate it and call `sdk.multiplayer.actions.refuteGameState({ gameStateId: id })` if it is invalid. See [Multiplayer](/docs/multiplayer).

##### `sdk.realtime.onRoom(callback)` and `sdk.realtime.onRoomError(callback)`

A realtime room became live, including one the platform joined for the player from an invite, or such a join failed. See [Multiplayer](/docs/multiplayer).

#### Purchases

##### `sdk.onPurchaseComplete(callback)`

A purchase finished, whether your game started it or the player bought from the host chrome. `callback({ success, item? })`. Re-check ownership with `sdk.hasItem`. See [Purchases](/docs/purchases).

#### Progression

##### `sdk.achievements.onUnlocked(callback)`

One or more achievements were earned, from an `unlock()` call or a refreshed snapshot. `callback(keys)`, each key once. The host already shows the popup; use this for in-game celebration.

#### A Complete Registration

```ts
const sdk = window.RemixSDK

sdk.onPlay((data) => startRun(data?.levelIndex))
sdk.onToggleMute(({ isMuted }) => (audio.muted = isMuted))
sdk.onPurchaseComplete(() => refreshUnlocks())
sdk.achievements.onUnlocked((keys) => celebrate(keys))

await sdk.ready()
```

### Purchases

Source: https://remix.gg/docs/purchases

Selling in-game items through the platform, checking ownership, and gating rewards correctly.

Items are configured in the Store on the game page in the Studio, each with a slug and a price in Bits. An item is one-time (the player keeps it) or consumable (bought again for each use), and new items go on sale the next time you launch the game. The platform owns the purchase flow, the player's Bits and the record of what they own; the game only asks whether the player owns an item and starts a purchase when they want one.

#### Checking Ownership

```ts
await sdk.ready()

sdk.hasItem('super-skin')              // boolean
sdk.getItemPurchaseCount('super-skin') // number
sdk.purchasedItems                     // string[]
sdk.inventory                          // { slug, quantity }[]
```

Ownership comes from the player record the host sends at boot and may refresh during play. A successful purchase adds the item right away, so re-check after `purchase()` resolves or in `onPurchaseComplete`.

The record keeps the items a player owns for good. Consumables do not stay in it: the next boot does not list them, and a count can drop back when the host refreshes the record. Grant a consumable's effect when its purchase succeeds, and keep what it gave (an extra life, a coin pack) in your own save if it should last.

#### Starting a Purchase

```ts
async function buy(slug: string) {
  const { success } = await sdk.purchase({ item: slug })
  if (success) refreshUnlocks()
}
```

`purchase()` opens the platform's purchase sheet and resolves when it closes. Gate the reward on `success`, not on the call having been made. It resolves `{ success: false }` when the player cancels or the platform cannot sell the item, such as a slug that is not on sale. Start one purchase at a time: a second call before the first resolves leaves the first unanswered.

Players can also buy from the host chrome without the game asking. `sdk.onPurchaseComplete(({ success, item }) => …)` fires either way, so keep ownership checks in one function and call it from both places.

#### Shop Items

`sdk.shopItems` and `sdk.getShopItem(slug)` describe the items the game page offers, when the host supplies them. Each has a `slug` and `name`, plus optional `itemType`, `bitsCost` (the price in Bits), `description` and `iconUrl`. Treat an empty list as "nothing to sell here" rather than an error; not every surface carries the catalogue.

#### Multiplayer

`sdk.multiplayer.actions.purchase({ item })` is the same call for challenge sessions.

#### What not to do

- Do not build a price list or currency in the game. Prices live on the platform, in Bits.
- Do not unlock on `purchase()` returning; unlock on `success`.
- Do not cache one-time ownership in game state. The host's record is the source of truth on every boot.

## Platform Features

### Leaderboards

Source: https://remix.gg/docs/leaderboards

Declare a best-score or fastest-time board, submit records with the SDK, and read the standings.

Every game has a main score board fed by `gameOver`. Named leaderboards are for everything else worth ranking, such as a best time per stage. Start with an all-time board using the default `best` operator. They are declared in `remix.json` and synced on publish. Remix keeps and ranks the records; your game reads the standings and draws them.

#### Declaring Boards

```jsonc
{
  "leaderboards": [
    {
      "key": "stage-1",
      "name": "Harbor Sprint",
      "sort": "asc",
      "operator": "best",
      "metadata": { "format": "time_ms" }
    }
  ]
}
```

| Field | Values | Meaning |
| --- | --- | --- |
| `key` | slug | Stable id the game submits to. |
| `name` | text | Display name, returned with the standings. May change later. |
| `sort` | `"desc"` (default), `"asc"` | `asc` ranks lower values higher. Submit raw milliseconds for times; never invert. |
| `operator` | `"best"` (default) | Keeps the player's best score. |
| `metadata` | object | Free-form display hints such as `format`. May change later. |

`sort`, `operator` and `reset` are immutable once a board ships. A changed value is ignored with a publish warning; only `name` and `metadata` update. An undeclared key is refused at runtime, never auto-created.

#### Submitting a Record

```ts
// One id per action; keep it for retries of that action.
const submissionId = sdk.leaderboards.createSubmissionId()

const result = await sdk.leaderboards.writeRecord({
  leaderboard: 'stage-1',
  score: Math.round(lapSeconds * 1000),
  submissionId,
})

if (result?.ok) {
  showBanner(`Best time: ${result.score} ms`)
}
```

`writeRecord` resolves `{ ok: true, score, subscore, numScore }` with the value after the operator applied, so a `best` board answers the previous best when the new value did not beat it. A write never ranks; read the board for a position. A refusal is `{ ok: false, code, reason }`, where `code` is one of:

- `invalid`: a value that is not a safe integer, or an expired submission id.
- `not_found`: a board key your `remix.json` does not declare.
- `conflict`: a submission id already used with different values.
- `throttled`: too many writes; the reason says when to retry.
- `unavailable`: the platform could not answer; retry with the same submission id.

Three rules, each load-bearing:

- **It does not end the run.** Submit at the moment it happens, a stage finish mid-run is fine, and still call `gameOver` once per run. The main score, achievements and crowns ride `gameOver`, never a board.
- **Every call may resolve `null`** for guests and hosts that predate boards. Show nothing extra and never block gameplay or a results screen on it.
- **Integers only.** Milliseconds for times. Unsafe integers and overflow are refused, never rounded.

##### Submission IDs

A `submissionId` names one logical action. Reuse it only to retry that action with identical values, for up to seven days. The SDK generates one when omitted, but then every call is a separate submission. An id reused with different values is refused, an expired id is refused rather than reapplied, and an expired action must not be given a fresh id.

#### Reading Standings

```ts
const top = await sdk.leaderboards.listRecords({ leaderboard: 'stage-1', limit: 10 })
```

`listRecords` resolves `{ leaderboard, records, ownerRank, rankingUpdatedAt }` where a record is `{ rank, score, subscore, username, imageUrl, isOwner }`. `limit` defaults to 50 and is at most 100; around the player it defaults to 5 and is at most 50. Draw `imageUrl` with an `<img>` and a letter fallback, since it can be `null` or an image the browser cannot load.

- `listRecords` is the top of the current window. `ownerRank` is exact only when the player's row is on the returned page, otherwise `null`.
- `listRecordsAroundOwner` is a window centred on the player, with an exact `ownerRank`. Use it for "you versus your neighbours".
- `listFriendRecords` ranks the player and everyone they follow among themselves; `ownerRank` is their place in that set. Nearby-player and friends views are optional; a basic board only needs `listRecords`.

Every page is one read: each `rank` is the record's exact position when the page was read, and `rankingUpdatedAt` is that read's time. The top page can trail a new record by about ten seconds. Read on a user action such as opening a standings screen, not on a timer; a game that polls is throttled on its own boards.

#### Existing Integrations

Other operators (`set`, `incr`, `decr`), scheduled resets, nearby-player queries and friends queries remain supported. Keep an existing board's configuration when updating a game. New boards can use `best` without a reset schedule or custom ranking metadata beyond a display format.

#### Limits

Each game can declare a limited number of boards, and never more than 32. A declaration past your game's limit is refused at publish with a warning that states the limit. See [Limits](/docs/limits).

### Achievements

Source: https://remix.gg/docs/achievements

Declare a handful of achievements and unlock them from your game when the player earns one.

Platform achievements are a curated set of accomplishments worth showing outside your game and sharing with other players. The platform lists them on the game page and shows a popup the moment one is earned. Your game decides when each one is earned. Earned once, for good.

#### Choosing Milestones

Your game can have a much larger internal achievement system. Keep its full checklist, and the numbers behind it, in [cloud saves](/docs/cloud-saves), and select a handful of memorable accomplishments for Remix: a major milestone, a difficult challenge, or a surprising feat. A clicker with hundreds of internal badges does not need to publish every badge, upgrade or repeated milestone.

Each kind of player data has one home:

- Lifetime counters, content unlocks and settings live in the save.
- Anything players compete on, such as a best combo or a fastest lap, is a [leaderboard](/docs/leaderboards).
- Anything worth showing off is an achievement your game unlocks.

The declaration limit is a ceiling, not a target. Keep existing published achievements and earned progress when updating a game.

#### Declaring Achievements

```jsonc
{
  "achievements": [
    {
      "key": "first-blood",
      "name": "First Blood",
      "description": "Land your first kill.",
      "icon": "assets/achievements/first-blood.png"
    },
    {
      "key": "centurion",
      "name": "Centurion",
      "description": "One hundred kills.",
      "hidden": true
    }
  ]
}
```

| Field | Meaning |
| --- | --- |
| `key` | Stable slug. Your game unlocks the achievement by this key. |
| `name` | A short, catchy title of at most 24 characters, like "Harbor Sprint" or "Untouchable". Shown on the game page and in the popup. |
| `description` | What the player did to earn it. Shown under the name. |
| `icon` | A square PNG or WebP under `assets/`. Publish hosts it with the game and the platform shows it instead of the default badge. Subfolders are fine. |
| `hidden` | Keeps the achievement off the game page until earned. |

#### Unlocking from the Game

Keep the numbers behind an achievement in your save, and call `unlock` when they cross the line:

```ts
await sdk.ready()

save.totalKills += 1
sdk.singlePlayer.actions.saveGameState({ gameState: save })
if (save.totalKills >= 1) sdk.achievements.unlock('first-blood')
if (save.totalKills >= 100) sdk.achievements.unlock('centurion')

sdk.achievements.isUnlocked('first-blood') // synchronous cached state
sdk.achievements.onUnlocked((keys) => celebrate(keys)) // newly earned while the game runs
```

Remix shows each achievement as locked or earned. To show progress toward one, such as 37 of 100 kills, read the counter from your save and draw it in your game.

`unlock(key)` resolves one of:

- `{ ok: true, key, unlocked, unlockedAt }`. `unlocked` is `false` when the player had already earned it, so calling `unlock` again is harmless. Unlocking on boot for a save that already qualifies is fine.
- `{ ok: false, code, reason }` when refused: `not_found` for a key your `remix.json` does not declare, `throttled` for too many unlocks, or `unavailable` when the platform cannot record it now (retry later).
- `null` for guests and hosts without achievements. Never block play on the answer.

`onUnlocked` receives each key newly earned while the game runs, once: from an `unlock` call that earned it, or a `refresh()` that finds one earned on another device. Achievements the player already had at boot are never announced. The host already shows the platform popup; use the callback for in-game celebration, not a second popup.

#### An In-Game Achievements Screen

Draw your own screen from the same reads. `list()` answers every declared achievement, hidden ones included, and `unlocked()` answers the earned keys with their unlock time:

```ts
const earned = sdk.achievements.unlocked() // { [key]: ISO time }

for (const achievement of sdk.achievements.list()) {
  const at = earned[achievement.key]
  if (achievement.hidden && !at) {
    addRow({ name: 'Secret', locked: true })
  } else {
    addRow({
      name: achievement.name,
      description: achievement.description,
      icon: achievement.icon, // hosted URL, or null for your own default badge
      locked: !at,
    })
  }
}
```

Call `sdk.achievements.refresh()` when the screen opens if the player may have earned one on another device; it resolves the latest snapshot, or `null`.

#### Icons

Icons make achievements feel earned. Keep them square, at least 256 by 256, on a background that reads at 44 pixels. In Remix Desktop, the agent can propose a matching icon set for every declared achievement in the game's art style; you generate and save each one from the review card. Saved icons land under `assets/achievements/` and are picked up on the next publish.

#### Where Players See Them

- The game page on the mobile app, remix.gg and Remix Desktop lists every visible achievement with its icon and earned state.
- Library cards on Remix Desktop summarise earned counts.
- A popup appears on the surface the player is on the moment an achievement is earned.

Game achievements carry no XP and no trophy tier; they are the game's own.

#### Limits

Each game can declare a limited number of achievements, and never more than 64. Remix Desktop shows your game's limit on its Details page, and a declaration past it is refused at publish with a warning. See [Limits](/docs/limits).

### Cloud Saves

Source: https://remix.gg/docs/cloud-saves

Named save slots for games that need more than one document per player, synced across devices with conflict detection.

`saveGameState` is the one autosave document and stays the default. Save slots are for games that need more than one: three save files, a run in progress beside a settings profile. Nothing is declared; a slot exists once it is written.

#### Reading and Writing

```ts
const slot = await sdk.saves.get('main')            // SaveSlot | null

const result = await sdk.saves.put({
  slot: 'main',
  data: { level, inventory },
  ...(slot ? { ifMatch: slot.etag } : { ifNoneMatch: '*' }), // protect existing and first saves
})

if (result && !result.ok && result.conflict) {
  // result.conflict is the slot as it is now: merge, or ask the player
}

const slots = await sdk.saves.list()      // up to 100 named slots; use storage.list for larger collections
await sdk.saves.delete({ slot: 'main' })
```

A `SaveSlot` is `{ slot, data, etag, updatedAt, bytes }`. A slot name is a stable slug you choose.

#### Conflicts

Pass the `etag` you read as `ifMatch` when you write. If another device saved in between, the write is refused with `{ ok: false, code: 'conflict' }` and `conflict` holding the current slot. Decide then: merge the two, keep the newer, or ask the player. Omit `ifMatch` only for data where last write wins is fine.

#### Null Means Nothing to Load

Every call resolves `null` for guests and on hosts without saves, and `get` also resolves `null` for an empty slot or one it cannot read. Read every `null` the same way. Never block a boot on a save answering.

#### Storage Limits

Each document is at most 256 KiB of JSON. Your game chooses how many slots each player uses; there is no slot-count cap. Storage usage includes autosaves, named saves, and files across all players, plus per-file metadata overhead. The Remix Desktop dashboard shows one total for your shared creator allowance across all your games.

A write that would grow total storage beyond that allowance returns `{ ok: false, code: 'quota', reason }`, preserving the existing save. Reads, deletions, and writes that keep or reduce storage usage remain available even if the allowance decreases. Slots hold the player's own data only. Never store other players' results or anything the platform owns, such as level unlocks or best scores.

#### Save files

For most games, use JSON documents through `sdk.saves.get` and `sdk.saves.put`, or keep your existing `saveGameState` autosave. Save at checkpoints or after a run, rather than every frame. Cloud saves can hold your economy, upgrades, internal counters and full in-game achievement checklist. When a saved number crosses a milestone worth showing outside the game, unlock one of your declared [achievements](/docs/achievements); not every saved number or badge needs a platform declaration. Use the [leaderboard API](/docs/leaderboards) for anything players compete on.

Existing file integrations can continue using `sdk.storage.put/get/head/list/delete`. Files accept up to 1 MiB each; listings return up to 100 entries with a continuation cursor. `put` accepts bytes (any typed array or an `ArrayBuffer`) or a JSON object, and `get` returns bytes. Named slot `main` maps to `saves/main.json`; the autosave maps to `state/autosave.json`. Keep these paths as JSON. Files share the same allowance, with 16 KiB of metadata overhead per file. `sdk.saves.list()` resolves `null` past 100 slots; page through `sdk.storage.list({ prefix: 'saves/' })` instead.

Handle a refused write without clearing the last saved progress. `sdk.saves.put`, `sdk.saves.delete` and every `sdk.storage` call resolve `{ ok: true, ... }` or `{ ok: false, code, reason }` with `code` one of `conflict`, `quota`, `throttled`, `invalid` (a bad name or path, data that is not JSON-serializable, or a document over its size limit), or `unavailable`; guests and unavailable hosts return `null`. On a conflict, reread and merge or ask the player. On throttling, wait before retrying. A failed response may follow a successful write, so reread before retrying a conditional write.

### Multiplayer

Source: https://remix.gg/docs/multiplayer

Turn-based challenges through shared game state, and realtime rooms for live play with invites owned by the platform.

Remix has two multiplayer models. **Challenges** are turn-based: players take turns pushing a shared game state, and the platform notifies whoever is up. **Realtime rooms** seat up to 12 players for live play, and the platform handles the room, the invites and the connections.

#### Turn-Based Challenges

Challenges run in the Remix app on iOS and Android and on remix.gg. A challenge session has several `players` in `GameInfo` and a `usersTurnId` naming who the platform is waiting on. Trust it over your own inference; the platform rejects state saves from anyone else.

```ts
const info = await sdk.ready()
const me = info.player.id
const myTurn = info.usersTurnId === me
```

Push shared state after a move. The first id in `alertUserIds` takes the turn, and every id must belong to a player in the challenge:

```ts
sdk.multiplayer.actions.saveGameState({
  gameState: { moves },
  alertUserIds: [nextPlayerId],
})
```

Receive other players' moves, validate them, and refute anything invalid:

```ts
sdk.onGameStateUpdated((update) => {
  if (!update) return resetBoard()
  try {
    applyMoves(update.gameState.moves)
  } catch {
    sdk.multiplayer.actions.refuteGameState({ gameStateId: update.id })
  }
})
```

End the match with every player's score:

```ts
sdk.multiplayer.actions.gameOver({
  scores: [{ playerId: a, score: 1 }, { playerId: b, score: 0 }],
})
```

Use the player ids from `GameInfo`; they are the only ids the platform accepts.

#### Realtime Rooms

`sdk.realtime` gives your game rooms, players and messages, and the platform runs everything underneath. Rooms work on Remix Desktop, remix.gg and the Remix mobile app.

```ts
const room = await sdk.realtime.createRoom()        // take the first seat
// or
const room = await sdk.realtime.joinRoom(code)      // join by invite code

sdk.realtime.room   // the live room, or null
room.selfId
room.peers          // live roster, self excluded, in seat order
room.isHost         // this player owns the room
room.isAuthority    // this player's device runs the match

room.send({ t: 'start', at })                       // to everyone, reliable and ordered
room.send(snapshot, { reliable: false })            // per-frame state, never resent
room.sendTo(userId, { t: 'ready' })                 // to one player

room.onMessage((fromUserId, data) => handle(fromUserId, data))
room.onPeerJoin((peer) => addRacer(peer))
room.onPeerLeave((peer) => removeRacer(peer))
// reason: 'left' | 'expired' | 'offline' | 'closed' | 'kicked'
room.onEnded((reason) => showLobby(reason))
// transport trouble worth telling the player; the room keeps trying
room.onError((message) => toast(message))

room.invite()       // opens the platform's invite flow
room.kick(userId)   // owner only; that player's room ends with 'kicked'
room.leave()
```

A message is any JSON-serializable value, or an `ArrayBuffer` or typed array, which arrives as an `ArrayBuffer`. Sends are reliable and ordered by default, for lobby state, countdowns and results; pass `{ reliable: false }` for the frequent state stream, where a late packet is worth less than the next one. `send` and `sendTo` return `false` when the message was dropped for at least one player, and every room `on…` call returns a function that unsubscribes.

`room.code` is an invite credential, not something to show players. Call `room.invite()` from a player action and the platform shares the invite. It has no delivery result, so wait for `onPeerJoin` before saying a friend joined.

Mount the lobby from `sdk.realtime.onRoom`, not only from your own create or join call: accepting an invite boots the game already seated, and `onRoom` replays a room seated before you subscribed. `onRoomError` is where a failed platform join (full, expired, wrong game) surfaces.

`createRoom` and `joinRoom` reject with a `RealtimeRoomError` whose `code` is `room_not_found`, `room_full`, `game_mismatch` (an invite for another game), `no_game_id` (the game is not published yet) or `join_failed`. Rooms need a signed-in player; a guest's request fails with `join_failed` and the platform offers sign-in.

One live room at a time: a new create or join leaves the current room. A room seats up to 12 players, so a thirteenth join fails with `room_full`. Seat order (oldest first) is the deterministic tiebreak when peers disagree.

The previews in the Studio and Remix Desktop answer `createRoom` and `joinRoom` with a local room that has no other players, so you can build the lobby there. Play real rooms in the published game.

##### Authority

The room owner (`hostUserId`, `isHost`) runs the lobby and is the only player who can kick. When the owner leaves, the oldest seat takes over and `onHostChange` fires. The authority (`authorityId`, `isAuthority`) is the player whose device runs the match, usually someone else. Simulate on `isAuthority`, never on `isHost`.

Call `room.electAuthority()` from your lobby's Start action once everyone is seated. It resolves with the id of the best-connected player, as the SDK measures it, and every player hears `onAuthorityChange`; it rejects if the room ends or the vote does not settle. `authorityId` is `null` before the first election and while the SDK replaces an authority who left. Pause on `null`, and have the new authority resynchronize the match, because match state does not carry over. `room.quality(userId)` reports this device's round trip and loss to a player, for a connection indicator.

Route guest inputs to the authority with `sendTo`. Guests predict locally, the authority settles outcomes, snapshots flow back. Keep wall time, simulation ticks, input sequence and packet sequence separate, and never let a network stall stretch a physics step. A rules engine that runs identically on every peer is the foundation; the transport is the easy part.

There is no creator-owned server. Anything that needs a trusted referee runs on the authority's device, which is still a player's device.

## Three.js

### Three.js

Source: https://remix.gg/docs/three-overview

Build with @remix-gg/three, the wrapper that owns the renderer, viewport, loop, input and the platform handshake.

`@remix-gg/three` is a thin layer over Three.js for games on Remix. One call, `createGame`, creates the renderer with the right settings, sizes the viewport for the design box, runs a fixed-step loop, wires input, loads the asset manifest and completes the platform handshake before your `setup` runs. A game on the wrapper never constructs a `WebGLRenderer` itself.

The engine is not inlined into your build. Published games import a pinned browser engine by URL, so a published document stays small.

New projects from Remix Desktop or `remix create game` start from plain Three.js and a small `remix.ts` that connects the game to the host, without the wrapper. Games built on `@remix-gg/three` keep working, and this page covers them.

#### The Shape of a Game

```ts
import { createGame, portraitRig } from '@remix-gg/three'
import * as THREE from '@remix-gg/three/three'

const game = await createGame({
  design: { width: 720, height: 1080 },
  camera: portraitRig(),
  assets: {
    models: { hero: 'assets/hero.glb' },
    textures: { ground: 'assets/ground.png' },
    audio: { hit: 'assets/hit.mp3' },
  },

  setup(game) {
    const hero = game.assets.model('hero')
    game.scene.add(hero)
    // build everything once
  },

  restart(game, data) {
    // reset run state; honour data?.levelIndex in level games
  },

  update(game, step) {
    // fixed step in seconds; simulation only
  },

  render(game, dt, alpha) {
    // per frame; interpolate with alpha
  },
})
```

`createGame` resolves with a `RemixGame`:

| Member | What it is |
| --- | --- |
| `renderer`, `scene`, `camera`, `rig` | The Three.js objects and the camera rig you chose. |
| `viewport` | Design box, real size, pixel ratio and `safeRect`, the area guaranteed visible and outside the device's notch and home indicator. |
| `input` | Pointer input in design units, with raycast helpers against the scene. |
| `assets` | The loaded manifest: `model()`, `texture()`, `sound()`. |
| `audio` | A bus that already honours the host's mute. |
| `hud` | DOM overlay anchored inside the safe rect. |
| `platform` | The Remix SDK, already connected. `platform.info`, `platform.player`, `platform.leaderboards`, `platform.saves`, `platform.achievements`, `platform.realtime`. |
| `transient` | A disposal scope cleared on every restart. Register anything created after `setup`. |
| `gameOver(score, levelAttempt?)` | End the run. Latched to one call per run. |
| `restart(data?)`, `pause()`, `resume()`, `dispose()` | Lifecycle controls. |

#### The Ten Rules

1. Import Three.js from `'three'` or `'@remix-gg/three/three'`, which load the same pinned engine, and addons from `'three/addons/…'`. A game installs nothing, so never add a `package.json`.
2. All setup goes through `createGame`. Never construct a `WebGLRenderer`.
3. Reference assets with literal strings such as `'assets/hero.glb'`. Publish finds them by scanning the built code; a path built at runtime is not hosted.
4. Call `game.gameOver(score, levelAttempt?)` exactly once per run. Level games pass `{ levelIndex, stars }`.
5. Implement `restart`. Play-again is already wired to it, it will be called many times in one session, and it must reset state rather than reload the page. Keep `data.levelIndex` intact.
6. Design at the design box, but read `game.viewport.safeRect` for anything the player must see or tap. Backgrounds may bleed.
7. Pointer only on mobile targets. No keyboard, no hover.
8. Register anything created after `setup` in `game.transient`. Never dispose anything in `game.assets`.
9. Budget: at most 150 draw calls and 150k triangles. Use pooling and instanced geometry.
10. Pick a rig once in `createGame` and use `fitBounds` instead of hand-tuned camera coordinates.

#### The Platform Through the Wrapper

`game.platform` wraps the surface documented in the [SDK](/docs/sdk-overview) pages, with the handshake, the one-`gameOver`-per-run latch and the play-again wiring already handled. Reach leaderboards, saves and achievements as `game.platform.leaderboards`, `game.platform.saves` and `game.platform.achievements`, haptics as `game.platform.haptic('light')`, and [purchases](/docs/purchases) as `game.platform.hasItem(slug)`, `game.platform.itemCount(slug)` and `game.platform.purchase(slug)`.

#### Desktop Targets

A desktop game declares `presentation.target: "desktop"` in `remix.json` and composes for a landscape 16:9 frame: pass both `design: LANDSCAPE_DESIGN` (1280 by 720) and `camera: landscapeRig()` to `createGame`, because the defaults stay portrait. Read the keyboard with ordinary DOM key events; for mouse look, call `game.input.lockPointer()` from a press and read the `look` event. The viewport is any size and changes when the window does, so read it when you lay out, never once at boot.

#### Types in the Editor

The scaffolded project carries `remix-three.d.ts`, a small entry point for the generated declarations under `.remix/types/`, so TypeScript understands Three.js and the SDK without an install. Outside a scaffolded project, the `@remix-gg/three` npm package ships the same types.

## CLI

### CLI

Source: https://remix.gg/docs/cli

The remix command that creates, imports and publishes flat game folders, and its machine-readable mode for agents.

The `remix` command is the bridge between coding agents and the platform. Remix Desktop bundles it and runs the same commands when you create, import or publish from the app, so terminal and app workflows share one project format.

```sh
remix create game      # create a Remix draft and a flat game folder
remix publish          # compile and upload a new playable version
remix import <dir>     # adopt a game folder into your library
```

#### Authentication

Run `remix login` before publishing. It completes authentication in the browser and saves the key to `~/.remix/config.json` with mode 600. Setting `REMIX_API_KEY` in the environment takes precedence over the saved key. Keys are created on your [API console](/api). `remix logout` removes the saved key from this machine; revoke the key itself on the API console.

#### Commands

##### `remix create game`

Creates the remote draft, scaffolds the flat folder from the template and writes the real `gameId` to `remix.json`. It never runs a package manager.

| Flag | Meaning |
| --- | --- |
| `--name <name>` | Skip the name prompt. Names are 5 to 25 characters and unique per account. |
| `--dir <path>` | Where to create the folder. |
| `--mode arcade\|levels\|experience\|portal` | `arcade`, the default, is a scored run. `levels` writes `lifecycle: "levels"` and a starting `levelCount` of 5. `experience` is open-ended. `portal` starts a portal world. |
| `--target mobile\|desktop` | The surface the game targets. Defaults to `mobile`; a portal world is always `desktop`. |
| `--orientation portrait\|landscape` | How a mobile game holds the phone. A desktop game is always landscape. |
| `--no-reuse` | Without a terminal, `create` reuses your unpublished game of the same name; this flag answers `name-taken` instead. |

##### `remix publish [dir]`

Compiles `game-main.ts` and its imports into one HTML document, hosts every literal `assets/` reference and every achievement icon, checks the pinned engine URLs, forwards `leaderboards` and `achievements` from `remix.json`, and uploads a new draft version. A build over 2 MB of HTML is refused with `game-too-large`; move large data into `assets/`. Warnings about declarations that could not take effect are printed after the upload.

`--launch` then takes the version live once it passes the automated review. Without it, launch from the Studio or Remix Desktop.

##### `remix import <dir>`

Adopts a game folder into your games library, finished or not. A folder without `remix.json` gets one named after the folder, and the result lists any of `index.html` and `game-main.ts` still missing. `--move` moves instead of copies. The library is `--library-dir`, then `REMIX_GAMES_DIR`, then `~/RemixGames`.

#### Machine Mode

`--json` writes one NDJSON object per line to stdout and sends human-readable text to stderr:

```jsonc
{ "event": "step", "step": "scaffolding", "detail": "grid-invaders" }
{ "event": "result", "dir": "/Users/me/RemixGames/grid-invaders", "gameId": "…" }
{ "event": "error", "code": "name-taken", "message": "…" }
```

`--yes` never prompts and returns `input-required` when a required value has no default. Non-interactive terminals imply `--yes`. Agents should treat the `event` stream as the contract and ignore stderr.

#### Environment Variables

| Variable | Meaning |
| --- | --- |
| `REMIX_API_KEY` | Overrides the saved login. |
| `REMIX_API_URL` | Overrides the API base. Default `https://remix.gg`. |
| `REMIX_GAMES_DIR` | Overrides the local games library. |

#### What the CLI does not do

Preview is owned by Remix Desktop. Games have no package manager, no local dev command, no dependency installation and no framework-upgrade command. The platform owns the engine version a publish emits.

## HTTP API

### HTTP API

Source: https://remix.gg/docs/http-api

Create games, push versions and read creator analytics from any language with a Studio API key.

The HTTP API is how a script, a CI job or a tool you build creates games and pushes versions without the Studio. Every route is rooted at `https://remix.gg/api/v1` and authenticated with a Studio API key. The [API console](/api) is where you create keys, and it shows live request and response examples.

#### Authentication

Create a key on the [API console](/api) and pass it as a bearer token:

```http
Authorization: Bearer sk_live_your_api_key_here
Content-Type: application/json
```

A key acts as you. Keep it out of client-side code and out of game code.

#### Rate Limit

All requests made with one key share a 60-request token bucket that refills at one request per second. A `429` response includes `Retry-After`.

#### Games

##### `GET /games`

Lists the games you own.

```json
{
  "games": [
    {
      "id": "cm3abc123",
      "name": "My Awesome Game",
      "appImageUrl": "https://…/app-icon.webp",
      "createdAt": "2025-10-23T10:30:00.000Z",
      "updatedAt": "2025-10-23T11:45:00.000Z",
      "liveVersionId": "cm3xyz456"
    }
  ]
}
```

##### `POST /games`

Creates a game from a complete HTML document.

| Body field | Required | Meaning |
| --- | --- | --- |
| `name` | yes | 5 to 25 characters, unique for your account. |
| `code` | yes | The whole HTML document. |
| `presentation` | no | The `remix.json` presentation block; `target` labels the game mobile or desktop. |
| `lifecycle`, `levelCount` | no | `"scored"`, `"levels"` or `"custom"`, and the level total (5 to 99) for level games. |
| `leaderboards`, `achievements` | no | The declaration blocks from `remix.json`, synced the same way a CLI publish syncs them. |

```sh
curl -X POST "https://remix.gg/api/v1/games" \
  -H "Authorization: Bearer sk_live_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My Awesome Game",
    "code": "<!doctype html>…",
    "presentation": { "target": "mobile" },
    "lifecycle": "scored",
    "leaderboards": [{ "key": "best-taps", "name": "Best Taps", "operator": "best" }]
  }'
```

Responds `201` with `{ "game": { "id", "name", "createdAt" } }` plus `leaderboardSync` and `achievementSync` blocks listing what was created, updated, disabled or ignored, with warnings. A duplicate name is a `400`.

An achievement `icon` is kept only when it is the URL of an image already hosted with this game; Remix Desktop and the CLI host files under `assets/` when they publish. Any other value shows the default badge and returns a warning.

##### `POST /games/:id/versions`

Uploads a new version of the game's code. Uploads overwrite the latest unpublished version; if the latest version is already live, a new draft is created. Every upload clears the previous version's checks, so the new code is checked again before it can launch.

| Body field | Required | Meaning |
| --- | --- | --- |
| `code` | yes | The whole HTML document for this version, up to 2 MB. |
| `presentation`, `levelCount` | no | As on create; `lifecycle` is read on create only. A desktop game's type is read from the build at each launch; a launched Levels game stays Levels and its level count cannot shrink. |
| `leaderboards`, `achievements` | no | The declaration blocks from `remix.json`; immutable fields on existing keys are ignored with a warning. |

Responds `201` with `{ "success": true, "version": { "id", "title", "createdAt" } }` plus the same sync blocks as create.

#### Analytics

Both routes require the key of the game's creator and accept optional `start_date` and `end_date` query parameters in `YYYY-MM-DD` (UTC, inclusive).

##### `GET /games/:id/leaderboard`

The top 100 unique players on the game's main score board, ranked by best score. With dates, it lists the players whose best score was set in that range.

```json
{
  "game_id": "cm3abc123",
  "period": { "start_date": "2026-04-01", "end_date": "2026-04-30" },
  "leaderboard": [
    { "rank": 1, "score": 12800, "achieved_at": "2026-04-15T12:30:00.000Z",
      "user": { "id": "user_123", "username": "player_one", "pfp": "https://…" } }
  ]
}
```

##### `GET /games/:id/scores`

Aggregate play activity, overall and per platform.

```json
{
  "game_id": "cm3abc123",
  "period": { "start_date": null, "end_date": null },
  "metrics": {
    "plays": 180,
    "qualified_plays": 142,
    "completed_plays": 96,
    "total_seconds": 12450
  },
  "platforms": [
    {
      "platform": "web",
      "plays": 180,
      "qualified_plays": 142,
      "completed_plays": 96,
      "total_seconds": 12450
    }
  ]
}
```

#### Errors

Every error is `{ "error": string }` with an optional `details`. `401` is a missing or invalid key, `403` is a key that does not own the game, an account without API access, or a suspended account, `404` is an unknown game, `400` is a malformed body or query, `429` is the rate limit.

## Reference

### Limits

Source: https://remix.gg/docs/limits

The sizes and counts the platform enforces on uploads, declarations, saves, writes and reads.

Most limits exist so a game cannot slow down every other game on the platform. Every one is enforced at publish or at write time with a warning or a refusal that names the cap, never by silently dropping data.

#### Declarations

| Declaration | Ceiling | Notes |
| --- | --- | --- |
| Named leaderboards | up to 32 per game | Your game's own limit may be lower. |
| Achievements | up to 64 per game | Your game's own limit may be lower; Remix Desktop shows it on the Details page. Retired keys still count toward the 64. |
| Levels | 5 to 99 | A launched game's count can grow but not shrink. |

A declaration past your game's limit is refused at publish with a warning naming the cap; the rest of the block still syncs.

#### Documents and Payloads

| Item | Limit |
| --- | --- |
| Game document | 2 MB of HTML per uploaded version; keep media in `assets/` |
| Asset file | 5 MB each |
| Save slot document | 256 KiB of JSON; no cap on the number of slots |
| Cloud file | 1 MiB each, counted against the creator's shared storage allowance |
| Leaderboard scores | Safe integers only; overflow on `incr` and `decr` boards is refused |
| Achievement icon | A PNG, JPEG or WebP under `assets/`; square reads best |

#### Immutability

Once an upload has declared a key, these fields cannot change on it; a changed value is ignored with a warning:

- Leaderboard `sort`, `operator`, `reset`

A launched game's `lifecycle` is locked too: later uploads cannot change its mode, and a level game's `levelCount` can only grow. Names, descriptions, metadata, icons and `hidden` can change on any later publish.

#### Time Windows

| Behaviour | Window |
| --- | --- |
| Leaderboard submission id retries | 7 days |
| New records reaching the `listRecords` top page | about 10 seconds |

#### Rate Limits

- HTTP API: 60-request bucket per key, refilling at one request per second.
- Leaderboard reads: a game that polls its own boards is throttled and hears `null`. Read on a user action.
- Writes: leaderboard records, achievement unlocks and saves are throttled per player and per game. A throttled write answers `{ ok: false, code: 'throttled' }`, and its reason says when to retry.

### Launch Checklist

Source: https://remix.gg/docs/launch-checklist

The checks a game should pass before you launch a version, in the order they usually fail.

Work through this on the version you are about to launch, in the real preview, not in a local tab.

#### The Handshake

- [ ] The game waits for `ready()` before the first run. Nothing scores, spawns or plays audio before it resolves.
- [ ] Listeners are registered before `ready()`, so no host message is missed.
- [ ] The SDK loads exactly once, and no copy is bundled into your code.

#### The Run

- [ ] `gameOver` is called exactly once per run, and the loop freezes afterwards. Check a death animation cannot fire it twice.
- [ ] `play` resets the run in place. Play three runs in a row without a reload and confirm the score resets.
- [ ] Open-ended games never call `gameOver`. Level games pass `levelAttempt` on every attempt and honour `data.levelIndex` on restart.
- [ ] Mute from the host chrome silences everything.

#### Layout and Input

- [ ] Everything the player must see or tap sits inside the safe area.
- [ ] Portrait games are legible in the feed at a glance; the first tap is the game, not a tutorial.
- [ ] Touch targets are at least 44 pixels. No hover-only affordances on mobile targets.
- [ ] The layout survives a viewport change mid-session.

#### Assets and Performance

- [ ] Every asset is referenced by a literal `assets/` path. Nothing is fetched from a third-party host at runtime.
- [ ] The document loads in under three seconds on a phone. Three.js games stay under 150 draw calls and 150k triangles.
- [ ] Audio starts only after the first interaction.

#### Declarations

- [ ] Every leaderboard and achievement in `remix.json` has a stable key you will not rename.
- [ ] The publish output has no declaration warnings, or you have read each one.
- [ ] Every achievement is unlocked somewhere in game code with `sdk.achievements.unlock(key)`, using a key from `remix.json`.
- [ ] Achievement icons are square, under `assets/`, and read at 44 pixels.

#### Progression

- [ ] Lifetime counters behind achievements live in the save and survive a reload.
- [ ] Leaderboard times are raw milliseconds on an `asc` board. Every `writeRecord` has a submission id you reuse only for retries.
- [ ] Every `null` from a platform call is handled by showing nothing extra. Play once as a guest.
- [ ] Nothing the platform owns (unlocks, stars, best scores) is stored in game state or a save slot.

#### Then Launch

Play the version yourself, on a phone if it is a mobile game. Launch from the game page. Watch the first few plays on the [scores endpoint](/docs/http-api#get-gamesidscores) or in the Studio.
