# Remix SDK

The Remix SDK enables game developers to integrate their HTML5 games with the Remix platform through a simple messaging interface.

Read the full guides and reference at [remix.gg/docs](https://remix.gg/docs), and play what people make at [remix.gg](https://remix.gg).

## Installation

Install the SDK using your preferred package manager:

```sh
npm install @remix-gg/sdk
# or 
bun install @remix-gg/sdk
# or
yarn add @remix-gg/sdk
# or
pnpm add @remix-gg/sdk
```

When you upload game code to Remix, a script tag to reference the SDK will automatically be added to your game code. The SDK will be accessible from `window.RemixSDK`.

## Player data: start small

Each kind of player data has one home:

- **The save holds the game's own state.** Currencies, upgrades, settings, lifetime counters
  (total kills, distance run), content unlocks and any in-game checklist live in the autosave
  (`saveGameState`) or in named documents through `sdk.saves.get/put`, with conflict detection.
- **Anything to rank is a leaderboard.** A best combo or a fastest lap is a board with
  `operator: "best"`; start with `writeRecord` and `listRecords`.
- **Anything to show off is an achievement the game unlocks.** Declare a handful in `remix.json`
  and call `sdk.achievements.unlock(key)` when the player earns one; the platform lists them on
  the game page and toasts the unlock. An in-game achievements screen reads `sdk.achievements`
  (the declared list plus the unlocked map). A clicker with hundreds of internal milestones does
  not need to publish every one; platform limits are ceilings, not targets.

Save at checkpoints and read standings when the player opens them. All cloud saves
share the creator's byte allowance across games and players; there is no slot-count cap.
The Desktop dashboard shows one storage meter.

Every write in these namespaces answers the same way: `{ ok: true, ... }` when it landed,
`{ ok: false, code, reason }` when it was refused (`code` is a short machine-readable word,
`reason` a sentence for the console), and `null` for guests and hosts without the namespace.
Detailed references follow.

## Example Implementation

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

class MyGame {
  sdk: RemixSDK
  player: Player
  gameState: GameState | null = null
  isMuted: boolean = true

  // some example values that correlate to items purchased by boosting
  canSaveGame: boolean = false
  hasSuperSkin: boolean = false

  constructor() {
    // attach the sdk to the Game class to we can reference it easily
    this.sdk = window.RemixSDK

    // Setup event listeners
    this.setupEventListeners()

    // Initialize your game
    this.initialize()
  }

  private async setupEventListeners() {
    // Listen for play (replay or host-directed level start)
    this.sdk.onPlay(() => {
      this.resetGame()
    })

    // Listen for mute state changes
    this.sdk.onToggleMute((data) => {
      this.isMuted = data.isMuted
    })

    // Listen for purchase completions (optional)
    this.sdk.onPurchaseComplete(() => {
      this.checkPurchasedItems()
    })
  }

  private async initialize() {
    // wait for the sdk to get game information from Remix
    await this.sdk.ready()

    // get information the game uses from the SDK
    this.player = this.sdk.player
    this.gameState = this.sdk.gameState

    // check which items the user has already purchased
    this.checkPurchasedItems()
    
    //... start the game logic
  }

  private checkPurchasedItems() {
    // check the identifier for each item to see if the user has purchased it

    if (this.sdk.hasItem('save-game')) {
      this.canSaveGame = true
    }

    if (this.sdk.hasItem('super-skin')) {
      this.hasSuperSkin = true
    }
  }

  private async purchaseItem(itemId: string) {
    // trigger the boost UI and then check for new items after
    await this.sdk.purchase({ item: itemId })
    this.checkPurchasedItems()
  }

  private gameOver(finalScore: number) {
    //
    this.sdk.singlePlayer.actions.gameOver({ score: finalScore })
  }

  private saveGame() {
    // return unless the user has purchased the 'save-game' item
    // see checkPurchasedItems
    if (!this.canSaveGame) return

    // push game state updates to save progress for the user
    const gameState: GameState = this.getCurrentGameState();
    this.sdk.singlePlayer.actions.saveGameState({ gameState })
  }

  private triggerHapticFeedback() {
    this.sdk.hapticFeedback()
  }
}
```


## API Reference

### Properties and Getters

#### `sdk.ready(): Promise<GameInfo>`

This function will return when the SDK has recieved all the game information from Remix. You should await the call to this function before getting info from the SDK (sdk.player, sdk.gameState, etc)

The SDK announces itself to the host automatically when it loads — games never send the `ready` handshake themselves. `sdk.ready()` only waits for host data (`gameInfo`, `gameState`, level progression) to arrive before you read it; a game that reads nothing from the host does not need to call it.

#### `sdk.isReady: boolean`

Check if the SDK has received game info and is ready. More often, you will just await the call to `sdk.ready()`, but you may use this value if you prefer.

#### `sdk.player: Player | undefined`

Get the current player object.

#### `player.avatarTraits: AvatarTraits | undefined`

The player's equipped Remix avatar as trait data, so a game can draw the player as a character: body type, colors and fills, clothing, eyes, mouth, hair, teeth, mustache and rarity. Every entry in `sdk.players` carries its own.

It is absent for players without a Remix avatar and on hosts that predate it, so always keep a fallback look. The string unions can gain values as the avatar system grows; draw an unknown value like the closest one you support.

```typescript
const traits = sdk.player?.avatarTraits
const bodyColor =
  traits?.bodyFill.kind === 'solid'
    ? traits.bodyFill.color
    : traits?.bodyFill.kind === 'gradient'
      ? traits.bodyFill.top
      : '#ffffff'
```

#### `sdk.players: Player[] | undefined`

Get the list of all players in the game. This is useful for multiplayer games. In multiplayer games, it is essential that you use the `players` array so you can report scores with the associated player ids and notify players it is there turn.

#### `sdk.gameState: GameState | null | undefined`

Get the current game state. This will be `null` when there is no existing GameState for this game and user.

#### `sdk.purchasedItems: string[]`

Get the list of item IDs that the current player has purchased. This is a read-only property that reflects the `purchasedItems` array from the current player's `GameInfo`.
When an item is purchased multiple times, that slug appears multiple times in the array.

#### `sdk.inventory: { slug: string, quantity: number }[]`

Get the player's inventory as slug + quantity entries, derived from `purchasedItems`.

#### `sdk.shopItems: ShopItem[]`

Get the list of shop items available for the current game session.

#### `sdk.hasItem(item: string): boolean`

Check if the current player has purchased a specific item.

Parameters:
- `item`: The item identifier to check (string)

Returns:
- `true` if the player has purchased the item, `false` otherwise

#### `sdk.getItemPurchaseCount(item: string): number`

Get how many times the current player has purchased a specific item.

Parameters:
- `item`: The item identifier to check (string)

Returns:
- The purchase count for that item (0 when never purchased)

#### `sdk.getShopItem(slug: string): ShopItem | undefined`

Get a specific shop item by slug.

Parameters:
- `slug`: The shop item slug to look up (string)

Returns:
- The matching `ShopItem` object when found, otherwise `undefined`

#### `sdk.gameInfo: GameInfo | undefined`

Get the current `GameInfo` object. This is all the data passed into the game from Remix. It is recommended that you use the other functions to access the specific data you need instead of pulling data out of this large object.

#### `sdk.hapticFeedback()`

Call this method to trigger haptic feedback on supported devices. Use this for important game events like collisions, achievements, or other significant player interactions.

#### Best Practices for Haptic Feedback

- Use haptic feedback sparingly to avoid overwhelming the player
- Reserve haptic feedback for meaningful interactions and achievements
- Consider using it for:
  - Collisions with obstacles
  - Collecting power-ups
  - Completing levels
  - Achieving high scores
  - Important game events

#### `sdk.purchase({ item: string }) => Promise<{ success: boolean }>`

Call this function to initiate a purchase for an in-game item. This method sends a purchase request to the Remix platform and returns a promise that resolves with the purchase result. Make sure the `item` identifier matches an item you have added to your game using the [Remix](https://remix.gg) web app.

Parameters:
- `item`: The identifier of the item to purchase (string)

Returns:
- A promise that resolves with `{ success: boolean }` indicating whether the purchase was successful

The purchase flow works as follows:
1. Call `purchase({ item: 'item-id' })` to initiate the purchase
2. The platform handles the purchase transaction (payment processing, etc.)
3. The promise resolves with `{ success: true }` if the purchase succeeds, or `{ success: false }` if it fails
4. You can also listen for `purchase_complete` events using `sdk.onPurchaseComplete()` for additional handling

**Important**: Purchase data is automatically updated after a successful purchase. Use `sdk.getItemPurchaseCount(item)` (or `sdk.inventory`) when your game needs quantity, and `sdk.hasItem(item)` for simple ownership checks.


### Single Player Actions

#### `sdk.singlePlayer.actions.gameOver({ score: number, levelAttempt?: LevelAttempt })`

Call this method when the game is over to report the final score.

Parameters:

- `score`: The player's final score (number)
- `levelAttempt`: For level-based games only — the result of the attempt that just ended (see Level-Based Games below). `score` is ignored for level-based games; pass `0`.

#### `sdk.singlePlayer.actions.saveScore({ score: number, levelAttempt?: LevelAttempt })`

Accepts the same payload as `gameOver` and emits `save_score`, requesting score
persistence without ending play or showing the platform's game-over screen.
It may be called repeatedly during play and returns `void` (no persistence
acknowledgement). Calling it does not prevent a later explicit `gameOver`.

This release adds the SDK event only. Host support will be added separately;
clients without a `save_score` handler will not record these scores.

### Level-Based Games

When the host configures a fixed level count, `gameInfo.levelBased` is set and the game runs as a level progression game:

- `gameInfo.levelBased.levelCount` is the total number of levels.
- `gameInfo.levelBased.progress` is the platform-owned, score-derived progression (`LevelProgressState`). Read it through the getters `sdk.isLevelBased`, `sdk.levelCount`, `sdk.currentLevelIndex`, `sdk.levelStars`, `sdk.highestUnlockedLevel`, and `sdk.totalStars`. Never store progression in `gameState`.
- When an attempt ends, report only what happened:

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

The platform merges attempts into cumulative progression — games never compute or report star maps, unlock state, or "all levels complete" (the host derives world completion from `levelIndex` and its own `levelCount`). Listen for host-directed level starts with `sdk.onPlay((data) => ...)`; the host passes the target level as `data.levelIndex`.

#### `sdk.singlePlayer.actions.saveGameState({ gameState: Record<string, unknown> })`

Call this method when you wish to persist game state for the user. This will enable users to save their progress in a
game between play sessions.

### Leaderboards

Named leaderboards let a game rank many things at once — one board per racing stage, a weekly
hot-lap board, a career-laps counter — beside the platform's main score. Boards are declared in
your `remix.json` and are configuration-immutable after creation (except `name`/`metadata`):

```jsonc
{
  "leaderboards": [
    { "key": "stage-1", "name": "Harbor Sprint", "sort": "asc", "operator": "best",
      "metadata": { "format": "time_ms" } },
    { "key": "weekly-hot-lap", "name": "Weekly Hot Lap", "sort": "asc", "reset": "0 0 * * 1" },
    { "key": "career-laps", "name": "Career Laps", "operator": "incr" }
  ]
}
```

- `sort`: `"asc"` (times — lower ranks higher, submit raw milliseconds, no inversion) or `"desc"` (default).
- `operator`: `"best"` (default), `"set"`, `"incr"`, or `"decr"` — applied server-side.
- `reset`: five-field cron (integers or `*`; e.g. `0 0 * * 1` = weekly, Monday midnight UTC). Each
  window is a fresh board; history is retained.

#### `sdk.leaderboards.writeRecord({ leaderboard, score, subscore?, submissionId? })`

Submit a value. Resolves `{ ok: true, score, subscore, numScore }` — the record's value *after*
the operator applied (a `best` board keeps the previous best) and how many submissions it has
counted. A write never ranks; read the board when a screen needs a position. The whole result is
`null` for guest sessions and hosts that predate leaderboards. Call it whenever the moment happens
(a stage finish mid-run is fine); it does not end the run — `gameOver` is still required and still
feeds the platform's main score.

```ts
const submissionId = sdk.leaderboards.createSubmissionId() // retain for retries for up to seven days
const result = await sdk.leaderboards.writeRecord({ leaderboard: 'stage-1', score: lapMs, submissionId })
if (result?.ok) {
  showBanner(`Best time: ${result.score} ms`)
}
```

Use a new `submissionId` for each logical action and reuse it for retries of that action.
The SDK generates an ID when omitted, but separate calls then represent separate submissions.
IDs are timestamped; expired IDs are refused, never reapplied. Receipts survive board resets
within the seven-day retry window. Do not give an expired action a fresh ID. Hosts clean up old
receipts after eight days; records are retained. Writes refuse unsafe integers and cumulative
overflow; they never silently round or clamp. A refusal is `{ ok: false, code, reason }`:

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

Equality with the submitted value can also mean a tied best, so it does not prove a new personal
record.

Publish responses include `leaderboardSync` with created, updated, ignored keys and warnings.
The CLI prints warnings for invalid declarations, capacity limits, and immutable rule changes.

#### `sdk.leaderboards.listRecords({ leaderboard, limit? })`

The top of the board's current window: `{ leaderboard, records, ownerRank, rankingUpdatedAt }`
where each record is `{ rank, score, subscore, username, imageUrl, isOwner }`. Every page is one
read: each `rank` is the record's exact position, counted when the page was read, and
`rankingUpdatedAt` is that read's time. `ownerRank` is exact when the player's row is on the
returned page and `null` otherwise (use `listRecordsAroundOwner` for their position). The top page
may be shared for ten seconds; request it when opening a leaderboard or after a completed action,
not every frame.

#### `sdk.leaderboards.listRecordsAroundOwner({ leaderboard, limit? })`

A window centred on the current player's record, plus their exact `ownerRank` — ideal for "you
vs. your neighbors" displays on a stage-select screen. `listFriendRecords` ranks the player among
the people they follow, with positions within that group.

All three resolve `null` rather than rejecting when the host has no answer (a guest asking for a
personal view, an unknown board, an older host), so a game runs unchanged on surfaces without
leaderboard support.


### Cloud save slots

`saveGameState` is the one autosave blob and stays the default. Slots are for games that need
more than one document per player — three save files, a run in progress beside a settings
profile — synced across devices. Nothing is declared; a slot exists once it is written. There is
no slot-count cap. Each document is at most 256 KiB of JSON; total storage is shared across all
games and players belonging to the creator.

```js
const slot = await RemixSDK.saves.get('main') // SaveSlot | null
const result = await RemixSDK.saves.put({
  slot: 'main',
  data: { level, inventory },
  ...(slot ? { ifMatch: slot.etag } : { ifNoneMatch: '*' }), // protect the first write too
})
if (result && !result.ok && result.conflict) {
  // result.conflict is the slot as it is now: merge, or ask the player
}
const slots = await RemixSDK.saves.list()
await RemixSDK.saves.delete({ slot: 'main' })
```

Every call resolves `null` for guests and on hosts without storage, and `get` resolves `null` for
an empty slot, so "nothing to load" reads the same either way. A refused write or delete answers
`{ ok: false, code, reason }` with cloud files' codes: `conflict` (another device saved first;
`conflict` then carries the slot as it is now), `quota` (the creator's storage is full),
`throttled`, `invalid` (a bad slot name, a document that is not a JSON object, or one past
256 KiB), or `unavailable`. Slots are JSON files in cloud storage: slot `main` is the file
`saves/main.json`. `list()` answers up to 100 slots and resolves `null` past that; page through
`sdk.storage.list({ prefix: 'saves/' })` for larger collections.

### Cloud files

For binary data or documents past 256 KiB — replays, ghosts, level packs — `sdk.storage` stores
the player's own files for this game, up to 1 MiB each, in the same allowance as save slots.

```js
const saved = await RemixSDK.storage.put('ghosts/track-1.bin', bytes, { ifNoneMatch: '*' })
const read = await RemixSDK.storage.get('ghosts/track-1.bin')
if (read?.ok && read.file) replay(read.file.data) // Uint8Array
```

- `put(path, bytesOrJson, { ifMatch?, ifNoneMatch? })` writes bytes (any typed array, a
  `DataView`, or an `ArrayBuffer`) or a JSON object; a value JSON cannot serialize is refused as
  `invalid`. Use `ifNoneMatch: '*'` for the first write and `ifMatch: file.etag` after that.
- `get(path)` answers `{ ok: true, file }` with `file.data` as a `Uint8Array`; `file` is `null`
  when there is no such file. `head(path)` answers the same without the bytes.
- `list({ prefix?, cursor?, limit? })` answers at most 100 files per page and a `cursor` for the
  next.
- `delete(path, { ifMatch? })`.

A refusal answers `{ ok: false, code, reason }`, where `code` is `conflict` (read the file again),
`quota`, `throttled`, `invalid`, or `unavailable`. Every call resolves `null` for guests and on
hosts without storage. `saveGameState` keeps its autosave at `state/autosave.json` in the same
namespace, so leave that path alone. Each file also counts 16 KiB of metadata against the
creator's allowance.

### Game achievements

A curated selection of the game's memorable accomplishments. The platform lists them on the game
page and shows a toast the moment they are earned. Declare them in `remix.json`; the game decides
when each one is earned and calls `unlock`. Earned once, for good; they carry no XP and no trophy
tier. An `icon` names a square image under `assets/`; publish hosts it with the game and the
platform shows it in place of the default badge (`game_info.achievements[].icon` is the hosted
URL, or `null`).

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

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

```js
await RemixSDK.ready()

save.totalKills += 1
if (save.totalKills >= 100) RemixSDK.achievements.unlock('centurion') // null for guests and older hosts

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

`unlock(key)` resolves `{ ok: true, key, unlocked, unlockedAt }` (`unlocked` is `false` when it
was already earned), `{ ok: false, code, reason }` when refused (`not_found` for a key your
`remix.json` does not declare, `throttled`, or `unavailable` — retry later), or `null` for guests
and hosts without achievements. Calling it again for an earned achievement is harmless.
`onUnlocked` hears only keys newly earned after boot — from an unlock that earned it, or a
`refresh()` that finds one earned elsewhere — never the ones the player already had.

An in-game achievements screen draws from the same reads:

```js
const earned = RemixSDK.achievements.unlocked() // { [key]: unlockedAt ISO }
for (const a of RemixSDK.achievements.list()) {
  const at = earned[a.key]
  if (a.hidden && !at) addRow({ name: 'Secret', icon: null, locked: true })
  else addRow({ name: a.name, description: a.description, icon: a.icon, locked: !at })
}
```

`hidden` keeps an achievement off the game page until earned. Achievements are capped at 64 per
game.

### Multi-Player Actions

Multiplayer shares the single-player actions, with per-player score payloads, shared state saving, and an additional refute action. It is important to note that YOU
MUST use the `gameInfo` data from the response to `ready()` in order to accurately report scores with player ids when the game ends.

#### `sdk.multiplayer.actions.gameOver({ scores: { playerId: string; score: number }[] })`

Call this method with the game is over to report the final scores.

Parameters:

- `scores`: The playerId and score for each player in the game.

#### `sdk.multiplayer.actions.saveScore({ scores: { playerId: string; score: number }[] })`

Accepts the same per-player payload as multiplayer `gameOver` and emits
`multiplayer_save_score`. It requests persistence without ending play or
showing results, returns `void`, and requires future host support just like
the single-player action.

#### `sdk.multiplayer.actions.saveGameState({ gameState: Record<string, unknown>; alertUserIds: string[] })`

Call this method when a player action should update the shared game state for all players. If your game has a concept of turns as most do, you must include the `alertUserIds` list with the id(s) of the player(s) that should be alerted it is their turn.

Parameters:

- `gameState`: A Record object containing your game state. The shape of the game state is open to the game creator's discretion.
- `alertUserIds`: A list of player ids to alert on this game state update. Typically this will be a list of length 1 containing the id of the player who is next to act, but different games may need to alert multiple players.

#### `sdk.multiplayer.actions.refuteGameState({ gameStateId: string })`

Call this method when your game client has determined an incoming game state is invalid. It is not strictly required that your game validates game state updates, but it should. Putting game state validation logic in your game client means that bad actors attempting to push unfair updates are invalidated by good actors running valid implementations of your game.

Parameters:

- `gameStateId`: The id from the `game_state_updated` event that the game client is refutting.

#### `sdk.multiplayer.actions.purchase({ item: string }) => Promise<{ success: boolean }>`

Same as the single player `purchase` method. Call this method to initiate a purchase for an in-game item in multiplayer games. See the single player purchase documentation above for details.


Here is an example function that handles update and refute game state for a Chess game:

```javascript
  handleGameStateUpdate({ gameState }) {
    if (!gameState) return;

    const { id, gameState: { moves } } = gameState;

    try {
      this.chess.reset();
      if (moves?.length > 0) {
        for (const move of moves) {
          this.chess.move(move);
        }
      }
      this.renderBoard();
      this.updateStatus();
      this.updateMoveHistory();
      this.updateCapturedPieces();
    } catch (error) {
      // game state is invalid, refuting this game state update
      this.sdk.multiplayer.actions.refuteGameState({ gameStateId: id });
    }
  }
```

### Events

The SDK provides both generic event listeners and specific typed listener methods. We recommend using the specific listener methods for better type safety and clarity.

#### Event Listeners

##### `sdk.onPlay(callback: (data?: { levelIndex?: number }) => void)`

Register a callback that is called when the host starts play — a replay after
game over, or (for level-based games) a host-directed level via `data.levelIndex`.
`data` may be omitted for a plain restart.

##### `sdk.onPlayAgain(callback: (data?: { levelIndex?: number }) => void)`

Alias of `sdk.onPlay`.

##### `sdk.onToggleMute(callback: (data: { isMuted: boolean }) => void)`

Register a callback function that is called when receiving mute state changes.

Parameters:
- `callback`: Function that receives `{ isMuted: boolean }` indicating the current mute state

##### `sdk.onGameStateUpdated(callback: (data: { id: string, gameState: Record<string, unknown> } | null) => void)`

Register a callback function that is called when there is an update to the game state. This is essential for multiplayer games to receive state updates from other players.

Parameters:
- `callback`: Function that receives the game state update data, or `null` if the game state is cleared

##### `sdk.onGameInfo(callback: (data: GameInfo) => void)`

Register a callback function that is called when game information is received. This is typically called automatically when the game initializes, but you can listen for updates.

Parameters:
- `callback`: Function that receives the `GameInfo` object

##### `sdk.onPurchaseComplete(callback: (data: { success: boolean, item?: string }) => void)`

Register a callback function that is called when a purchase completes. This can be used for additional handling beyond the promise returned by `purchase()`. It returns the same value as awaiting the call to `purchase()`, but sometimes users may boost your game without your game triggering the `purchase` function.

Parameters:
- `callback`: Function that receives purchase result data, including optional item and quantity info when available

## TypeScript Support

The SDK is written in TypeScript and includes full type definitions.


## Example Multiplayer Implementation

Multiplayer game development is not yet available to all users, but the SDK supports it. Watch the Remix Discord for announcements about
multiplayer game support.

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

class Game {
  sdk: RemixSDK
  gameState: GameState
  player: Player
  players: Player[]

  constructor() {
    this.sdk = window.RemixSDK

    // Initialize your game
    this.initialize()

    // Setup event listeners
    this.setupEventListeners()
  }

  private setupEventListeners() {
    // Listen for play (replay or host-directed level start)
    this.sdk.onPlay(() => {
      this.resetGame()
    })

    // Listen for mute state changes
    this.sdk.onToggleMute((data) => {
      this.setMuted(data.isMuted)
    })

    // Listen for game state updates (essential for multiplayer)
    this.sdk.onGameStateUpdated((data) => {
      const { id, gameState } = data
      const newGameStateIsValid = this.validateGameState(gameState);

      if (newGameStateIsValid) {
        this.gameState = gameState
      } else {
        this.sdk.multiplayer.actions.refuteGameState({ gameStateId: id });
      }
    })

    // Listen for purchase completions (optional)
    this.sdk.onPurchaseComplete((data) => {
      if (data.success) {
        this.handlePurchaseSuccess()
      }
    })
  }

  private async initialize() {
    await this.sdk.ready()

    this.gameState = this.sdk.gameState
    this.player = this.sdk.player
    this.players = this.sdk.players
  }

  private saveGameState(gameState: Record<string, unknown>, nextTurnPlayerId: string) {
    this.sdk.multiplayer.actions.saveGameState({
      gameState,
      alertUserIds: [nextTurnPlayerId],
    })
  }

  private gameOver(scores: { playerId: string, score: number }[]) {
    this.sdk.multiplayer.actions.gameOver({ scores })
  }

  private async purchaseItem(itemId: string) {
    try {
      const result = await this.sdk.multiplayer.actions.purchase({ item: itemId })
      if (result.success) {
        console.log(`Successfully purchased ${itemId}`)
        // Item quantity is available via sdk.getItemPurchaseCount(itemId)
        // Purchase data is automatically updated for all players
      } else {
        console.log(`Purchase failed for ${itemId}`)
      }
    } catch (error) {
      console.error('Purchase error:', error)
    }
  }
}
```

## License

MIT

## Precise content layout (MOBILE and LANDSCAPE)

New games should use `sdk.contentLayout` instead of reserving a full edge for
Remix controls. It contains:

- `viewport: { width, height }`: reference size in game viewport CSS pixels.
- `safeAreaInset: { top, right, bottom, left }`: device/system insets only.
- `exclusions: [{ id, kind, x, y, width, height }]`: visible control/cutout
  rectangles, clipped to the viewport and already padded by 8 CSS pixels.

The host publishes updated `game_info.contentLayout` when controls appear,
disappear, or move. Read `sdk.contentLayout` on `sdk.onGameInfo` and window
resize; the getter maps host coordinates into the current game viewport.
`sdk.isUiRectSafe({ x, y, width, height })` tests a HUD or touch target in those
CSS coordinates. Convert canvas/design units before testing; do not use device
pixels or multiply by devicePixelRatio. Background art can fill the viewport.
Do not add another 8px or collapse the exclusions into an edge band.

`contentSafeAreaInset` keeps its existing conservative meaning for old games.
The new getter falls back to that inset when an older host omits the new field;
never apply both layouts. Existing games opt in by adopting the new API.
The game-owned `remix.ts` template offers the same getter and collision helper,
plus `remix.onContentLayout(callback)` (returns an unsubscribe function).

Host implementers can import pure geometry helpers from
`@remix-gg/sdk/content-layout` without initializing the browser SDK.
`createContentLayout` pads raw hit targets once; `measureContentLayout` measures
DOM targets relative to the frame. Hosts retain their legacy inset separately.
