Skip to content

The format

This is the specification of format 1: one TOML format for 3D scenes, prefabs, material libraries and their projects, which an engine and an editor both read and write. It is pure data: no engine type, no GPU and no clock. pfx-scene parses it, checks it, writes it back keeping comments, spacing and order, and migrates older files. An engine loads a checked project into its own types; an editor edits it through the crate’s writer; a build tool emits it.

file holds
project.toml the project root’s marker: format, [project] (name; scene, the scene an editor opens first; and ignore, the folders not to look in), [tunables.<name>] (the game’s tunables), [layers] (the collision layers) and [authoring]. It lists no files: editors find the scenes as the *.scene.toml files under the root, outside the folders skipped (see Project::open).
*.scene.toml a scene: its includes, material libraries, meshes, objects, lights, emitters, movers, content, text, sounds, camera rigs, tile layers, sun, sky, haze, camera, finish, trace, plates and physics.
*.prefab.toml a reusable piece: the entry tables a scene has (meshes, objects, lights, emitters, movers, content, text, sounds, rigs) and its own materials and include, with no [[tiles]], fallback, [sun], [sky], [haze], [camera], [finish], [trace], [plates] or [physics].
*.materials.toml a material library: [materials.<name>] tables.
*.proxies.toml a plate’s invisible stand-ins: [[proxy]] entries.

The kind of a file comes from its name: project.toml, *.scene.toml (and a format 0 scene.toml), *.prefab.toml, *.materials.toml and *.proxies.toml. A format 0 material library may keep any name (materials.toml); it is read as a library because a scene’s materials names it.

Every file starts with format = 1. A file without format is format 0 and is read through the migration below.

Assets are referenced by path, never embedded: glTF 2.0 (.glb, or .gltf with its buffers beside it) for meshes and animation, PNG (KTX2 later) for images and sprite atlases, WAV or Ogg Vorbis for sound, TTF or OTF for fonts, Radiance .hdr for skies.

  • Every path is relative to the project root, whichever file names it. An empty path, an absolute path and a path with a backslash are refused (bad-path), and so is a path that leaves the root with .. past it (outside-root). A .. that stays inside the root is allowed.
  • The project root is the nearest folder, from the opened file upward, that holds a project.toml. Without one, the root is the opened scene’s own folder.
  • Paths use / and keep their case. A path the checker finds only with another case is refused (path-case), so a project reads the same on Linux, Windows and macOS.
  • A named file must exist (missing-file). The exceptions are [plates] dir, a folder a bake writes, and the paths of [project] ignore: they may not exist yet.
  • Every entry that something can refer to carries an id: each mesh, object, light, emitter, mover, content, text, sound, rig, tile layer, material, prefab placement and proxy.
  • An id is 10 lowercase Crockford base32 characters (0-9, a-z without i, l, o and u), 50 bits, such as id = "7k2m9q4xzr". It has no clock part. Any other text is refused (bad-id).
  • A writer makes an id random (an editor, when a person adds an entry, passes its own random bits: Id::from_bits keeps the low 50), or derives it from a stable key it owns (a build, which must give the same bytes for the same input): Id::derive takes the first 50 bits of the SHA-256 of that key, such as the authoring file’s project-relative path and the entry’s name. The crate reads no clock and no system randomness. Either way the writer checks that the id is unique in the project.
  • An id is written once, when the entry is made, and never changes: renaming, moving or re-parenting keeps it. A tool that derives ids keeps them across renames by recording the id in its own source.
  • The checker refuses an id used twice anywhere in the project, naming both places (duplicate-id).
  • A hand-written entry may leave id out. The checker warns (missing-id), and the first write of that file through pfx-scene (an editor’s save, a fix) adds one, derived as the migration derives them (below). Nothing else writes ids.
  • Names are labels. name on a list entry, and the key of a keyed table ([mesh.<name>]), are free text for people and may change. A list entry’s name is required, non-empty, and has no space at either end; a material name also holds no control character (bad-name). Names stay unique within their kind across a scene and its includes (duplicate-name), so that a name can still stand for an id.

A key that refers to another entry takes that entry’s id or, while it is unique in its kind, its name. Editors write ids. A reference that matches neither is refused (bad-reference), and one that matches a name used twice is refused with both places (ambiguous-reference). The keys that refer:

key refers to
fallback a material
[mesh.*.nodes.*] material a material
[[object]] mesh a mesh
[[object]] parent an object
[[object]] material, the values of materials materials
[[object]] content a content
[[object]] prefab a prefab file’s path, or a placed prefab’s id (the same prefab as that placement)
[[mover]] objects objects
[sound.*] object an object with a body
[[rig]] target an object
[[tiles]] palette values a prefab file’s path, or a placed prefab’s id (the same prefab as that placement)
[[proxy]] object a static object

A layer name ([object.body] layer, [object.character] layer, [object.trigger] layer and mask) is not an entry: it names a layer of [layers] in project.toml, by its name only, and one that names none is refused (bad-reference), as is any layer name in a project without [layers].

A clip name and a tile are not entries either. A sprite’s clip and an event’s clip name a clip of its [object.animation.clips], and a character of a tile layer’s rows or a tile of its cells names a key of its palette; one that names none is refused (bad-reference). A glTF clip name is the loader’s to check, against the object’s glTF file.

A reference resolves among the entries of the scene and its includes. Materials resolve among every library the scene, its includes and its placed prefabs name. A proxy’s object resolves among the objects of each scene whose [plates] proxies names its file, as that scene resolves (includes merged, prefabs placed): by id, a placed object’s namespaced id included, or by its key in that scene (its name, or "<placement name>/<inner name>" for a placed object). A proxies file that no scene names resolves across the project instead, by id or by a name unique in the project.

A prefab is a *.prefab.toml file, placed as an object:

[[object]]
id = "q8w2e6r4ty"
name = "left lamp"
prefab = "props/lamp.prefab.toml"
at = [-1.2, 0.0, 0.4]
rotate = [0.0, 30.0, 0.0]
parent = "desk"
[object.set]
"shade.material" = "brass"
"bulb.intensity" = 3.0
  • A placement takes only id, name, prefab, set, at, rotate, scale, parent, hidden, dynamic and authoring; any other key is refused (placement-key). An object without prefab needs mesh.
  • A placement is a group with no mesh. Its transform is the parent of every root object in the prefab: the prefab’s root objects take the placement as their parent. Its hidden applies to every object it places. Its dynamic, like any object’s, reaches the objects under it that don’t set their own.
  • A placement carries no body, character, trigger or animation of its own. A body or a character in a prefab is placed with it and starts at its world transform, the placements’ transforms times its own ([object.body] and [object.character], under [[object]]).
  • [object.set] overrides any key of any entry in the prefab: its keys are "<entry>.<key path>", and its values replace the prefab’s. <entry> is the inner entry’s id, or its name when that name is unique in the prefab and holds no .; an inner entry of a nested placement is written "<placement id>/<inner id>". The key path is the entry’s keys joined by ., where a number picks an entry of a list, counting from 0 ("lamp.layers.1.frequency"), as the writer’s key paths do. An override may set a key the entry takes but does not hold yet. An override that names no entry, names a key that entry doesn’t take, or gives a value of the wrong type is refused (bad-override).
  • Override values are read as the prefab’s own: a reference in an override resolves inside the prefab.
  • An override only replaces values. Adding entries to a placed prefab is what a nested placement or the prefab’s own include is for; format 1 has no other way.
  • A prefab is self-contained: its references resolve inside the prefab and its includes, and its materials among its own libraries.
  • Inside a placement, inner ids are namespaced "<placement id>/<inner id>", so the same prefab placed twice gives distinct, stable ids. A reference from outside reaches an inner entry by that namespaced id.
  • A prefab may place other prefabs, nested at most 64 deep (MAX_DEPTH): a scene’s placement is one deep, a placement inside it two. A placement past that depth is refused at its prefab key (prefab-depth), and so is a cycle, a prefab that places itself through any chain of placements (prefab-cycle). Both diagnostics name the chain of files in their message and list each placement of it in related.
  • A build tool’s own composition (a file placed with a prefix, at, rotate, scale and parent) maps onto a placement one to one; the prefix becomes the placement’s name.

