Storing data: the storage API
Goal: persist a value from your Lua game, restart the server, and read it back - without writing a line of SQL.
Your fighter moves while a match runs, but the moment the server stops that state is gone. This step makes it survive. You will not maintain a database to do it.
The mental model
You do not own a database. You do not write SQL, run migrations, or open a connection. You call a small key-value API from your Lua, and Asobi persists the value for you.
There are two scopes:
- Per-player - a value owned by one player (their best score, their unlocks).
- Shared - one global value the whole game reads and writes (the fighter's last resting position, a server-wide counter).
That is the whole surface. Four calls, all under game.storage:
game.storage.set(collection, key, value) -- write shared
game.storage.get(collection, key) -- read shared
game.storage.player_set(player_id, collection, key, value) -- write per-player
game.storage.player_get(player_id, collection, key) -- read per-playercollection is a namespace you pick (e.g. "arena"); key is a string; value is any scalar, list, or table. It is stored as JSON, so nested tables are fine. A read of an absent key returns nil.
There is no per-match scope. A match can call game.storage.set, but the value it writes is shared/global - it outlives the match. If you want data that dies with the match, keep it in the match state table (step 8); if you want it to persist, write it to storage.
Persist the fighter
Restore the fighter when a match starts, and save it as it moves. In match.lua:
local W, H = 16, 16
function init(config)
local saved = game.storage.get("arena", "fighter")
return {
fighter = saved or { x = 0, y = 0 }
}
end
function handle_input(player_id, input, state)
local f = state.fighter
f.x = math.max(0, math.min(W - 1, f.x + (input.move_x or 0)))
f.y = math.max(0, math.min(H - 1, f.y + (input.move_y or 0)))
local moves = game.storage.player_get(player_id, "arena", "moves") or 0
game.storage.player_set(player_id, "arena", "moves", moves + 1)
return state
end
function tick(state)
game.storage.set("arena", "fighter", state.fighter)
return state
endThe client still only sends intent (input.move_x, input.move_y in {-1, 0, 1}); the server decides the new position, persists it, and remains the single source of truth. Nothing about this changes between Cloud and self-hosted - the Lua is identical everywhere.
Where the value actually lands
The game logic above is the same on both deployments. Only the database underneath differs, and you configure that once, out of band.
Cloud. Every environment gets its own managed Postgres database, provisioned for you when the environment is created. Connection details are injected into your deployment automatically. You never see a connection string, never run a migration, never touch the database. Deploy your bundle and storage just works.
Self-hosted. You bring your own Postgres (17+) and point Asobi at it. On the asobi_lua Docker image this is a handful of ASOBI_* env vars:
ASOBI_DB_HOST=db
ASOBI_DB_NAME=asobi
ASOBI_DB_USER=postgres
ASOBI_DB_PASSWORD=postgresEmbedding Asobi as an Erlang dependency instead? The same settings live under the kura key in sys.config. Either way the schema is created and migrations run automatically on startup - you still never hand-write SQL. See the Configuration guide for the full key list and both forms.
What storage is not
Two things look like storage but are separate systems - reach for them by name, not through game.storage:
- Inventory and items are their own primitive (the economy system: item definitions, per-player item instances, wallets). If you want "the player owns 3 potions", that is inventory, not a storage key. See the Economy guide.
- Cloud saves are a per-player, slotted save API over REST (
GET/PUT /api/v1/saves/:slot, up to 10 slots, 256 KB each) - built for client-driven save/load of a game blob, distinct from the server-sidegame.storagecalls above. See the Storage section of the REST API guide.
For persisting arbitrary server-decided progress from Lua - which is what this track needs - game.storage is the right tool.
Checkpoint
Prove the value outlives the process. A Lua edit hot-reloads without dropping state, so you must restart the whole server, not just the script.
- Join a match and move the fighter a few times so
tickwrites it. - Restart the server:
- Self-hosted: restart the container/release (e.g.
docker compose restart). Your Postgres keeps running. - Cloud: redeploy the environment. The managed database persists across deploys.
- Self-hosted: restart the container/release (e.g.
- Add a temporary read at match start and log it:
function init(config) local saved = game.storage.get("arena", "fighter") print("restored fighter:", saved and saved.x, saved and saved.y) return { fighter = saved or { x = 0, y = 0 } } end - Start a fresh match. The log shows the fighter's last position from before the restart, not
0, 0.
Same experiment for per-player: print(game.storage.player_get(player_id, "arena", "moves")) should show the move count carried over.
Remove the temporary print once you have seen it work.
Next: Step 6 - Set up a match + modes
How config.lua maps a mode to its script, and single-mode vs multi-mode bundles.