Files
sessionzero/schema.md
2026-07-17 20:18:38 -05:00

13 KiB

sessionzero data format (szdf)

This document defines the file format used for sessionzero data objects: schemas and instances. It is TOML-adjacent but not TOML. A dedicated parser is required; existing TOML libraries will not handle type annotations, reference literals, or computed fields.

1. File kinds

There are two kinds of files, distinguished by their header key.

Schema files declare an sztype: its fields, their types, defaults, and computed values. A schema is the shape a data object can take. Header key is schema.

Instance files are actual data objects that conform to (but are not locked to) a schema. Header key is sztype. An instance supplies values for the fields its schema declares, and may freely add fields the schema never mentioned. There is no strict validation against the schema beyond type checking on fields that exist in both. This is intentional: schemas describe a starting shape, not a locked contract, so a table can extend or override rulesets without editing shared package files.

Schemas belong to a package, same as instances do. Two packages may each declare a schema for the same sztype name, and those two schemas are entirely independent of one another. szcore.player and custom-package.player are different schemas that happen to share a name.

2. Addressing

Every object, schema or instance, is identified by package.sztype.id, taken from its package, sztype/schema, and id keys. This triple is how objects reference each other and how the runtime resolves them.

Fields within an object are accessed by dotting further into its table structure, e.g. my-package.player.jeff.stats.maxHp.

3. Schema resolution

An instance binds to a schema by its own package.sztype, exact match, not a search across packages. An instance with package = "custom-package" and sztype = "player" binds to custom-package.player, never to szcore.player, even if szcore.player exists and custom-package.player does not.

If no schema exists for that exact package.sztype, the instance is treated as a fully freeform object: every field is untyped, nothing is readonly, and no computed fields resolve. This is a valid and supported state, not an error, for tables that don't want to bother declaring a schema at all.

A package that wants to reuse or build on another package's schema does so explicitly, with extends in the schema header:

schema = "player"
package = "custom-package"
extends = "szcore.player"

extends pulls in every field the target schema declares (including computed fields and readonly flags). The extending schema may then add new fields or override existing ones by redeclaring them; a redeclared field replaces the inherited one entirely, it does not merge. extends may point to a schema in any package, not just the current one.

The wildcard package type reference described in section 4 (.sztype, matching any loaded package) is unrelated to schema resolution. It only affects how a field's value is validated against a set of possible referenced objects, not which schema an instance itself is bound to.

4. Scalars and tables

Comments start with #. Sections are declared with [section] and nested sections with [section.subsection], same as TOML. Supported scalar types are string, int, number (float), bool, and lists written with [ ]. Strings support the same triple-quoted multiline form as TOML (""").

description = """
A player named Jeff
"""

5. Type annotations

Type annotations only appear in schema files, on the field's first (and only) declaration. Instance files never use type annotations; a field's type is fixed by its schema, or is untyped if the field is a freeform addition.

name: string
level: int = 1

The annotation goes between the field name and the =. A default value is optional; if omitted, the field is unset until an instance supplies one.

Custom object types (references to other sztypes) use the package.sztype form as the type:

class: szcore.class

A wildcard package, matching any loaded package's sztype of that name, uses a leading dot:

class: .class

A union of allowed sztypes uses |:

weapon: szcore.weapon | szcore.staff

6. Lists

A list field wraps its element type in [ ]:

items: [szcore.item] = []

Lists of unions are written [Type1 | Type2]. There is no separate comma-in-brackets form; commas are reserved for separating list elements, not types.

7. Reference literals

A reference to another object is a distinct literal, not a quoted string, so the parser can resolve it structurally rather than re-parsing string contents. References start with $:

class = $szcore.class.ranger

$self refers to the current object, used when one field's computed value depends on another field in the same object:

$self.stats.modifiers.dex

8. Computed fields

A field whose value is derived from an expression, rather than stored directly, is declared with := instead of =. This marks it at parse time as something to place in a dependency graph and evaluate, rather than a literal value to store.

ac := 10 + $self.stats.modifiers.dex

Computed fields may reference other computed fields. The runtime resolves these through a dependency graph and evaluates on read (with memoization invalidated by upstream changes), or once at load time, depending on how live the session needs to be. Circular dependencies are a load-time error, not a runtime crash.

A computed field never takes a type annotation; its type is inferred from the expression.

9. Readonly fields

The readonly keyword prefixes a field declaration in a schema. A readonly field can be set once (at instance creation, or by hand in a text editor) but cannot be modified programmatically afterward.

readonly maxHp: int

Readonly is a schema-level property. It is not re-declared in instance files.

10. Freeform fields

Because schemas describe a starting shape rather than a strict contract, an instance may declare fields its schema never mentioned. These need no type annotation; their type is inferred from the value. This is how a table adds house rules, custom equipment slots, or homebrew stats without touching the shared schema file.

[background]
nickname = "Jeffrey the Bold"

An instance may also omit fields the schema declares. Their value stays unset, effectively leaving the field as a fill-in-the-blank template slot.

11. Grammar summary

file        := header table*
header      := (schema_hdr | instance_hdr) pair*
schema_hdr  := "schema" "=" string ; "package" "=" string ; ("extends" "=" string)?
instance_hdr:= "sztype" "=" string ; "package" "=" string ; "id" "=" string

table       := "[" dotted_key "]" pair*
pair        := readonly? key (":" type)? ("=" value | ":=" expr)

readonly    := "readonly"
key         := identifier
type        := scalar_type | list_type | ref_type | union_type
scalar_type := "string" | "int" | "number" | "bool"
list_type   := "[" type "]"
ref_type    := (package "." sztype) | ("." sztype)
union_type  := type ("|" type)+