include = ["…", …] merges other files in: an include’s entries come before the including file’s own, includes in order, depth first. Includes nest at most 64 deep (MAX_DEPTH), counting from the scene or prefab being read: an include past that depth is refused at its key (include-depth), and so is a cycle, a file that includes itself through any chain of includes (include-cycle). Both diagnostics name the chain of files in their message and list each include of it in related. A scene’s include names *.scene.toml files; a prefab’s include names *.prefab.toml files. At most one file of a scene and its includes holds fallback, [sun], [sky], [haze], [camera], [finish], [trace], [plates] and [physics]; a second is refused, naming the first (held-twice). A prefab is not an include: it is placed, transformed and namespaced.

  • Every table refuses an unknown key, naming the file, line, column and the allowed keys (unknown-key).
  • Two keys are the exception. A top-level [authoring] table in any file, and an authoring key (any TOML value) inside any entry, belong to the tool that authored the file. pfx-scene checks only that they are TOML, ignores them on load, and keeps them byte for byte on write.
  • Only entries take authoring (a mesh, object, light, emitter, mover, content, text, sound, rig, tile layer, material, prefab placement or proxy). Node overrides, sky layers, room lights, finish passes, bodies, characters, triggers, animations, tile cells, tunables and layers take neither id nor authoring.
  • Authoring tools keep their own sources (recipes, generators, variants, parameters) and emit this format; they may record their provenance in [authoring], such as recipe = "<path>" and variant = "<name>", so their files round-trip.

Colours are linear RGB under color. Angles are degrees. Lengths are scene units (metres by convention), speeds scene units per second, and times seconds, except where a key counts ticks of the play clock ([physics] rate) or a sprite’s frames (fps, frames per second). Y is up, and a right-handed scene looks down −Z. A model is T·R·S, with rotate about X, then Y, then Z.

Each table below lists every key it takes. Type uses TOML terms: a number is an integer or a float, and [x, y, z] is an array of three numbers. Where a table gives a range, a number outside it (or not finite) is refused (bad-value). Default is the value when the key is left out; needed means the key must be there. A key whose default is described (“the object’s”) is resolved by the loader.

The crate checks structure, values, references, ids, and that each asset path exists with its exact case. It does not read the assets’ contents: glTF nodes and the node names in node, nodes and materials, and the decoding of images, sounds, fonts and HDR skies stay with the loader that reads them.

key type default notes
format integer 0 1.
[project] table needed
[tunables.<name>] tables none the game’s tunables.
[layers] table none the collision layers.
[authoring] table none the authoring tool’s.

[project]:

key type default notes
name string needed the project’s name.
scene path none the scene an editor opens first.
ignore array of paths [] folders (or files) Project::open skips, besides target and tmp; each relative to the root, so "builds" is the root’s builds folder.

[tunables.<name>] is one value the game reads at run time and a person tunes while it runs, such as a jump height or the gravity. The table’s key is its name, which the game reads it by: it follows the name rules (bad-name), and a name with a space or a . is quoted ([tunables."god mode"]). A tunable takes no id: the game’s code names it, so renaming one changes that code too.

key type default notes
type "float", "int", "bool" or "vector" needed a number, a whole number, true or false, or [x, y, z].
default the tunable’s type needed the value the game starts with. A float takes a whole number too, read as a float.
min the tunable’s type none float, int and vector only; at most default and max, per component for a vector.
max the tunable’s type none float, int and vector only; at least default and min, per component for a vector.
group string none the heading an inspector lists the tunable under; a name, as above.

A value of another type than type is refused (bad-type), as is a vector of other than three numbers. A min or max on a bool, a value that is not finite, a min above max and a default outside them are refused at that key (bad-value).

[tunables.jump_height]
type = "float"
default = 2.0
min = 0.5
max = 6.0
group = "Movement"
[tunables.gravity]
type = "vector"
default = [0.0, -9.81, 0.0]
group = "Movement"
[tunables.lives]
type = "int"
default = 3
min = 1
[tunables."god mode"]
type = "bool"
default = false
group = "Debug"

Project::read of project.toml gives them as ProjectFile::tunables, by name: each a Tunable whose default, min and max are TunableValues of its type (Float(f32), Int(i64), Bool(bool) or Vector([f32; 3])).

[layers] names the project’s collision layers, at most 32, and which of them meet. Each key is a layer’s name (the name rules apply, bad-name), and its value lists the layers it collides with. A layer may list itself. Meeting goes both ways, so the table is symmetric: when player lists pickup, pickup lists player. A name the table doesn’t hold (bad-reference), a name listed twice in one list, a pair listed on one side only (each at its place in the list) and a 33rd layer are refused (bad-value).

[layers]
world = ["world", "player", "enemy"]
player = ["world", "enemy", "pickup"]
enemy = ["world", "player"]
pickup = ["player"]

A body, a character and a trigger take a layer by its name (layer), and a trigger the layers it senses (mask). One without layer is on every layer: it meets every layer that lists any, as everything meets everything in a project without [layers]. Which bit a layer takes is the engine’s; the crate gives the layers by name, as ProjectFile::layers and Scene::layers (a BTreeMap of each name to its list).

key type default notes
format integer 0 1.
include array of paths [] *.scene.toml files merged in (Includes).
materials array of paths [] material libraries. A material name in two libraries is refused, naming both files (duplicate-name).
fallback reference none the material for a glTF material no library names; without it the loader’s default material. At most one file of a scene and its includes holds it.
[mesh.<name>] tables none meshes.
[[object]] tables none objects and prefab placements.
[[light]] tables none local lights.
[[emitter]] tables none glowing spheres.
[[mover]] tables none preview motions.
[content.<name>] tables none images shown in a material’s content layer.
[text.<name>] tables none text blocks.
[sound.<name>] tables none sounds.
[[rig]] tables none camera rigs a game drives.
[[tiles]] tables none tile layers: grids of prefab placements.
[sun], [sky], [haze], [camera], [finish], [trace], [plates], [physics] table none each held by one file of a scene and its includes at most.
[authoring] table none the authoring tool’s.

A glTF material resolves to the library entry of its name, else of its name before the first . (.001 suffixes), else fallback.

key type default notes
format integer 0 1.
include array of paths [] *.prefab.toml files merged in.
materials array of paths [] the prefab’s material libraries.
[mesh.<name>], [[object]], [[light]], [[emitter]], [[mover]], [content.<name>], [text.<name>], [sound.<name>], [[rig]] tables none as in a scene.
[authoring] table none the authoring tool’s.
key type default notes
id id none
file path needed a .gltf (buffers read beside it) or .glb.
node string none the node whose subtree this mesh is. Its own placement in the file is dropped; the object places it. Without node, every root of the file’s default scene, with their transforms.
nodes tables none [mesh.<name>.nodes."<node name>"]: overrides of one node each.
authoring any none

[mesh.<name>.nodes."<node name>"] overrides one node of the file (an unknown node is refused by the loader):

key type default notes
material reference the glTF material’s a material.
hidden bool false
shadow "cast", "only" or "none" the object’s
two_sided bool the object’s

The loader computes missing normals (smooth, area weighted); missing tangents are [1, 0, 0, 1] and missing UVs zero.

[[object]]: an instance of a mesh, or a prefab placement

Section titled “[[object]]: an instance of a mesh, or a prefab placement”
key type default notes
id id none
name string needed unique among the objects of a scene and its includes.
mesh reference needed without prefab a mesh. A placement takes none.
prefab path or id none places a prefab (Prefabs): a *.prefab.toml path, or a placement’s id.
set table none [object.set]: a placement’s overrides, "<entry>.<key path>" = value.
at [x, y, z] [0, 0, 0]
rotate [x, y, z] [0, 0, 0] degrees about X, then Y, then Z.
scale number or [x, y, z] 1 the model is T·R·S.
parent reference none an object: the model is the parent’s model times this one. A cycle is refused (parent-cycle).
material reference none a material for every part of the mesh.
materials table of node name = reference none { "<node name>" = "<material>", … }: per node, over material.
shadow "cast", "only" or "none" "cast" "only" casts and is never drawn; "none" is drawn and casts none.
two_sided bool false
hidden bool false not drawn; its parts are not uploaded.
clip array of [nx, ny, nz, d] [] at most two planes; a point p is cut where n·p > d. With the planes of the movers that move it, at most two.
content reference none a content: the image shown in the object’s material’s content layer. Two objects showing different content through one slot are refused by the loader.
pick unsigned integer its 1-based place among the objects the pick id. Format 0 called it id.
alpha_cutoff number, 0 to 1 0.5 texels of a cut-out material below this alpha are dropped.
face_camera bool false a card: it keeps its position and scale, and turns every frame so its local +Z faces the camera with its +Y along the camera’s up (its rotate is ignored).
dynamic bool derived whether the object moves or changes at run time: a plate leaves a dynamic object out, and a live renderer draws it over the plate ([plates]). Without the key, an object is dynamic when a [[mover]] moves it, when it is a character, when it is animated, when it faces the camera, or when its parent is dynamic; every other object is static. true marks what a game moves itself; false keeps an object static, and a character or an animated object refuses it.
body table none [object.body]: the object’s rigid body.
character table none [object.character]: a character controller that moves the object. Not with body.
trigger table none [object.trigger]: a volume that senses what enters it.
animation table none [object.animation]: the clips of its glTF, or a sprite, played on it.
authoring any none

