Saving data
Save player progress, shared game state and leaderboards from a script with save, and how it's stored on NEO Platform, on the desktop, in the browser and on BLACKBOX.
On this page
A game can keep data between play sessions without a backend of its own: a player's progress and settings, state every player shares, and leaderboards. Scripts reach it through save; Rust code through the neo_save crate. Every call is asynchronous: it returns a promise that resolves on a later simulation tick, so scripts stay deterministic.
Saving and loading a player's data#
save is the current player's own data. save.get(key) resolves to the stored value (or null), save.set(key, value) stores any JSON-like value: numbers, strings, booleans, lists, objects.
import { World, on, save } from "neo";
export default class Progress extends World {
state = { best: 0 };
@on("round_start")
async load() {
this.state.best = (await save.get("best")) ?? 0;
}
@on("finished")
async finished(player, time) {
if (time > this.state.best) {
this.state.best = time;
await save.set("best", time);
}
}
}from neo import World, on, save
class Progress(World):
state = {"best": 0}
@on("round_start")
async def load(self):
best = await save.get("best")
self.state.best = best if best is not None else 0
@on("finished")
async def finished(self, player, time):
if time > self.state.best:
self.state.best = time
await save.set("best", time)
default = Progressthis.save (self.save in Python) is the same object as the imported save.
| Call (JS / Python) | Resolves to | |
|---|---|---|
save.get(key) | the value, or null | |
save.getEntry(key) / save.get_entry(key) | { value, version }, or null | the version is raised by every write |
save.set(key, value) | the new version | |
save.setIf(key, value, version) / save.set_if(...) | { ok, version, value } | writes only if the stored version is version (0: the key doesn't exist yet); otherwise ok is false and you get the current value and version |
save.update(key, patch) | the merged object | merges an object's fields atomically; a field set to null is removed |
save.remove(key) | whether it existed | |
save.keys(prefix) | a list of keys | in key order |
Use setIf when two places might write the same key: read with getEntry, compute, write with the version you read, and retry if ok is false.
Shared data and other players#
save.sharedhas the same calls for data every player of your game shares (a world record, a message of the day). Only the session's authority writes it: the host of a peer session, the player of a solo one.save.player(userId)reaches a specific player's data. Anyone in the session can read it; the player and the session's authority can write it (the authority only for players in its session).
Leaderboards#
save.leaderboard(name) is an ordered store of whole numbers by key:
const wins = save.leaderboard("wins");
await wins.increment(1); // the current player's entry
const top = await wins.top(10); // [{ key, value, rank }, …], highest firstwins = save.leaderboard("wins")
await wins.increment(1)
top = await wins.top(10)increment(by, key) adds to an entry; without a key it's the current player's own (keyed by their user id). Players only write their own entry; the session's authority may write any key (a team's, say).
Where it's stored#
| Where the game runs | Storage |
|---|---|
| Launched from NEO Platform | the platform's data store for your game, per experience: the same data on every device the player signs in on |
| The desktop, not launched by the platform | a file in the game's save folder |
| A browser, not launched by the platform | the browser's IndexedDB for that page |
| BLACKBOX | the console's saves (written atomically) |
A game launched from the platform gets a session token with its launch; without one (playing in the editor, a downloaded build), saves stay on the device. Your script doesn't change.
Limits#
- Values: up to 4 MB each (as JSON). Keys: 1 to 128 characters. Store names (
save.leaderboard(name)): letters, digits,_,.and-, up to 50. - Each game has a request budget per minute that grows with the number of players in its sessions, and a total storage size. Over the budget, a call fails with
resource_exhausted: save on meaningful moments (a level finished, a purchase), not every tick. - Previous values are kept for 30 days.
A failed call rejects its promise (Python raises): catch it to show the player something or retry later.
Guests, deleting and exporting#
- A guest's data is kept when they make an account, whether they upgrade the guest account or sign up while playing as a guest.
- Deleting an account deletes everything games saved about that player, including their leaderboard entries.
- Players can download everything games saved about them from Settings > Your data.
From Rust#
Games built in Rust use the neo_save crate's Saves resource: each call returns a ticket, and the result arrives later as a SaveDone message.
use neo_save::{SaveDone, SaveScope, Saves};
fn load(mut saves: ResMut<Saves>, mut ticket: Local<Option<neo_save::SaveTicket>>) {
if ticket.is_none() {
*ticket = Some(saves.get(SaveScope::Me, "best"));
}
}
fn loaded(mut done: MessageReader<SaveDone>) {
for d in done.read() {
info!("save {:?}: {:?}", d.ticket, d.result);
}
}