value       := string | int | number | bool | list | reference
list        := "[" (value ("," value)*)? "]"
reference   := "$" dotted_path
expr        := arithmetic/logical expression over values, references, and literals

12. Examples

Schema: szcore player

schema = "player"
package = "szcore"

[info]
name: string
level: int = 1
class: szcore.class
race: szcore.race

[stats]
readonly maxHp: int
currentHp: int
ac := 10 + $self.stats.modifiers.dex
str: int = 10
dex: int = 10
int: int = 10
wis: int = 10

[stats.modifiers]
str := ($self.stats.str - 10) / 2
dex := ($self.stats.dex - 10) / 2
int := ($self.stats.int - 10) / 2
wis := ($self.stats.wis - 10) / 2

[inventory]
items: [szcore.item] = []

Schema: custom-package player, extending szcore's

A different package can declare its own player shape. Here it builds on szcore's version instead of starting over, adding a field szcore never had.

schema = "player"
package = "custom-package"
extends = "szcore.player"

[info]
background: string = ""

A schema named player is declared in a package like any other schema; it's a template, not an instance. Building an actual player instance from one of these templates follows the special rules in section 15, not the ordinary package.sztype.id instance form shown for rock below.

Schema: item

schema = "item"
package = "szcore"

[info]
name: string
value: number = 0
weight: number = 0

[stats]
damage: int = 0
skill: string
attack_dice: string

Instance: rock

sztype = "item"
package = "szcore"
id = "rock"
equipable = true

[info]
name = "Rock"
value = 1
weight = 0.5

[stats]
damage = 1
skill = "str"
attack_dice = "1d4"

rock binds to szcore.item since its own package is szcore. equipable is a freeform top-level field; the item schema never declares it, so it carries no type annotation and is simply a bool inferred from its literal value.

13. Package manifest

A package is a datapack. Every package directory contains one manifest file, package.szdf, using the same scalar/table syntax as everything else in this document, but with no schema or sztype header since a manifest is neither.

id = "szcore"
name = "SessionZero Core"
version = "1.2.0"
description = """
Core ruleset objects for sessionzero.
"""
compatible_game_systems = ["dnd5e", "generic"]

[dependencies]
some-other-package = ">=1.0.0"

id is the package identifier used everywhere else in this document as the leading segment of package.sztype.id. version is a plain semver string. compatible_game_systems is an arbitrary list of strings with no fixed vocabulary; it exists purely so sessions and the Library can filter packages by declared system, and two packages are free to agree or disagree on naming with no validation between them.

[dependencies] lists other package ids this package's objects reference (via $other-package.thing.id), each mapped to a version constraint string. Constraints follow standard semver comparator syntax (>=1.0.0, ^1.2.0, ~1.2.0, or an exact 1.2.0). A package with no external references omits the table or leaves it empty. Resolving these constraints against what's actually installed, and pulling missing dependencies from the Library, is a loader concern outside the file format itself.

14. Sessions

A session is a local directory (optionally synced online, per the four session modes) with its own manifest, session.szdf:

id = "curse-of-the-lost-mine"
name = "Curse of the Lost Mine"
description = """
A short introductory campaign.
"""
compatible_game_systems = ["dnd5e"]
player_template = $custom-package.player

[datapacks]
szcore = ">=1.0.0"
custom-package = ">=1.0.0"

compatible_game_systems filters which datapacks are offered as relevant when adding to the session; it is compared against each candidate package's own compatible_game_systems. player_template is a reference literal pointing at the one schema every player instance in this session must be created from. [datapacks] lists the packages included in the session, same constraint syntax as a package's own [dependencies].

A session's own data (config for future tools, chat state, turn order, whatever comes later) lives as further freeform fields and tables in this same manifest or in additional files under the session directory, per section 10.

15. Player instances

player is the one sztype that never belongs to a package. A player instance is not addressed as package.sztype.id; it has no package at all. Instead of a package field, its header carries template, a reference to whichever schema it's built from:

sztype = "player"
template = $custom-package.player
id = "jeff"
description = """
A player named Jeff
"""

[info]
name = "Jeff"
level = 10
class = $szcore.class.ranger
race = $szcore.race.human
background = "Former blacksmith"

[stats]
maxHp = 30
currentHp = 30
str = 20
dex = 20
int = 20
wis = 20

[inventory]
items = [$szcore.item.rock]

[background]
nickname = "Jeffrey the Bold"

jeff binds to custom-package.player because template says so explicitly, not because of any package field, since there isn't one. jeff.stats.ac still resolves through that schema's inherited computed field exactly as it would for an ordinary instance, reading jeff.stats.modifiers.dex, itself another inherited computed field reading jeff.stats.dex. [background] is a freeform table jeff added on top of everything the template declares.

Where the file lives determines its scope, and scope determines its address:

  • Inside a session, under players/, it belongs to that session and is addressed through the session's own lookup: players.jeff, not a global package.sztype.id path. Two different sessions can each have a players.jeff with no collision, since the session itself is the namespace.
  • Outside any session, under the application's local/players/, it's a personal roster entry not tied to a session yet, addressed as local.players.jeff. This is what the data management mode writes to when a player builds a character ahead of time, or a GM preps an NPC before a session exists to hold it. It can later be copied into a session's players/ folder to actually be used at the table.

Everything else about a player instance, its computed fields, readonly fields, freeform additions, resolves exactly as any other instance bound to a schema (section 3), just with template standing in for the package lookup that ordinary instances use.