[object.body] makes the object a rigid body in play mode. Every object above it is a prefab placement (or it has no parent), and no mover moves it or any of those placements; it does not face the camera. It starts at its world transform, the placements’ transforms times its own, and from then on the simulation moves it; its children ride with it. A body placed under an ordinary object or under a placement a mover moves is refused (bad-value), at the key that puts it there: the body when it is in the file being checked, else the parent or the mover’s objects that does, or the [object.set] key when an override does (bad-override). The finding names the body and the placement or object in its related locations.

key type default notes
kind "dynamic", "kinematic" or "fixed" "dynamic" simulated, moved by a game, or fixed.
shape "box", "sphere", "capsule" or "cylinder" "box" upright along the object’s local Y.
half [x, y, z], each > 0 half the object’s mesh bounds times its scale box only.
radius number > 0 sphere: the largest of those halves; capsule, cylinder: the larger of the X and Z halves sphere, capsule and cylinder.
half_height number > 0 capsule: the Y half less the radius; cylinder: the Y half capsule and cylinder.
offset [x, y, z] the centre of the mesh bounds times the scale the shape’s centre in the object’s frame.
density number > 0 1
friction number ≥ 0 0.5
restitution number, 0 to 1 0
velocity [x, y, z] [0, 0, 0] units per second at play.
spin [x, y, z] [0, 0, 0] degrees per second at play.
gravity_scale number 1
damping [linear, angular], each ≥ 0 [0, 0]
ccd bool false continuous collision detection for a fast body.
layer layer name none its collision layer ([layers]).

A shape takes only its own size keys: half for a box, radius for a sphere, radius and half_height for a capsule or a cylinder. The defaults that come from the mesh bounds are the loader’s.

[object.character] makes the object a character in play mode: a kinematic capsule, upright along Y, that a game drives each tick with a move and a jump. It walks up slopes and steps, keeps to the ground, rides what it stands on, and pushes the bodies it walks into. A 2d character is the same controller held in a plane, for a platformer. A character sits where a body may (on a root object or under placements, moved by no mover, not a card), and the same findings refuse it elsewhere. It takes no [object.body], and it is dynamic.

key type default notes
kind "3d" or "2d" "3d"
radius number > 0 the larger of the X and Z halves of the object’s mesh bounds times its scale the capsule’s radius.
height number > 0, at least 2 · radius the Y size of those bounds the capsule’s whole height, both caps included.
max_climb number, 0 up to (not including) 90 45 degrees: the steepest slope it walks up; it slides down a steeper one.
max_step number ≥ 0, below height 0.3 the highest step it walks onto.
snap number ≥ 0 0.2 how far below it the ground may drop, over a bump, a step down or the top of a slope, and it still keeps to it; 0 is off.
coyote integer, 0 to 1000 6 ticks after it leaves the ground in which a jump still takes off.
jump_buffer integer, 0 to 1000 6 ticks before it lands in which a jump pressed early is kept.
jump_speed number ≥ 0 5 its upward speed at take-off; 0 never jumps.
layer layer name none its collision layer ([layers]).
plane "xy" or "yz" "xy" 2d only: the plane it moves in, through its starting position: along X or along Z, with Y up.
variable_jump number, 0 to 1 0 2d only: the share of its upward speed it loses when the jump is let go while it rises; 0 jumps the same height however long the jump is held.
wall_slide number ≥ 0 none 2d only: the fastest it slides down a wall it presses into, in its fall; without it, a wall does not slow its fall.
wall_jump [away, up], each ≥ 0 none 2d only: the speeds of a jump off a wall it touches; without it, it jumps off no wall.

A 3d character takes no plane, variable_jump, wall_slide or wall_jump (bad-value). The defaults that come from the mesh bounds are the loader’s.

[[object]]
id = "h3r0000001"
name = "hero"
mesh = "hero"
at = [0.0, 0.9, 0.0]
[object.character]
kind = "2d"
radius = 0.3
height = 1.8
layer = "player"
variable_jump = 0.5
wall_slide = 2.0
wall_jump = [4.0, 6.0]

[object.trigger] makes the object a trigger: a volume, never solid, that tells a game when objects enter it, stay in it and leave it. It moves with its object, on any parent; an object with a trigger is often hidden.

key type default notes
shape "box", "sphere", "capsule" or "cylinder" "box" upright along the object’s local Y.
half [x, y, z], each > 0 half the object’s mesh bounds times its scale box only.
radius number > 0 sphere: the largest of those halves; capsule, cylinder: the larger of the X and Z halves sphere, capsule and cylinder.
half_height number > 0 capsule: the Y half less the radius; cylinder: the Y half capsule and cylinder.
offset [x, y, z] the centre of the mesh bounds times the scale the shape’s centre in the object’s frame.
layer layer name none its own layer, which queries and other triggers see it on.
mask array of layer names the layers its layer lists; every layer without layer the layers whose objects it senses.

A shape takes only its own size keys, as a body’s does.

[object.animation] plays an animation on the object, in play mode and in a live renderer: the clips of the object’s glTF file (its skeleton’s or its nodes’ animation), or a sprite, the frames of an image atlas shown on the object’s mesh (a quad, usually a camera-facing card), its UVs spanning one frame. An animated object is dynamic. A game plays other clips on it by name, and blends or crossfades them; these keys say what plays from the start.

key type default notes
clip clip name none the clip it plays from the start: an animation of the object’s glTF file, by its name, or a sprite’s clip of clips. Without clip or blend, nothing plays until a game plays a clip: the object keeps its rest pose, a sprite shows its atlas’s first frame. Not with blend.
speed number ≥ 0 1 a factor on the clip’s rate; 0 holds it still.
loop "loop", "once" or "ping-pong" "loop" at the clip’s end it starts again, stops on its last pose or frame, or plays back to its start and on, to and fro. A sprite’s clip may set its own.
start number ≥ 0 0 seconds into the clip where play starts.
blend array of { clip, weight } none glTF only, instead of clip: clips played together, the pose each gives weighted by weight (a number ≥ 0, default 1), the weights shared out by their sum, which is above 0. Not empty, each clip once. speed, loop and start apply to every clip of it.
events array of { name, clip, time, frame } [] marks a game is told of as play passes them, below.
atlas path none a PNG: the animation is a sprite, its frames cut from this image.
grid [columns, rows], integers ≥ 1 sprite: needed without frames the atlas cut into equal cells; frames count from 0, left to right, then top to bottom. Not with frames.
frames array of [x, y, width, height], integers, width and height ≥ 1 sprite: needed without grid the frames’ rectangles in pixels, from the atlas’s top left corner; they count from 0 in the order written. Not empty.
fps number > 0 12 sprite: frames per second, for a clip that sets none.
clips tables none sprite: [object.animation.clips.<name>], its named clips. Without clips, a sprite is one clip, every frame of its atlas in order, which plays from the start.

A glTF animation takes no grid, frames, fps or clips, and a sprite no blend (bad-value). A clip name follows the name rules (bad-name). The crate checks a sprite’s clip names, frames and atlas path; the names of a glTF file’s clips, and whether a sprite’s frames lie inside its atlas, are the loader’s to check, as other asset contents are.

[object.animation.clips.<name>] is one clip of a sprite: a run of its atlas’s frames.

key type default notes
from integer ≥ 0 needed its first frame.
to integer, at least from, at most the atlas’s last frame needed its last frame, included.
fps number > 0 the animation’s fps frames per second.
loop "loop", "once" or "ping-pong" the animation’s loop

An event of events:

