DEMIENGINEDOCUMENTATION Everything is free
Game data · Game systems

Constraints, references, and validation errors.

Data schemas

Games carry lots of structured data: item tables, level configs, character stats, localization strings. A data schema is a machine-checkable description of what one of those documents is allowed to contain. Schemas exist so a typo in a JSON file is caught at load time with a precise error, instead of becoming a mysterious bug in the middle of a fight. This page covers declaring schemas, loading and querying data from Lua, and the reload events that keep live data fresh — using the sample game "Skyforge" at games/my_game.

What a DataSchema asset is

A DataSchema is an asset whose manifest settings describe the shape of a data document. The schema is referenced by a data asset (the JSON content itself). Validation then checks every load against the schema, and errors report the exact JSON pointer path of the offending field (for example /items/2/price).

Why an asset and not a comment in the file? Because assets have stable IDs, are validated with everything else, and travel with the project through cook and package — the guarantee survives shipping.

Schema constraints

A schema is a JSON document listing constraints. Each constraint narrows what a valid value may be:

ConstraintWhat it checks
typeThe JSON kind of the value: object, array, string, number, integer, or boolean.
requiredField names that must be present on an object.
propertiesNested schema per object field.
itemsSchema every element of an array must satisfy.
enumThe exact set of allowed values.
minimumSmallest allowed number.
maximumLargest allowed number.
referenceRequires a URI of kind asset, prefab, or scene and validates that the URI resolves in the project.

Here is a schema for Skyforge item rows:

{
  "format_version": 1,
  "type": "object",
  "required": ["id", "name", "price"],
  "properties": {
    "id":    { "type": "string" },
    "name":  { "type": "string" },
    "price": { "type": "integer", "minimum": 0 },
    "rarity": { "type": "string", "enum": ["common", "rare", "epic"] },
    "icon":  { "type": "string", "reference": "asset" }
  }
}
  • format_version — always required; documents without it are rejected.
  • type: "object" — the data document is a JSON object.
  • required — id, name, and price must exist or the load fails with a JSON pointer error.
  • properties — one nested constraint per field: strings for id and name, a non-negative integer for price, an enum for rarity, and an asset URI for icon.
  • reference: "asset" — icon must be an asset:// URI that actually resolves; a dangling reference is a validation error.

The data asset manifest

The JSON content is declared as a data asset. Its manifest settings carry three data-specific fields:

{
  "format_version": 1,
  "id": "asset://data/items",
  "type": "DataAsset",
  "source": "assets/data/items.json",
  "settings": {
    "schema": "asset://schemas/item_row",
    "content_type": "application/vnd.demi.items+json",
    "tags": ["items", "economy"]
  }
}
  • type — required; must be "DataAsset" for a data document.
  • settings.schema — the DataSchema asset ID validating this document.
  • settings.content_type — a type label scripts use to find documents of this kind.
  • settings.tags — free-form labels for grouping and queries.
  • source — the JSON file to load. General source data documents use format_version: 1 at their root. Native content types own their versions; terrain palettes use version 2.

Validation errors for the content report the JSON pointer path inside the source file, so "price is not an integer at /items/2/price" is enough to jump straight to the row.

Loading data from Lua

The Data service loads and queries data assets. Import it explicitly, like every engine service:

local Data = require("demi.data")

local items, err = Data.load("asset://data/items")
  • require("demi.data") — imports the data service table.
  • Data.load(id) — loads the data asset with the given asset:// ID and validates it against its schema.
  • On success it returns value, nil — the parsed document as a Lua table, and no error.
  • On failure it returns nil, {code, message, path} — a stable error code, a human-readable message, and the JSON pointer path of the offending value.

Handle both shapes explicitly:

local items, err = Data.load("asset://data/items")
if err then
    print(err.code, err.message, err.path)  -- stable code, text, JSON pointer
    return
end
print(items[1].name)                        -- first row's name field
  • err.code — stable diagnostic code, the same family as validation output.
  • err.message — human-readable explanation.
  • err.path — JSON pointer path to the invalid value.
  • items[1].name — ordinary table access over the loaded document; nested objects and arrays arrive as Lua tables.

Querying by content type and tags

When many data assets share a schema, query them instead of loading by hand:

local rows = Data.query({ content_type = "application/vnd.demi.items+json",
                          tags = { "items" } })
  • content_type — matches settings.content_type from each data asset manifest.
  • tags — a Lua array of tags; a document matches when it carries them.
  • Data.query(...) returns an array of matching documents as snapshot tables (not URIs). An empty array means nothing matched.

Lua array-like tables round-trip as numeric arrays across JSON and network bridges, so list-shaped data stays list-shaped.

Reading values safely

Every loaded value answers a few small questions that keep gameplay code defensive:

local price = items[1].price
print(Data.revision("asset://data/items"))  -- integer revision of the loaded asset
print(Data.kind(price))                    -- "array", "object", "null", or nil for scalars
print(Data.is_null(price))                 -- true only for explicit JSON null
print(Data.null)                           -- the explicit JSON null sentinel value
  • Data.revision(id) — the document revision of the loaded asset, given its asset:// ID string. Returns an integer.
  • Data.kind(value) — "array", "object", or "null" for those JSON kinds; nil for scalars (numbers, strings, booleans).
  • Data.is_null(value) — returns true when the value is an explicit JSON null, distinguishing "null" from "missing".
  • Data.null — a sentinel value (not a function), for writing fields that must round-trip as JSON null.

Reload events

During development, data files change while the game runs (watch mode and hot reload). Instead of polling the disk, subscribe to the reload event:

local Events = require("demi.events")

Events.subscribe("data_asset_reloaded", function(e)
    -- e.id, e.old_revision, e.new_revision, e.affected_dependents
    print(e.id, e.old_revision, e.new_revision)
    for _, dependent in ipairs(e.affected_dependents) do
        print("refresh:", dependent)
    end
end)
  • data_asset_reloaded — fires after a data asset's source changed and revalidated successfully.
  • e.id — the asset:// ID of the reloaded document.
  • e.old_revision / e.new_revision — integer revisions before and after the reload.
  • e.affected_dependents — an array of IDs (as strings) that reference the reloaded document, so cascading refreshes can be scoped instead of global.

A reload that fails validation is rejected and does not raise the event; the previously loaded valid revision stays in effect, with diagnostics reported in the usual format.

Where data lives at runtime

  • Authored source documents live under the project (for example games/my_game/assets/data/) and are validated like every other source.
  • Cooked copies live in generated content at ship time; scripts keep addressing them by asset:// ID.
  • Player-written values (saves) are separate: they go to platform user-data, never into data assets. The save API is covered in Replay & profiler.

See also Assets & packages for import and dependency commands, and demi.project.json for startup preload behavior.