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:
| Constraint | What it checks |
|---|---|
type | The JSON kind of the value: object, array, string, number, integer, or boolean. |
required | Field names that must be present on an object. |
properties | Nested schema per object field. |
items | Schema every element of an array must satisfy. |
enum | The exact set of allowed values. |
minimum | Smallest allowed number. |
maximum | Largest allowed number. |
reference | Requires 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, andpricemust exist or the load fails with a JSON pointer error.properties— one nested constraint per field: strings foridandname, a non-negative integer forprice, an enum forrarity, and an asset URI foricon.reference: "asset"—iconmust be anasset://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 useformat_version: 1at 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 givenasset://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— matchessettings.content_typefrom 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 itsasset://ID string. Returns an integer.Data.kind(value)—"array","object", or"null"for those JSON kinds;nilfor scalars (numbers, strings, booleans).Data.is_null(value)— returnstruewhen the value is an explicit JSONnull, distinguishing "null" from "missing".Data.null— a sentinel value (not a function), for writing fields that must round-trip as JSONnull.
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— theasset://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.