key type default notes
name string needed what the game is told, such as "step"; the name rules apply.
clip clip name the animation’s clip the clip it marks; needed when the animation plays no clip from the start, except in a sprite without clips, whose one clip takes no name. In a sprite, a clip of clips.
time number ≥ 0 none seconds into the clip. A time past the clip’s end never comes.
frame integer ≥ 0 none sprite only: the frame of the clip, counting from 0 at its first (from), below its count of frames.

An event takes time or frame (missing-key), not both (bad-value). An event fires each time play passes its mark, once per pass, whichever way a ping-pong clip runs.

[[object]]
id = "h3r0000001"
name = "hero"
mesh = "card"
face_camera = true
[object.animation]
atlas = "art/hero.png"
grid = [8, 4]
fps = 12.0
clip = "idle"
events = [{ name = "step", clip = "run", frame = 2 }, { name = "step", clip = "run", frame = 6 }]
[object.animation.clips.idle]
from = 0
to = 3
[object.animation.clips.run]
from = 8
to = 15
fps = 16.0
[[object]]
id = "w41ker0001"
name = "walker"
mesh = "walker"
[object.animation]
blend = [{ clip = "Walk", weight = 0.7 }, { clip = "Run", weight = 0.3 }]
events = [{ name = "step", clip = "Walk", time = 0.4 }]
key type default notes
id id none
name string needed unique among the lights.
position [x, y, z] needed
color [r, g, b], each ≥ 0 [1, 1, 1]
intensity number ≥ 0 needed
radius number ≥ 0 0
range number > 0 10
shadow bool true
authoring any none
key type default notes
id id none
name string needed unique among the emitters.
position [x, y, z] needed
radius number > 0 needed
color [r, g, b], each ≥ 0 [1, 1, 1]
intensity number ≥ 0 needed it radiates intensity / (π radius²) per channel.
authoring any none
key type default notes
id id none
name string needed unique among the movers.
objects array of references needed, not empty what it moves, with their children. An object is moved by one mover at most.
kind "turn" or "slide" needed degrees about axis through pivot, or units along axis.
pivot [x, y, z] [0, 0, 0] turn only, in the scene’s rest frame. A slide takes no pivot.
axis [x, y, z], not zero needed
travel [from, to] needed degrees or units.
period number > 0 4 seconds.
motion "swing", "loop" or "once" "swing" swing: from, to and back, eased by (1 − cos 2πu) / 2; loop: from to to, then again; once: from to to, then stays.
offset number 0 seconds added to the time.
clip array of [nx, ny, nz, d] [] world planes that cut what it moves, fixed while it moves; at most two, and at most two with each moved object’s own.
authoring any none

At time t, u = (t + offset) / period and the mover stands at from + (to − from) · f(u). A child’s own mover applies first, then its parent’s, up the chain.

key type default notes
id id none
image path needed a PNG.
authoring any none
key type default notes
id id none
text string needed the words.
font path needed a .ttf or .otf.
size number > 0 needed in scene units per em.
color [r, g, b, a], each ≥ 0 [1, 1, 1, 1]
at [x, y, z] [0, 0, 0] the block’s centre.
rotate [x, y, z] [0, 0, 0] as an object’s.
lit bool false unlit text glows its colour; lit text is shaded by the scene.
dynamic bool false text a game changes or moves is drawn live over a plate; static text is baked into it.
authoring any none

The block reads along its local +X with +Y up and faces +Z.

key type default notes
id id none
file path needed a WAV or an Ogg Vorbis file.
volume number ≥ 0 1
pan number, −1 to 1 0
loop bool false loops as the music track; one looping sound per scene at most.
play "start", "hit" or "game" "start" when play starts, when object’s body starts a contact, or when a game plays it.
object reference needed for "hit", refused otherwise an object with a [object.body].
authoring any none

A rig moves the camera in play mode, each frame, from its target and the game’s look input. A scene may hold several; the game picks the one that drives the camera, by name or id, and may hand it another target. A renderer without a game uses [camera].

key type default notes
id id none
name string needed unique among the rigs.
kind "follow", "first-person" or "third-person" needed
target reference none the object it follows or looks from at the start.
offset [x, y, z] follow: [camera] at less the target’s position at rest; first-, third-person: [0, 0, 0] follow: the camera’s place from the followed point; first-person: the eye from the target’s origin; third-person: the point it orbits, from the target’s origin.
dead_zone [across, up], each ≥ 0 [0, 0] follow: how far the target moves from the followed point, across and up the view, before the camera follows.
look_ahead number ≥ 0 0 follow: how far ahead of the target the followed point leads, in its direction of travel.
damping [x, y, z], each ≥ 0 [0, 0, 0] follow: seconds, per world axis, in which the camera closes most of its gap to where it should be; 0 keeps up at once.
bounds { min = [x, y, z], max = [x, y, z] }, min at most max on each axis none follow: the box the camera’s position never leaves.
orthographic number > 0 none follow: the camera views orthographically, this many units high; without it, it keeps [camera]’s projection.
snap number > 0 none follow, with orthographic: the art’s pixels per unit; the camera moves by whole art pixels.
sensitivity number > 0 1 first-, third-person: a factor on the look input.
invert bool false first-, third-person: looking up and down is turned over.
smoothing number ≥ 0 0 first-, third-person: seconds in which the look closes most of its gap to the input; 0 is off.
pitch [min, max], each −90 to 90, min at most max [-85, 85] first-, third-person: the degrees the look turns down and up to.
head_bob number ≥ 0 0 first-person: the height of the eye’s bob as the target walks, growing with its speed; 0 is off.
distance number > 0 4 third-person: how far behind the orbited point the camera sits.
collide bool true third-person: the camera stops short of what lies between it and the orbited point.
authoring any none

A follow rig keeps [camera]’s view direction and up and moves the camera, from the target and its offset, dead_zone, look_ahead, damping and bounds; a 2d game’s follow rig is usually orthographic. A first-person rig puts the eye at the target’s position plus offset and turns the view with the look input. A third-person rig orbits the target’s position plus offset at distance, turned by the look input. A rig refuses the keys of the other kinds (bad-value).

[[rig]]
id = "s1dec4m001"
name = "side"
kind = "follow"
target = "hero"
offset = [0.0, 1.0, 10.0]
dead_zone = [0.5, 0.25]
look_ahead = 1.5
bounds = { min = [-20.0, 0.0, 10.0], max = [20.0, 8.0, 10.0] }
orthographic = 9.0
snap = 16.0

A tile layer is a grid in a plane whose cells place prefabs: a platformer’s level, or a grid level’s floor plan. Each filled cell is a placement of its tile’s prefab, unturned and unscaled, at the cell’s position, as an [[object]] with prefab places one (Prefabs). A scene and its includes may hold several layers; a prefab holds none.

key type default notes
id id none
name string needed unique among the tile layers.
cell number > 0, or [across, up], each > 0 1 a cell’s size along the plane’s two axes.
origin [x, y, z] [0, 0, 0] where cell [0, 0] places its prefab.
plane "xy", "xz" or "yz" "xy" the plane the layer lies in, below.
palette table of tile = prefab {} each tile’s key and the prefab it places: a *.prefab.toml path, or a placement’s id (that placement’s prefab).
rows array of strings none the cells as a picture: one string per row, one character per cell, . for an empty one. The last string is row j = 0 and each string above it one row further; a string’s first character is i = 0. Not with cells.
cells array of { at = [i, j], tile } none the filled cells as a list: at, two integers, which may be negative, and tile, a palette key. Each cell at most once. Not with rows.
authoring any none
plane i runs along j runs along seen from
"xy" +X +Y +Z: the side, Y up.
"xz" +X −Z +Y: above, with −Z ahead.
"yz" −Z +Y +X: the side, Y up.

Cell [i, j] places its prefab at origin + i · across · I + j · up · J, where I and J are the directions i and j run along and across and up the sides of cell. Seen from where the table says, i runs right and j up, so rows reads as the layer looks.

A palette key holds no space and is not . (bad-name); in a layer with rows, each key is one character (bad-value). A character of rows or a tile of cells that names no palette key is refused at that character or key (bad-reference), and so is a palette id that names no placement of the scene. A palette path is checked as any path is, and must name a *.prefab.toml; each palette prefab is checked with the scene.

A cell’s placed entries take no id or name the scene can refer to: no reference, override or mover reaches them. A body in a tile’s prefab sits under its cell’s placement, which no mover moves.

