DEMIENGINEDOCUMENTATION Everything is free
Lua API reference · Create

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) — takes content_type and/or tags; returns an array of document snapshot tables (one per matching asset, including scalar/null roots; empty when none match, never nil).
  • Data.revision(id) → integer revision number for the document (for change detection).
  • Data.kind(value) → "array", "object", or "null"; nil for scalars and missing values.
  • Data.is_null(value) → boolean, true only for the null sentinel.
  • Data.null — the sentinel value for JSON null. 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"
CallTakesReturns
Save.set_number(slot, key, value)Slot name, key, numbertrue when written, false on failure (see Save.last_error())
Save.get_number(slot, key, default)Slot, key, fallback numberStored number, or default when missing
Save.set_string(slot, key, value)Slot, key, stringtrue when written, false on failure
Save.get_string(slot, key, default)Slot, key, fallback stringStored string, or default when missing
Save.read(slot)Slot nameTable of stored keys, or nil when the slot has never been written
Save.write(slot, table, format_version?)Slot, table of values, optional versiontrue on success, false on failure; details via Save.last_error()
Save.read_state(slot)Slot nameStructured 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 with autosave, sequence, and reason fields, or nil when 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 from from_version to to_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. Returns true when 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