Skip to content

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.

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.

Read the save

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();

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.

Write the save

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;
}

SaveRead and SaveWrite

// 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 }

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.

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.

Reload on a newer copy

sdk.save.on('replaced', (read) => {
version = read.version;
replaceState(read.data ?? defaults());
});

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.

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

Sync, then reset

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

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.

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.