[[tiles]]
id = "gr0nd00001"
name = "ground"
cell = 0.5
origin = [-4.0, 0.0, 0.0]
palette = { "#" = "tiles/brick.prefab.toml", "^" = "tiles/spike.prefab.toml" }
rows = [
"....^...",
"########",
]
[[tiles]]
id = "f100r00001"
name = "floor plan"
cell = [2.0, 3.0]
plane = "xz"
palette = { wall = "tiles/wall.prefab.toml" }
cells = [{ at = [0, 0], tile = "wall" }, { at = [-1, 2], tile = "wall" }]
key type default notes
model "daylight" or "authored" needed
hour number, 0 to 24 daylight: needed; authored: reference_hour
day number, 1 to 365 172 day of the year.
latitude number, −90 to 90 45
heading number, 0 to 360 180
reference_hour number, 0 to 24 12 the hour of intensity 1.
toward [x, y, z], not zero authored: needed the direction towards the sun, as authored at reference_hour. Daylight refuses it.
color [r, g, b], each ≥ 0 authored: [1, 1, 1] Daylight refuses it.
irradiance number ≥ 0 authored: needed Daylight refuses it.
radius number, 0 to 10 0 the sun’s angular radius in degrees: a soft sun.

An authored sun follows hour: at hour = reference_hour (the default) it is exactly as authored, and away from it the sun turns, lowers, warms and dims as the day does. Without [sun] the sun is dark.

key type default notes
kind "analytic", "hdr", "room" or "mix" needed
path path hdr: needed the Radiance .hdr file. hdr only.
rotation_deg number 0 hdr and room: turns the sky by its negative.
intensity number ≥ 0 1 hdr and room: scales it.
turbidity number, 1 to 10 3 analytic only.
ground_albedo [r, g, b], each ≥ 0 [0.2, 0.2, 0.2] analytic only.
ambient number ≥ 0 1 analytic only, with an authored sun: the sky’s level.
prepare table none [sky.prepare], hdr only.
room table room: needed [sky.room], room only.
layer tables none [[sky.layer]], mix only: two or more.

An analytic sky takes turbidity, ground_albedo and ambient and follows the sun; an hdr sky takes path, rotation_deg, intensity and [sky.prepare]; a room sky takes [sky.room], rotation_deg and intensity; a mix sky takes only [[sky.layer]]. Any other key of [sky] is refused for that kind (bad-value). Without [sky] the sky is black.

[sky.prepare] prepares the HDR before it turns, in this order; it needs at least one key:

key type default notes
cap number > 0 none caps the luminance.
balance [r, g, b], each > 0 none balances to this colour.
mean number > 0 none scales to this mean luminance.

[sky.room] is a room lit by lamps, rendered into the sky:

key type default notes
width integer, 2 to 8192 64 the sky’s width in texels; its height is half.
floor [r, g, b], each ≥ 0 [0.5, 0.5, 0.5]
wall [r, g, b], each ≥ 0 [0.5, 0.5, 0.5]
ceiling [r, g, b], each ≥ 0 [0.5, 0.5, 0.5]
lights tables none [[sky.room.lights]].

[[sky.room.lights]]:

key type default notes
name string "" a label.
az number 0 azimuth, degrees.
el number 0 elevation, degrees.
power number ≥ 0 1
color [r, g, b], each ≥ 0 [1, 1, 1]
width number, above 0 and below 180 45 degrees.
height number, above 0 and below 180 45 degrees.
soft number ≥ 0 0.1 edge softness.
slats number ≥ 0 0 blind slats across the lamp.
open number 1 how open the slats are.

[[sky.layer]] is one sky of kind analytic, hdr or room, with that kind’s keys and its own [sky.layer.prepare] or [sky.layer.room], and one more key:

key type default notes
weight number ≥ 0 1 the layer’s share; the mix is the weighted sum, not renormalised. Only a layer takes it.

A layer is not a mix and holds no layers. The mix is baked once, on load, into one HDR.

key type default notes
lo [x, y, z] needed the haze’s lower bounds.
hi [x, y, z] needed its upper bounds; lo is below hi on every axis.
amount number, 0 to 1 0 sets the four densities below; without it the haze is clear.
fog number ≥ 0 amount / 6
smoke number ≥ 0 amount · 20
mist number ≥ 0 amount · 0.2
floor number ≥ 0 amount · 2
phase number, −0.9 to 0.9 0.3
back number ≥ 0 0.5
reach number ≥ 0 3
gold [r, g, b], each ≥ 0 [1, 1, 1]
ambient [r, g, b], each ≥ 0 [0.03, 0.03, 0.03]
unmapped number ≥ 0 0
seed unsigned integer 0
key type default notes
projection "perspective" or "orthographic" "perspective"
at [x, y, z] [0, 0, 5] differs from look_at.
look_at [x, y, z] [0, 0, 0]
up [x, y, z] [0, 1, 0] not along the view.
fov number, above 0 and below 180 40 perspective: vertical degrees. Not with focal.
focal number > 0 none perspective, instead of fov: focal length in mm.
sensor number > 0 24 perspective, with focal: sensor height in mm. Without focal it is refused, unless fov is given.
height number > 0 orthographic: needed the view’s height in scene units. A perspective camera refuses it.
near number > 0 0.05
far number > near 100
shift [x, y] [0, 0] lens shift in halves of the view, right and up (0.5 moves the picture a quarter of its width).
fstop number > 0 none perspective: depth of field through a thin lens of the camera’s focal length at this f-number. Without it everything is sharp.
focus number the distance from at to look_at perspective, with fstop only: the focus distance in scene units, beyond the focal length.
preset table none [camera.preset].

An orthographic camera refuses fov, focal, sensor, fstop and focus.

[camera.preset] tunes the camera director over its defaults. Angles are degrees; every value is a number unless said.

key keys defaults
gain a number 1
orbit stiffness, yaw, pitch, hold 4, 0.5°, 0.25°, 2
look stiffness, lean, yaw, pitch, sign 3, 0.2, 0.6°, 0.4°, 1
zoom stiffness, hold, max, rate 3, 2, 2, 1.1
wander idle, every, hold 8, 12, 3
nudge stiffness, pitch, hold, floor 30, 0.1°, 0.2, 0
hush floor, stiffness, blocks_cursor (bool) off; when given, all three are needed
depth focus, blur, floor off; when given, all three are needed

Each sub-table is optional and each of its keys tunes one default.

The image’s finish: a chain of post passes. [finish] takes either file, a finish file, or the finish keys inline; [[finish.pass]] may follow either.

key type default notes
file path none a finish file. With file, [finish] holds no inline key.
the finish keys below none inline.
pass tables none [[finish.pass]].

Inline keys build the chain as the finish file does: a style’s passes first, then each key adds or tunes its pass, keys taken in name order, not in the order written. For an order of your own, list passes in [[finish.pass]]: they apply in the order written, after the base (the file’s chain, or the style and inline keys). Each [[finish.pass]] is a table of finish keys that builds its passes from nothing, usually one key or the keys of one pass (cel_bands with cel_shadow). A pass that builds nothing (empty, or only encode = false), or names style, seed, frame or file, is refused. Without [finish] the engine’s standard finish applies.

A finish file holds the finish keys at its top level, and nothing else: no format, no file, no pass.

[finish]
exposure = 1.1
[[finish.pass]]
warmth = 0.2
[[finish.pass]]
tone = "agx"

The finish keys. A colour is "#rrggbb" (sRGB, read as linear) or [r, g, b]. A whole number is a number ≥ 0 that is rounded. Every number is finite.

