Loading game data and reading/writing saves.
Data & save API
Games need two kinds of stored numbers: data (authored balance and content, read at runtime) and saves (player progress, written during play). Two modules cover them — demi.data and demi.save.
What game data is
Game data is authored JSON (or YAML) validated against a schema: enemy stats, item tables, dialogue lines. It ships with the game and is read-only during play. Loading it through demi.data means the shape is checked and errors tell you exactly which field is wrong.
local Data = require("demi.data")
local value, err = Data.load("asset://data/skyforge_enemies")
if err then
-- err.code : short machine string ("not_found", "schema_mismatch", …)
-- err.message : human-readable sentence
-- err.path : JSON pointer like "/stats/0/hp", or the URI
return
end
-- value: parsed document (table), ready to read
Data.load(uri) takes an asset:// URI and returns value, nil on success or nil, err on failure. The error is always a table with three string fields: code, message, and path (a JSON pointer to the offending field, or the whole URI when the document is missing). On success the second result is nil — you never get both a value and an error.
Inspecting loaded values
local Data = require("demi.data")
local snapshots = Data.query({ content_type = "application/x-skyforge-enemy", tags = { "balance" } })
-- snapshots: array of document tables (possibly empty), ordered by stable asset ID
print(Data.revision("asset://data/skyforge_enemies")) -- integer revision of the loaded document
local value = Data.load("asset://data/skyforge_enemies")
print(Data.kind(value.nickname)) -- "null", "array", "object", or nil for scalars
print(Data.is_null(value.nickname)) -- true only for JSON null
Data.query(filter)— takescontent_typeand/ortags; returns an array of document snapshot tables (one per matching asset, including scalar/null roots; empty when none match, nevernil).Data.revision(id)→ integer revision number for the document (for change detection).Data.kind(value)→"array","object", or"null";nilfor scalars and missing values.Data.is_null(value)→ boolean,trueonly for the null sentinel.Data.null— the sentinel value for JSONnull. Assign it when writing a real null.
JSON null is not the same as a missing field. Lua's nil means “key absent”, so Data.null stands in for a present-but-null field. See API conventions for how this fits the rest of the API.
YAML text
local Data = require("demi.data")
local value, err = Data.parse_yaml("hp: 12\nname: Drone", "inline-example")
if err then
-- err.code, err.message, err.path — same shape as Data.load errors
return
end
Data.parse_yaml(text, source) takes a YAML string plus a source label used in errors and returns value, nil or nil, err with the same {code, message, path} error table. Use it for YAML snippets that did not come from an asset URI.
What a save is
A save is player-owned state written to the platform user-data directory: settings, unlocked levels, a Skyforge campaign position. Saves are per-slot (several playthroughs or profiles) and survive restarts. Nothing under the install or cooked directory is ever written.
Use the simple typed keys for one value at a time, and the structured calls when a whole table goes in at once.
local Save = require("demi.save")
Save.set_number("profile1", "credits", 480)
Save.set_string("profile1", "callsign", "Ace")
print(Save.get_number("profile1", "credits", 0)) -- 480 (third argument is the default)
print(Save.get_string("profile1", "callsign", "")) -- "Ace"
| Call | Takes | Returns |
|---|---|---|
Save.set_number(slot, key, value) | Slot name, key, number | true when written, false on failure (see Save.last_error()) |
Save.get_number(slot, key, default) | Slot, key, fallback number | Stored number, or default when missing |
Save.set_string(slot, key, value) | Slot, key, string | true when written, false on failure |
Save.get_string(slot, key, default) | Slot, key, fallback string | Stored string, or default when missing |
Save.read(slot) | Slot name | Table of stored keys, or nil when the slot has never been written |
Save.write(slot, table, format_version?) | Slot, table of values, optional version | true on success, false on failure; details via Save.last_error() |
Save.read_state(slot) | Slot name | Structured state table, or nil |
Save.write_state(slot, state, options?) | Slot, state table, optional options (format_version, autosave, sequence, reason) | true on success; errors go to Save.last_error() |
read returns a plain table of the slot's keys or nil for a slot that does not exist — nil is “no slot”, an empty table is “slot exists with no keys”. The pair get_*/set_* covers scalars; read/write covers whole tables; read_state/write_state covers structured game state trees.
Writes return true on success and false on failure. After any failure (or a suspicious read), ask for the last problem:
print(Save.last_error()) -- message string; empty when the last call succeeded
print(Save.exists("profile1")) -- true when the slot has been written
local meta = Save.metadata("profile1")
-- meta: { autosave, sequence, reason } for the slot, or nil when the slot is missing
Save.last_error()→ string message of the most recent failure; empty when the last operation succeeded.Save.exists(slot)→ boolean.Save.metadata(slot)→ table withautosave,sequence, andreasonfields, ornilwhen missing.
Migrations and cleanup
When your save format changes, old saves must keep working. Register a migration per version bump; it runs on load for slots saved at older versions.
local Save = require("demi.save")
Save.register_migration(1, 2, function(state, from_version, to_version)
state.credits = state.credits or 0 -- fix up the older shape
return state
end)
print(Save.version("profile1")) -- integer version of the slot
Save.delete("profile1") -- remove the slot entirely
Save.register_migration(from_version, to_version, fn)— registers a function that upgrades state fromfrom_versiontoto_version. The function receives the state table plus both versions and returns the updated table. Returns an integer migration ID.Save.version(slot)→ integer version stored with the slot.Save.delete(slot)— deletes the slot and its metadata. Returnstruewhen deleted.
Never silently delete an incompatible save. Migrate it, or report the problem and leave the file alone. The CLI can inspect a slot from a terminal with demi save inspect.
Next steps
- Data schemas — declaring what a data document must contain.
- Assets, time & timers — loading large data asynchronously.
- Replay & profiler — saving and inspecting runs.