# save

One save per player per game, a JSON value up to 64 kB, local first and synced to the host, with a version the game increments.

One save per player per game: a JSON value of at most 64 kB once serialized. The SDK is local-first. It
writes `localStorage` at once (key `filudi:save:<gameId>`) and pushes to the host after: the API on
filudi.com, the data module on CrazyGames, the storage on Playgama. Poki and the mock keep it local.

## `save.get<T>(): Promise<SaveRead<T>>`

Waits up to 1 s for the remote copy, then returns the local one. If the remote turns out newer later, the
SDK replaces the local copy and fires `replaced`.

<CodePanel title="Read the save">
  <Fragment slot="javascript">
    ```js
    const read = await sdk.save.get();
    // read: { data: MySave | null, version: number, source: 'remote' | 'local' | 'none', updatedAt: string | null }
    let version = read.version;
    const state = read.data ?? defaults();
    ```

  </Fragment>
  <Fragment slot="typescript">
    ```ts
    import type { SaveRead } from '@filudi/game-sdk';

    interface MySave { level: number; stars: number[] }

    const read: SaveRead<MySave> = await sdk.save.get<MySave>();
    let version = read.version;              // 0 when there is no save yet
    const state: MySave = read.data ?? defaults();
    ```

  </Fragment>
</CodePanel>

## `save.set(data, { version }): Promise<SaveWrite>`

`version` is a positive integer the game increments: the version of the last `get()` or `saved` result
plus one. Without it the call returns `{ status: 'error' }` with a message that says so. It has nothing to
do with a schema version inside your data: keep that inside the blob.

`set()` returns `saved` at once with `synced: false`, then pushes. It returns `conflict` without writing
when the version the SDK already knows from the server is greater than or equal to the one you pass.

<CodePanel title="Write the save">
  <Fragment slot="javascript">
    ```js
    let writing = Promise.resolve();

    // One write at a time, so each one sends the version the previous one returned.
    function writeSave(state) {
      writing = writing.then(async () => {
        const result = await sdk.save.set(state, { version: version + 1 });
        if (result.status === 'saved') version = result.version;
        else if (result.status === 'conflict') {
          version = result.server.version;
          replaceState(result.server.data);
        } else if (result.status === 'too-large') {
          console.error(`save is ${result.bytes} bytes, max ${result.max}`);
        }
      });
      return writing;
    }
    ```

  </Fragment>
  <Fragment slot="typescript">
    ```ts
    import type { SaveWrite } from '@filudi/game-sdk';

    let writing: Promise<void> = Promise.resolve();

    // One write at a time, so each one sends the version the previous one returned.
    function writeSave(state: MySave): Promise<void> {
      writing = writing.then(async () => {
        const result: SaveWrite = await sdk.save.set(state, { version: version + 1 });
        if (result.status === 'saved') version = result.version;
        else if (result.status === 'conflict') {
          version = result.server.version;
          replaceState(result.server.data as MySave);
        } else if (result.status === 'too-large') {
          console.error(`save is ${result.bytes} bytes, max ${result.max}`);
        }
      });
      return writing;
    }
    ```

  </Fragment>
</CodePanel>

## Types

<CodePanel title="SaveRead and SaveWrite">
  <Fragment slot="javascript">
    ```js
    // SaveRead:  { data, version, source: 'remote' | 'local' | 'none', updatedAt }
    // SaveWrite: { status: 'saved', version, synced }
    //          | { status: 'conflict', server }      server has a higher version
    //          | { status: 'too-large', bytes, max }  max is 65536
    //          | { status: 'error', message }
    ```

  </Fragment>
  <Fragment slot="typescript">
    ```ts
    interface SaveRead<T> {
      data: T | null;
      version: number;                      // 0 when none
      source: 'remote' | 'local' | 'none';
      updatedAt: string | null;             // ISO
    }

    type SaveWrite =
      | { status: 'saved'; version: number; synced: boolean }
      | { status: 'conflict'; server: SaveRead<unknown> }   // server has a higher version
      | { status: 'too-large'; bytes: number; max: 65536 }
      | { status: 'error'; message: string };
    ```

  </Fragment>
</CodePanel>

## How a conflict resolves

On filudi.com the push is `PUT /v1/sdk/saves/:gameId` with `If-Match`. A 409 resolves by version: the
higher version wins. If the server is higher, the SDK replaces the local copy and fires `replaced` so the
game can reload. Equal versions with different content: the server wins, another device has seen it. On
portals with a storage module the push goes there; else the save stays local and `synced` stays false.

## `save.on('replaced', listener): Off`

Fires when a newer copy arrives from the host: after a `get()` that returned the local copy first, after a
conflict, or when another device wrote. The listener gets the `SaveRead`. Reload your state from it.

<CodePanel title="Reload on a newer copy">
  <Fragment slot="javascript">
    ```js
    sdk.save.on('replaced', (read) => {
      version = read.version;
      replaceState(read.data ?? defaults());
    });
    ```

  </Fragment>
  <Fragment slot="typescript">
    ```ts
    sdk.save.on('replaced', (read: SaveRead<unknown>) => {
      version = read.version;
      replaceState((read.data as MySave | null) ?? defaults());
    });
    ```

  </Fragment>
</CodePanel>

## `save.sync<T>(): Promise<SaveRead<T>>`

For a game that needs the authoritative copy before drawing: waits for the host and returns the merged
read. On a host without remote saves it returns the local copy.

## `save.clear(): Promise<void>`

Deletes the local and the remote copy. The next `get()` returns `version: 0` and `source: 'none'`.

<CodePanel title="Sync, then reset">
  <Fragment slot="javascript">
    ```js
    const authoritative = await sdk.save.sync();

    resetButton.addEventListener('click', async () => {
      await sdk.save.clear();
      version = 0;
      replaceState(defaults());
    });
    ```

  </Fragment>
  <Fragment slot="typescript">
    ```ts
    const authoritative: SaveRead<MySave> = await sdk.save.sync<MySave>();

    resetButton.addEventListener('click', async () => {
      await sdk.save.clear();
      version = 0;
      replaceState(defaults());
    });
    ```

  </Fragment>
</CodePanel>

## The `_summary` field

A game may put a `_summary` object at the top level of its save data so the hub can draw it without knowing
the game: `line` (a string under 60 characters, like "World 3 of 5"), `progress` (0 to 1), `streak` (days,
for daily games, counted by the game) and `share` (the text grid of today's result, under 400 characters,
emoji allowed there). The SDK does not read it.

## Unavailable cases

Saves never answer `unavailable`: a host without storage keeps the save in `localStorage` and `source` says
`local`. `features.saves` from `init()` says `remote` or `local` ahead of time. The migration of a save
written by an older build, and what to keep in the blob, is in [saves](/docs/games/saves/).