key type notes
aberration number chromatic aberration.
angle number halftone screen angle, degrees.
black number black level.
black_threshold number modern comic: black threshold.
bloom number or table a number is the bloom’s strength; the table below.
cavity number cavity shading’s strength.
cavity_distance number cavity shading’s distance.
cel_bands whole number cel shading’s bands.
cel_shadow number cel shading.
cel_softness number cel shading.
cel_spec number cel shading: specular.
cel_spec_roughness number cel shading.
cel_spec_threshold number cel shading.
cel_threshold number cel shading.
contrast number contrast.
crease_angle number outline crease angle, degrees.
crush number noir: black crush.
curvature number screen curvature.
darkening number watercolour edge darkening.
dither bool or number dither amplitude; true is 1, false is 0.
dither_kind "blue" or "ordered" one-bit dither pattern.
distortion number lens distortion.
dot number halftone cell size.
dust number film dust.
encode bool true adds the encode pass; false adds nothing.
exposure number exposure.
flicker number film flicker.
focus number tilt-shift focus.
frame unsigned integer the frame for animated passes. Not in a pass.
gain number ACES tone gain.
grain number or table a number is the grain’s strength; the table below.
halation number halation strength.
highlight colour duotone highlight.
highlight_threshold number modern comic.
ink number or colour a number is comic ink; a colour is halftone ink.
interior_line_strength number modern comic.
keep colour noir: the colour kept.
keep_range number noir: how much of it.
levels number posterize (or comic) levels.
line_weight number modern comic.
lut table a colour lookup table, below.
mask number aperture grille.
outline bool or table turns the outline on or off; the table below.
outline_alpha number outline opacity.
outline_color colour outline colour.
outline_thickness number outline thickness.
palette bool pixel art: palette on or off.
paper_grain number paper grain (or watercolour grain) strength.
pixel unsigned integer pixel size; 0 reads as 1.
posterize number the same as levels.
radius number the radius of the bloom, kuwahara, halation, neon or tilt-shift pass already in the chain; without one, a kuwahara pass.
rim number rim light strength.
rim_color colour rim light colour.
rim_width number rim light width.
saturation number saturation.
saturation_lift number modern comic.
scale number paper grain or watercolour scale.
scanlines number scanline strength.
scratches number or table a number is the scratches’ strength; the table below.
seed unsigned integer the chain’s seed. Not in a pass.
sepia number sepia.
shadow colour duotone shadow.
style a style name a style’s passes first. Not in a pass.
tape "forward", "rewind", number or table a tape look: a direction, a strength, or the table below.
threshold number bloom threshold.
tilt_range number tilt-shift range.
tone "aces", "agx", "neutral" or table the tone map; the table below.
tone_steps number, 3 to 5 modern comic tone steps.
vignette number or table a number is the vignette’s strength; the table below.
warmth number or table a number is the warmth’s amount; the table below.
weave number film weave.
weights [r, g, b] black-and-white or saturation weights.

The style names: cel, bw, noir, vignette, neon, rubber_hose, sepia, film, crt, one_bit, halftone, comic, modern_comic, watercolor, paper_grain, pixel, duotone, gradient_map, posterize, kuwahara, tilt_shift.

The finish keys’ tables (each key optional unless said):

table keys
bloom strength, threshold, knee, knee_width, soft, sigma, clamp, unit (numbers); radius (whole number); down (whole number; 0 reads as 1); linear_taps (bool); radii, gains (four numbers each); kind ("box", "rings" or "tent")
grain strength, response (numbers); clamp (bool)
lut size (integer, 2 to 32, needed); values (size³ · 3 numbers, red, green and blue per entry, needed)
outline enabled, local_color (bools); thickness, alpha, crease_angle (numbers); color (colour)
scratches count (unsigned integer); strength (number)
tape direction ("forward" or "rewind"); strength (number ≥ 0, read up to 1); bands, echo, grain, smear, split, wash (numbers ≥ 0)
tone kind ("aces", "agx" or "neutral", needed); start (number, neutral only, default 1); clamp (bool, neutral only, default false)
vignette strength, power, aspect, scale, inner, outer (numbers); kind ("power" or "smoothstep")
warmth amount, shadow, highlight (numbers); low, high (two numbers each); kind ("linear" or "luma_curve")

A tape number is a strength ≥ 0, read up to 1.

Settings only a path tracer reads; a live renderer ignores them.

key type default notes
transmissive_shadows bool false shadow rays pass glass and liquid surfaces, tinted, instead of stopping at them.
clamp_indirect number, 0 to 10000 0 (off) the luminance cap on each light contribution reached after the first bounce; a brighter one is scaled down to the cap, keeping its hue. Light at the first hit is never clamped.
filter_glossy number, 0 to 1 0 (off) the least roughness at every vertex after a diffuse bounce: once a path has bounced off a diffuse surface, sharper lobes are traced at least this rough. The first hit, and a chain of mirror, glossy or glass bounces from the camera, keep their own roughness.

Both remove fireflies, the lone bright pixels a sharp reflection seen in diffuse light makes, at a cost to the look: clamp_indirect takes energy from bright caustics and from what a mirror or glass reflects, so it belongs well above the sunlit brightness of the brightest diffuse surface; filter_glossy keeps the energy but softens glints and caustics. filter_glossy = 0.25, with clamp_indirect as a backstop, is a good start for a small sun.

The baked plates of the scene’s static part. A bake traces the static objects and the static text into images once; a live renderer composites those images and draws only what is dynamic ([[object]] dynamic, [text.*] dynamic) over them. A renderer that reads no plates ignores the table.

key type default notes
dir path needed the plate folder a bake writes. It need not exist yet: a missing folder is no error.
proxies path none a *.proxies.toml file: the static objects’ invisible stand-ins.
casters "meshes", "proxies" or "none" "meshes" what casts shadows on the dynamic objects in the static objects’ place: each static object’s own mesh, the proxies, or nothing. "proxies" needs proxies.

Play mode’s rigid world; renderers ignore it.

key type default notes
gravity [x, y, z] [0, -9.81, 0]
rate number, 1 to 1000 60 the play clock’s ticks per second and the rigid world’s steps per second.
substeps integer, 1 to 16 1 solver steps per step.
iterations integer, 1 to 64 4 solver iterations per substep.
key type default notes
format integer 0 1.
[materials.<name>] tables none one material each. A name with / or . is quoted ([materials."set/blue"]).
[authoring] table none the authoring tool’s.

[materials.<name>] is one physically based material. Every key has a default, and the values have no enforced range beyond the layer count; the usual ranges below say what makes sense.

key type default usual
id id none
family a family name "plain"
base [r, g, b] [0.5, 0.5, 0.5] 0 to 1, linear.
roughness number 0.5 0 to 1.
metalness number 0 0 to 1.
specular number 0.04 0 to 1; reflectance at normal incidence.
clearcoat number 0 0 to 1.
clearcoat_roughness number 0.05 0 to 1.
sheen number 0 0 to 1.
transmission number 0 0 to 1.
ior number 1.5 1 and above.
dispersion number 0 0 and above.
thickness number 0 0 and above, scene units.
subsurface number 0 0 to 1.
subsurface_tint [r, g, b] [1, 1, 1] 0 to 1.
absorption number 0 0 and above.
thin_film number 0 0 and above.
thin_film_ior number 1.5 1 and above.
thin_film_amount number 0 0 to 1.
emission [r, g, b] [0, 0, 0] 0 and above, linear.
fresnel_power number 5
normal table { source = "flat", strength = 0 } [materials.<name>.normal].
maps table { layer = -1, tile = 1, normal = 1, albedo = 1 } [materials.<name>.maps].
content a content kind "none"
content_layer table below [materials.<name>.content_layer].
layers tables none [[materials.<name>.layers]]: at most four noise layers.
ageing table every key 0 [materials.<name>.ageing].
authoring any none

The families: plain, paper, plaster, stone, cloth, metal, occluder, petal, leaf, bark, cord, wood, lacquer, glass, liquid, emissive, chrome. The content kinds: none, ink, decal, screen, photo, scroll, print, field.

normal, when present, needs both keys:

key type notes
source "flat", "bump" or "map" where the shading normal comes from.
strength number

maps, when present, needs all four keys, each a number: layer (-1 for none), tile, normal and albedo.

content_layer composites a content slot; each key has its default:

key type default notes
slot integer -1 the content slot; -1 is none.
blend "over", "multiply" or "emit" "over"
ink_roughness number -1 -1 keeps the surface’s.
emboss number 0
strength number 1

ageing, when present, needs all eight keys: fade, yellow, ink, scratch, edge, dust, patina (numbers, usually 0 to 1) and seed (unsigned integer).

[[materials.<name>.layers]] is one noise layer; a material takes at most four. A layer whose amplitude is 0 does nothing.

