save
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>>
Section titled “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.
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();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 yetconst state: MySave = read.data ?? defaults();save.set(data, { version }): Promise<SaveWrite>
Section titled “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.
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;}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;}// 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 }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 };How a conflict resolves
Section titled “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
Section titled “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.
sdk.save.on('replaced', (read) => { version = read.version; replaceState(read.data ?? defaults());});sdk.save.on('replaced', (read: SaveRead<unknown>) => { version = read.version; replaceState((read.data as MySave | null) ?? defaults());});save.sync<T>(): Promise<SaveRead<T>>
Section titled “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>
Section titled “save.clear(): Promise<void>”Deletes the local and the remote copy. The next get() returns version: 0 and source: 'none'.
const authoritative = await sdk.save.sync();
resetButton.addEventListener('click', async () => { await sdk.save.clear(); version = 0; replaceState(defaults());});const authoritative: SaveRead<MySave> = await sdk.save.sync<MySave>();
resetButton.addEventListener('click', async () => { await sdk.save.clear(); version = 0; replaceState(defaults());});The _summary field
Section titled “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
Section titled “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.