key type default notes
kind a noise kind needed
frequency number needed
amplitude number needed
seed unsigned integer needed
params twelve numbers twelve zeros their meaning is the kind’s.
kind params, in order
none an empty slot.
fibre across, along (the fibre streaks’ frequencies across and along the surface’s y), fine, mottle, ripple, swell (bump frequencies), fibre_gain, mottle_gain, ripple_gain, swell_gain
crinkle two frequencies, then two gains, of two bump octaves
plank_wood width of a plank in metres (planks run along x and stack in z), rings per metre, warp of the rings in metres, two figure frequencies along and across the grain, tone spread between planks, figure_depth, seam_floor (the seam’s darkest factor), roughness gain from the figure, jitter of the rings
wall_mottle two broad and two fine albedo octaves, two swell bump octaves, broad_gain, fine_gain, two swell gains
grime two frequencies, the dirty and clean tints, two roughness factors for dirty and clean
leaf, bark none: fixed patterns with a height relief
flow none: frequency scales the uv; a warped 2D noise that moves with time and shifts the thin film
value, fbm none: frequency scales the position
scratch line frequency across the scratches (stretched by 0.02 along them), two smudge values, speck
coat_wobble three frequencies (fine, then two broad), two weights of the broad octaves relative to the fine one, whose weight is the layer’s amplitude

Unset params are 0, and a kind with no params draws nothing visible: plank_wood and coat_wobble need a positive first param to draw at all.

key type default notes
format integer 0 1.
[[proxy]] tables none one stand-in each.
[authoring] table none the authoring tool’s.

A proxy is never drawn. It stands in for one static object: it casts that object’s shadow with casters = "proxies", and a ray that meets it picks that object. Its coordinates are world space.

[[proxy]]:

key type default notes
id id none
object reference needed a static object with a mesh, of the scene whose [plates] names this file (References). A dynamic object or a placement is refused (bad-reference).
kind "box" or "hull" needed
at [x, y, z] [0, 0, 0] box only: the box’s centre.
rotate [x, y, z] [0, 0, 0] box only: degrees about X, then Y, then Z.
size [x, y, z], each > 0 box: needed box only: the box is a unit cube centred on the origin, scaled by size, turned by rotate and moved to at (T·R·S).
points array of [x, y, z], at least four hull: needed hull only: the vertices.
triangles array of [a, b, c], at least four hull: needed hull only: the faces, each corner a place in points counting from 0, counter-clockwise seen from outside.
authoring any none

A box takes no points or triangles, and a hull no at, rotate or size (bad-value). The checker refuses a corner past the last point; keeping the winding counter-clockwise is the writer’s part.

format counts whole versions; a file never mixes two. Reading an older file migrates it in memory and warns once per file (old-format); diagnostics on it point into the file as written. The migration also rewrites a file on disk through the same code, keeping comments, spacing and order. Format 0 to 1:

  1. Adds format = 1 on the first line, followed by a blank line.
  2. Rewrites each path from relative to its file to relative to the project root. A path that would leave the root is refused, as any such path is (outside-root). A lone scene with no project.toml has its own folder as its root, so a format 0 path with .. past that folder can’t migrate as it stands: the migration names each such path and suggests a project.toml at the deepest folder that holds every file the scene reads.
  3. Renames [[object]] id to pick.
  4. Moves each [body.<object>] into its object, in the same file, as [object.body]. A body whose object is in another file can’t move (migration); move it by hand.
  5. Writes an id into every entry that can be referenced. References stay names; they still resolve, and an editor writes ids as it touches them.

A migrated id is derived: Id::derive of "<project-relative path>\n<kind>\n<name>", where the kind is the entry’s table key (mesh, object, light, emitter, mover, content, text, sound, rig, tiles, materials or proxy) and the name is its name or table key (a proxy, which has no name, uses "<object>\n<place>"). If that id is taken, "\n1", "\n2", … is appended to the key until it is free. A written id goes after the entry’s existing keys.

A newer format than the reader knows is refused, naming the version it knows (newer-format).

  • Every write goes through pfx-scene’s writer, on toml_edit: comments, blank lines, key order and number spelling survive, and a changed value keeps its place. Of an array, only the components that differ are replaced (at = [ -0.9, 0.350, 0.0 ] # by the wall becomes at = [ -0.9, 0.350, 1.5 ] # by the wall). A value that is already equal writes nothing.
  • New keys go after the entry’s existing ones; a new entry’s keys go in the order the tables above list them, and a new table goes after the last of its kind. A file that can’t be written back byte for byte (CRLF line ends) is refused when it is opened.
  • An edit is a patch on the document: set, unset, push or remove at a target and a key path. A target is an entry by id or unique name, a section ([sun], [sky], [haze], [camera], [finish], [trace], [plates], [physics]) or a file’s top level. A key path is the keys into it, where a number picks an entry of a list, counting from 0 (["layers", "1", "frequency"]); a missing table on the way is created. A list of tables lands as [[…]] tables, any other table as an inline one.
  • An entry is edited in the file that holds it, a material in its library; a section where it is, or in the root scene file when no file holds it yet. A new entry goes into the root scene file (a material into the first library) unless a file of the scene is given.
  • Every edit is checked before it is written: the edited texts are checked in memory as check checks them, and a refused edit writes nothing. An edit is also refused when a file changed on disk since it was read. Files are written by a temporary file and a rename.
  • Editors group patches into undo steps. Each edit returns a patch group: a label and one patch per file it touched, with that file’s exact text before and after, so undo and redo bring the bytes back exactly. A group can span many edits (a drag), keeping the first text before and the last text after per file; a group whose net text is unchanged is no step. Patches and groups are plain values with their inverse, so an editor with its own history can apply them itself.
  • Ids are written only when an entry is made, or when a file is fixed or migrated; the first write of a file through the writer fills its missing ids.

The writer is SceneEdit, opened on a scene or a prefab file of format 1:

let mut edit = SceneEdit::open("scenes/room.scene.toml")?;
edit.set_at("crate", [-0.9, 0.35, 1.5])?;
edit.set(&Target::Light("7k2m9q4xzr".into()), &["intensity"], 9.5f32)?;
edit.add(Kind::Object, Id::from_bits(random), "ball", &[("mesh", "block".into())], None)?;
edit.undo()?;
  • set, unset and push take a Target (Scene, Object, Mesh, Node { mesh, node }, Material, Light, Emitter, Mover, Content, Text, Sound, Rig, Tiles, Sun, Sky, Haze, Camera, Finish, Trace, Plates, Physics) and a key path; remove takes a target. An entry placed by a prefab is edited through its placement’s [object.set]. An object’s character, trigger, animation and body are edited through its key paths (["character", "jump_speed"], ["animation", "clip"]), and a tile layer’s cells through rows or cells (a painted row is its whole new string).
  • add(kind, id, name, fields, file), add_material(name, id, material, file) and duplicate_object(name, new_name, id) make entries with the id the caller gives; the id must be free in the project. rename_object follows the name references to the object (parent, a mover’s objects, a sound’s object, a rig’s target); references by id need nothing. Typed helpers cover an object’s keys (set_at, set_rotate, set_scale, set_object_material, set_object_materials, set_shadow, set_two_sided, set_hidden, set_clip, set_parent), a mesh (add_mesh, set_node) and a material (set_material).
  • begin(label) … end() groups edits into one undo step and cancel() takes it back; undo(), redo() and apply(&PatchGroup) move between texts. A refused edit returns an EditError with the file, the line, the edited key, the diagnostic’s code and its message. When a check refuses it, diagnostics holds what Project::scene returns for it, every error with its column and related; a refusal of the writer’s own (a file changed on disk, a key that is not there) leaves it empty.
  • SceneEdit::open, an edit and a dry run read the scene’s includes without recursing, so a scene that includes itself, or nests its includes more than 64 deep, is refused with the include-cycle or include-depth finding and its chain, as Project::scene gives it, on any stack.
  • dry_run(|edit| …) runs edits on the texts in memory and returns their merged group, labelled by its first edit, without writing a file or touching undo or scene(). Each edit sees the ones before it and the ids they made; the group is checked as a whole at the end, so a step may pass through a state that alone would be refused (an object naming a material the next edit adds). apply(&group) then writes it. Inside a dry run, begin, undo, redo, apply, reload and another dry run are refused.
  • PatchGroup::after() maps each file to its text after the group: the overlay for Project::scene_with and check_with, so an editor runs its own checks (asset contents) on an edit before it writes. PatchGroup::write() writes a group without an editor, refused when a file no longer holds the text the group starts from; a failed write puts back the files already written.
  • Project::fix(file) fills the missing ids of every file a scene or prefab reads (the file, its includes, its libraries, the prefabs it places and its proxies file), derived as the migration derives them, with every id of the project taken. It returns the patch group, checked as a whole, and writes nothing. A scene that does not load, a format 0 file (migrate it, which writes its ids) and a file that can’t be written back byte for byte are refused. A scene that does not load is refused with the errors Project::scene gives in EditError::diagnostics: a cycle or a nesting too deep with its chain in the message and its includes or placements in related, as check reports them.

pfx-scene checks a buffer or a whole project, and resolves a scene:

pub fn check(source: &str, file: &Path, project: &Project) -> Vec<Diagnostic>;
impl Project {
pub fn open(root: &Path) -> Result<Project, Diagnostic>;
pub fn check(&self) -> Vec<Diagnostic>;
pub fn scene(&self, file: &Path) -> Result<Scene, Vec<Diagnostic>>;
pub fn scene_with(&self, file: &Path, overlay: BTreeMap<PathBuf, String>) -> Result<Scene, Vec<Diagnostic>>;
pub fn check_with(&self, overlay: BTreeMap<PathBuf, String>) -> Vec<Diagnostic>;
pub fn fix(&self, file: &Path) -> Result<PatchGroup, EditError>;
pub fn read(&self, file: &Path) -> Result<File, Vec<Diagnostic>>;
pub fn migrate(&self, file: &Path) -> Result<String, Vec<Diagnostic>>;
pub fn root_of(file: &Path) -> PathBuf;
}
pub struct EditError {
pub file: PathBuf,
pub key: String,
pub line: Option<u32>,
pub code: &'static str,
pub message: String,
pub diagnostics: Vec<Diagnostic>,
}
pub fn migrate(text: &str, file: &Path, root: &Path, kind: FileKind) -> Result<String, Vec<Diagnostic>>;
pub const MAX_DEPTH: usize = 64;
pub struct Diagnostic {
pub file: PathBuf,
pub line: u32,
pub column: u32,
pub end_line: u32,
pub end_column: u32,
pub severity: Severity,
pub code: &'static str,
pub key: String,
pub message: String,
pub related: Vec<Location>,
}
pub struct Location {
pub file: PathBuf,
pub line: u32,
pub column: u32,
}
  • Project::root_of finds a file’s project root. Project::open finds the files under the root by their names. It skips the root’s target and tmp folders (build output and scratch), the paths [project] ignore lists, folders and files whose name starts with a dot, and folders that hold their own project.toml (another project). A skipped target or tmp is the root’s alone: levels/tmp is read. A file a scene names inside a skipped folder is still read and checked with that scene; it is only not found on its own.
  • Project::check checks every file Project::open found; each top scene (a scene no other scene includes, or one caught in an include cycle) with everything it reads; each prefab on its own; and each proxies file against the scenes whose [plates] name it, or against the project’s objects when no scene does. It resolves every include, prefab, reference and id, and checks that each asset path exists with that case.
  • scene_with and check_with resolve and check as scene and check do, with the overlay’s texts in place of the files they name. Its paths are absolute or relative to the root; a file only the overlay holds counts as existing, so an unsaved new include resolves.
  • check runs on an editor’s unsaved buffer: it checks that one text in every context it takes part in, against the project for references, and returns the diagnostics located in that file, so a TOML editor marks errors as it types.
  • Project::read returns one file as its typed data (File::Scene, File::Materials, …), migrated in memory when it is older. Project::migrate and migrate return a file’s text migrated to format 1, or the diagnostics of the steps that can’t run; a format 1 text comes back as it is.
  • Project::scene returns the resolved scene, with its warnings: includes merged, prefabs placed with namespaced ids, every reference rewritten to the key of the entry it names, and paths relative to the root. A top-level entry’s key is its name; a placed entry’s key is "<placement key>/<inner key>". Every object’s dynamic is set, derived where the file leaves it out. Scene::rigs holds the rigs, each target rewritten to its object’s key like any reference. Scene::proxies holds the proxies of [plates] proxies, each keyed by its 0-based place in its file, and Scene::files lists every file the scene read, the proxies file and the tile layers’ prefabs among them. Scene::tiles holds the tile layers, keyed by name, each palette value rewritten to its prefab’s root-relative path (a placement’s id to that placement’s prefab); a loader resolves each of those prefabs once (Project::scene on its path) and places it at every cell that names it. Tiles::cells lists a layer’s filled cells as ([i, j], tile), from rows or cells, and Tiles::position gives a cell’s position. Scene::layers holds the project’s [layers], empty without them, so a loader builds its physics from the scene alone.
  • A layer name is checked against project.toml as the overlay or the disk holds it. When [layers] can’t be read (the file doesn’t parse, or the table has the wrong shape), the layer names of scenes and prefabs go unchecked and Scene::layers is empty; Project::check reports the project file’s own error.
  • file is relative to the project root. Lines and columns are 1-based, columns count characters, and end_line and end_column point just past the span.
  • related holds the other places a diagnostic names: the first use of a duplicate id, each entry an ambiguous name matches, the other file of a table held twice, and for an include or prefab cycle or a chain nested too deep, the include or prefab key of each file on the chain, outermost first, ending with the diagnostic’s own place.
  • Resolving keeps the chain of files it is in on the heap, never on the call stack, so the stack it needs stays the same however deep includes and prefabs nest: the tests resolve includes and placements 64 deep, and refuse chains 1000 deep, on a 2 MB thread in a debug build.
  • key is the dotted path to the value, with a list entry named by its id when it has one and by its 0-based place otherwise: object.7k2m9q4xzr.at, light.0.intensity.
  • severity is Error or Warning. The warnings are missing-id and old-format; everything else is an error.
  • code is a stable short name that editors may key help on:
code when
syntax the text is not valid TOML.
unreadable a file can’t be read.
not-utf8 a file is not UTF-8.
unknown-key a table holds a key it doesn’t take; the message lists the keys it takes.
missing-key a needed key is absent, such as an object’s mesh, a light’s position or a box proxy’s size.
bad-type a value has the wrong type or length, such as text for a number, two numbers for three, four numbers for a scale of a number or three, or a tunable’s value of another type than its type.
bad-value a value is outside its range or its list, or a combination the table refuses (fov with focal, a slide with a pivot, an object moved by two movers, a body or a character under an ordinary object or a moved placement, a character with a body, a 3d character with wall_jump, a follow rig with pitch, a sprite with blend, an event at a frame past its clip, a tile layer with rows and cells, a cell set twice, a box proxy with points, casters = "proxies" without proxies, a tunable’s default outside its min and max, a layer listed on one side only).
bad-id an id is not 10 lowercase Crockford base32 characters.
missing-id an entry that can be referred to has no id (a warning).
duplicate-id an id is used twice in the project.
bad-name a name is empty, has a space at an end, or (a material’s) holds a control character; a palette key is ., or holds a space.
duplicate-name a name is used twice in its kind across a scene and its includes, or a material is in two libraries.
bad-reference a reference matches no entry of its kind, or an entry that can’t serve (a hit sound’s object without a body, a proxy’s dynamic object or placement, a palette id that is no placement), or a layer name names no layer of [layers], a sprite’s clip no clip of its clips, or a tile no key of its layer’s palette.
ambiguous-reference a reference names a name two entries carry.
bad-path a path is empty, absolute, or holds a backslash.
outside-root a path leaves the project root.
missing-file a named file does not exist (a [plates] dir and the paths of [project] ignore may not exist yet).
path-case a named file exists only with another case.
include-cycle a scene or prefab includes itself; related lists each include of the cycle.
prefab-cycle a prefab places itself; related lists each placement of the cycle.
include-depth includes nest more than 64 deep; related lists each include of the chain.
prefab-depth prefab placements nest more than 64 deep; related lists each placement of the chain.
parent-cycle objects’ parents form a cycle.
held-twice fallback or a one-file table is held by two files of a scene.
bad-override an [object.set] key names no entry or a key that entry doesn’t take, or its value doesn’t fit (a layer name the project doesn’t name among them), or it puts a body or a character under an ordinary object or a moved placement.
placement-key a placement holds a key only an object with a mesh takes (body, character, trigger and animation among them).
old-format a format 0 file, read through the migration (a warning).
newer-format a file’s format is newer than the reader knows.
migration a migration step can’t run, such as a [body.<object>] whose object is in another file.

A JSON Schema for TOML editors follows format 1.

pfx-scene is MIT licensed. It depends on toml, toml_edit and serde only. Vectors are plain arrays ([f32; 3]), never a maths crate’s types. Its semver is its own; the format number changes only with a migration.