Documentation · reference

The scene format

One scene written down as JSON, field by field: the reference for anyone writing a tool that reads or produces one.

A .scene file is one scene written down as JSON. This page is for anyone writing a tool that reads or produces one: an editor, an importer, a level generator, a build step. It is shipped in nacatamalon: format: "nacatamalon-scene", version: 1.

0. What a scene document is, and is not

A scene document describes one scene: a tree of boxes, what each one is, and where it is.

It does not describe the game. The size of the window, its background colour, the input map and the look of the screen belong to project.json, because "this game looks like a Mega Drive" is not a fact about whichever room happens to be open.

It does not describe your files either. A shader, an atlas, the cells of a map and the numbers of a particle effect all stay in their own file and appear here only as a name.

Three functions are the whole surface. Reading is the game's (nacatamalon), writing is a tool's (nacatamalon/authoring):

import { createGame } from 'nacatamalon';
import { parseSceneDoc, sceneFromDoc } from 'nacatamalon/files';
import { serializeScene } from 'nacatamalon/authoring';
 
const doc = parseSceneDoc(await (await fetch('/scenes/level1.scene')).json(), '/scenes/level1.scene');
createGame('#app', options)({ Level1: sceneFromDoc(doc) });   // it is now an ordinary scene
const written = serializeScene(root);                          // and a running one goes back to JSON

Or the engine fetches it, in a scene body, the way it loads a picture:

const cave = useLoadScene({ src: 'scenes/cave.scene' });   // added when it lands, as cave.name: the name inside it

What the engine never does is look for a scene file by its name: a page cannot list a folder, so which files a game has is the host's to say (the editor keeps them in src/scenes.ts).

sceneFromDoc returns an ordinary scene function. From that point nothing in the engine knows the scene came out of a file: starting it, changing to it, covering that change with a transition, pausing it and destroying it all work because it is not special.

1. The shape

{
  "format": "nacatamalon-scene",
  "version": 1,
  "name": "Level1",
  "assets": [ /* everything to fetch, by name */ ],
  "root": { /* the tree */ }
}
FieldMeaning
formatAlways "nacatamalon-scene". A file that does not say this is refused, not half read.
versionThe format version. A higher number is read anyway, with one warning.
nameWhat the scene is called and what starts it.
assetsEverything to fetch, keyed by the name the tree refers to it by.
rootThe scene's own box. Its children are the scene's contents.

Why name is a field. A scene in this engine is named by the key it was registered under, and createScene() deliberately takes no name. Without this the document could only be identified by its file name, which makes it useless the moment it is moved or inlined into a bundle.

2. The five rules

Everything below follows from these. They are worth reading once; after that the tables are self-explanatory.

A field left out is a field at its default

There is exactly one convention. visible absent means visible; flipX absent means not flipped. A default is never written down, because a written-down default is a decision nobody made, and the next version that changes that default cannot tell the two apart.

A file is named, never inlined

Shader source, atlas tables, map cells, effect parameters: all of it stays in its own file, and the scene names it. A document carrying a copy is a copy that goes stale the moment somebody edits the file, and a diff nobody can read.

A drawable is placed by its box; a camera and a lamp place themselves

Where a picture is belongs to its box, so a sprite and the shadow under it move together by saying it once.

A camera and a lamp are the exception, for one reason: composition deliberately does not reach them. Nothing moves a camera, because it is what everything else is measured against. A lamp is moved by the tree but never turned by it, so that a lantern held by someone who spins does not sweep the room. What the tree cannot decide, the component carries.

Anything unknown is kept, never deleted

A component of a kind this version has never heard of, a field belonging to some other tool: both survive being read and are written back where they were. Deleting them would quietly rewrite somebody's file the first time it was opened and saved.

Nothing throws

parseSceneDoc is total. A field that is missing, of the wrong shape or plain nonsense falls back to its default and the level still opens. serializeScene is total too: anything it cannot write is left out with a warning rather than taking the document down.

3. assets — what to fetch

Walked before a single box is built. Every loader registers its slot the moment it is asked and fills it in later, so by the time a sprite says "my texture is hero", hero is there, possibly still arriving.

key is what the tree calls it; src is where it is. They are usually the same string, and when only key is written, src takes its value.

{ "type": "texture",   "key": "hero",  "src": "/assets/hero.png" }
{ "type": "atlas",     "key": "player","src": "/atlases/player.atlas" }
{ "type": "shader",    "key": "crt",   "src": "/shaders/crt.wgsl" }
{ "type": "particles", "key": "fire",  "src": "/effects/fire.particles" }
{ "type": "tilemap",   "key": "level", "src": "/maps/level.tilemap" }
{ "type": "audio",     "key": "cheer", "src": "/audio/cheer.ogg" }
{ "type": "song",      "key": "theme", "src": "/music/theme.song" }
{ "type": "motion",    "key": "intro", "src": "/motions/intro.motion" }
{ "type": "bitmap-font", "key": "pac",   "json": "/fonts/Pac-Man.json", "atlas": "/fonts/Pac-Man.png" }
{ "type": "font",      "key": "inter", "src": "/fonts/Inter.ttf", "size": 64 }
{ "type": "geometry",  "key": "cube:1:1:1", "source": { "kind": "cube" } }

A bitmap font is two files, deliberately: the metrics and the sheet. Guessing one from the other is the sort of rule that works until somebody names a file oddly. Its sheet is not also registered as a texture of its own.

A vector font is one file (.ttf or .woff). Its atlas is drawn in the game, letter by letter, so it is never listed; size (pixels per em in that atlas) is written only when it is not the default 48. A text names either kind of font by its key, and the reader looks in both.

A shape is a recipe, not its corners. A cube is a handful of numbers of description or a few hundred of vertex data, and the description is what a person edits, what a diff shows, and what survives the engine changing how it builds a cube.

kindFields (all optional)
cubewidth, height, depth
planewidth, depth, widthSegments, depthSegments
circleradius, segments
uvSphereradius, segments, rings
icoSphereradius, subdivisions
cylinderradius, height, segments
coneradius, height, segments
torusradius, tube, radialSegments, tubularSegments
latheprofile (required: an array of { "x", "y" }, from the bottom up, at least two, x not below 0), segments
gltfsrc (required), node (the piece's name, "" for one the file did not name), occurrence (which piece of that name, from 0, counted in the order the file is walked)

A shape built from raw vertex data has no recipe. It cannot be written down, so a model using one is left out of the document with a warning.

4. root and the tree

{
  "id": "player",
  "name": "Player",
  "transform": { "x": 40, "y": 120, "z": 0, "rotation": 0, "rotationX": 0, "rotationY": 0,
                 "scaleX": 1, "scaleY": 1, "scaleZ": 1 },
  "components": [ /* what this box IS */ ],
  "children":   [ /* other boxes placed relative to it */ ],
  "data": [ { "value": 3 } ], // useData values, in the order they were declared; absent when none
  "tags": ["enemy"],       // what useTag says it is; absent when none
  "visible": false,        // absent means visible
  "screenSpace": true,     // absent means it lives in the world
  "worldOffScreen": true,  // on the root only: the screen shows what is pinned to it, the world only through views
  "extra": { }             // anything this version does not act on
}

id is stable and must stay so. It is the only address anything outside the engine has: a remembered selection, a link between two boxes, an undo step. Regenerating it on load breaks all of them, quietly.

transform: null means "not anywhere in particular", which is what an empty box used only to group things should cost: nothing. Write null rather than an identity transform, or every grouping box pays for a placement it does not have.

A partial transform is completed with identity, so { "x": 40 } is a box forty across and otherwise ordinary.

A transform may carry a "pivot": { "x": 0, "y": -1, "z": 0 }: the point, in its own units, it turns and grows about in three dimensions. Absent is the origin, and it is written only when set. It moves nothing by being there; only a turn or a growth goes about it.

Attachments are not children. A component in components is something the box is (its picture, its shape, its camera). A box in children is another object placed relative to it.

data is positional. Each entry is one useData value, in the order the box's code declares them, so the code that reads them back must declare them in the same order.

extra

Whatever the file said about a box that this version does not understand, kept exactly as written and written back untouched. Two things land here:

A component of an unknown kind lands under extra.unsupportedComponents, and is put back into components when the scene is written out again.

5. components — what a box is

The kinds, grouped below by what they are. Every one carries type and id.

GroupKinds
Drawnsprite, nine-slice, text, shapes, mesh, tilemap, particles, particles-3d
The scene's viewcamera-2d, camera-3d, fog, light, sprite-texture
Where it is drawnmask, filter
Behaviour and statescript, store, motion
Soundsound, music, song, audio-listener
Physicsphysics-body-2d, physics-body-3d, physics-world-2d, physics-world-3d
Particles hitting thingsparticle-collider-2d, particle-collider-3d

Every drawn kind may carry its own transform, measured on its box and left out when it is the box's own corner or origin (flat kinds take the five-field 2D transform, mesh and particles-3d the nine-field 3D one). createSprite({ transform }) writes into it, which is how almost every scene in code is written.

sprite

{ "type": "sprite", "id": "art", "texture": "hero", "tint": { "r": 1, "g": 1, "b": 1, "a": 1 },
  "width": 32, "height": 32, "anchor": { "x": 0.5, "y": 0.5 }, "zIndex": 3 }
FieldNotes
textureAn asset key, or null.
atlas, frameA sheet cut into a grid, and which cell.
width, heightLeft out means take the texture's size, decided when it lands. Writing today's numbers freezes the sprite at the size of the art it had when it was saved.
tintFour channels from 0 to 1.
anchorWhich point of the sprite sits on its transform, in 0 to 1 of its own size.
uvOffset, uvScaleWhich part of the sheet, when it is not cut by an atlas.
animation{ autoplay, speed }: which run of the atlas it starts on (null rests on frame) and how fast (1 is the run's own fps). The runs themselves are the .atlas file's and are never copied here. Needs atlas.
pivotThe point it turns and grows about, in 0 to 1 of its own size. Left out is the anchor.
blend'additive' to add its light to what is behind; left out covers it.
parallax{ x, y }: how much it moves with the camera (1 is with the world, 0 stays put). Left out is 1.
tile, tileOffsetThe picture laid across width × height instead of stretched, and where the pattern starts.
flipX, flipY, smooth, visible, zIndex
material, uniformsSee §6.

nine-slice

A panel: a picture whose corners are kept and whose edges and middle stretch to its size, for a button or a dialogue box.

{ "type": "nine-slice", "id": "box", "texture": "ui/panel", "tint": { "r": 1, "g": 1, "b": 1, "a": 1 },
  "width": 120, "height": 48, "slice": { "left": 8, "top": 8, "right": 8, "bottom": 8 } }
FieldNotes
textureAn asset key. A panel with no picture is said and left out of the scene that is built.
atlas, frameA sheet cut into a grid, and which cell.
uvOffset, uvScaleWhich part of the picture, when it is not cut by an atlas file: a cell of a sheet made in code is written this way.
width, heightAlways written: a panel is always stretched to a size.
sliceThe four borders, in pixels of the picture (of its frame, with a sheet).
modeHow the edges and the middle fill: 'tile', 'tile-fit', or { x, y } one per direction. Left out is 'stretch'.
anchor, pivot, smooth, blend, parallax, visible, zIndexAs on a sprite.
material, uniformsSee §6.

shapes

Shapes drawn by the card each frame (createShapes), in the order of the list, each filled first and then outlined.

{ "type": "shapes", "id": "hud", "tint": { "r": 1, "g": 1, "b": 1, "a": 1 }, "shapes": [
  { "type": "rect", "x": 0, "y": 0, "width": 60, "height": 6, "radius": 2,
    "fill": { "r": 0.88, "g": 0.33, "b": 0.33, "a": 1 },
    "stroke": { "width": 1, "color": { "r": 1, "g": 1, "b": 1, "a": 1 }, "align": "inside" } },
  { "type": "line", "points": [{ "x": 0, "y": 20 }, { "x": 40, "y": 30 }],
    "stroke": { "width": 3, "color": { "r": 1, "g": 1, "b": 1, "a": 1 }, "cap": "round" } }
] }
FieldNotes
shapesEach one of rect (x, y, width, height, radius one number or four, rotation), circle (x, y, radius), ellipse (x, y, radiusX, radiusY, rotation), the two with start, end and segments; polygon (points), line (points, closed, and a stroke it needs).
fill, strokeA colour; { width, color, join, cap, align }, each left out at its default. Colours are written as { r, g, b, a } however the game wrote them.
visibleOn a shape, written only as false.
transform, tint, parallax, visible, zIndexAs on a sprite.

A shape that is not one of these, or misses what it cannot be drawn without (a rectangle's size, a circle's radius, a line's points or stroke), is said and left out when the scene is read; the rest are kept.

text

text, font (an asset key), style (fontSize, align, letterSpacing, lineSpacing, maxWidth), tint, anchor, pivot, smooth, blend, parallax, visible, zIndex, material, uniforms.

mesh

geometry (an asset key), material (a mesh material, see §6), uniforms, visible, zIndex, and castShadow, written only as false (the floor, a decal): a field at its default is left out. alwaysDrawn: true draws it even when nothing of it is on screen; boundsMargin is how far past its shape it reaches, in its own units, before it counts as off screen (what a material that moves its corners in its shader has to say to be skipped at all).

There is no skeleton here. A rig belongs to the model file it was read out of and is adopted from there when the file lands; writing it into a scene would put a copy of the artist's work somewhere they will never look for it.

tilemap

One entry per map, not per layer. createTilemap reads the file and puts every layer on the box in one go, so an entry per layer would describe something no call can make.

{ "type": "tilemap", "id": "level", "map": "level",
  "transform": { "x": 0, "y": 0, "rotation": 0, "scaleX": 1, "scaleY": 1 },
  "layers": [ { "name": "treetops", "visible": false } ] }

layers holds only what this scene changed about individual layers, by the name the file gave them: tint, visible, zIndex, material, uniforms. A map dropped in and left alone writes none. The map itself may carry tint (over every layer) and smooth.

transform is here rather than on the box because a map is cached and shared: two scenes showing one level are looking at the same placement. Writing it as the box's would claim each scene had its own.

particles

{ "type": "particles", "id": "torch", "name": "torch", "effect": "fire",
  "tint": { "r": 1, "g": 1, "b": 1, "a": 1 }, "alpha": 1, "autoplay": true, "seed": null,
  "overrides": { "sizeScale": 0.5, "speedScale": 1.6 } }

Nothing about how the effect behaves is here: the rate, the lifetimes and the curves are in the .particles file, which is what makes three torches one document and one picture. overrides is how this one differs.

autoplay is the authored intent. Whether it is emitting right now is not written, so a scene saved while an explosion happened to be mid-burst does not come back permanently mid-burst.

seed: null takes the seed from the clock; a number gives you the same cloud every time. smooth, visible and zIndex as on a sprite.

particles-3d

The same fields, with a 3D transform. Its own kind rather than a flag on the flat one, because the file it names is a different kind of thing, and a component that could name either would make pointing it at the wrong one impossible to report.

camera-2d, camera-3d

Both carry transform (their own, see §2) and zoom. camera-3d adds projection ("perspective" or "orthographic"), fov, near, far, and, orthographic only, follow2d: true: the models placed in the 2D world, seen through the 2D camera.

fog

{ "type": "fog", "id": "mist", "color": { "r": 0.6, "g": 0.7, "b": 0.8, "a": 1 }, "near": 10, "far": 40,
  "height": { "top": 2, "bottom": -1, "enabled": true } }

The fog the models are seen through. It belongs to the view the way the camera does, so it is written on the box the camera is: the scene's root, or a box drawn into a picture. near and far are where it starts and where it is complete; distance: false turns that off, and height is a fog by height (where it starts and where it is complete), left out when off at its default heights. enabled: false only when switched off.

light

{ "type": "light", "id": "lamp", "kind": "point",
  "color": { "r": 1, "g": 0.9, "b": 0.7, "a": 1 }, "intensity": 2, "ambient": 0.35, "range": 7,
  "transform": { "x": 4, "y": 5, "z": 6, "rotationX": 0.25, "rotationY": -0.5 } }

kind is directional, point, spot, rect or ambient. range is on point, spot and rect; angle and penumbra on spot; width and height (how big the panel is) on rect. An ambient light is the whole room at once: no place, no direction, no reach.

Its transform is five fields and not nine, because a lamp has no scale and no roll.

Shadows: castShadow (left out is no; only one light in a scene casts), shadowBias (how far the test is pushed towards the light, in the world's units), shadowStrength (0 to 1), and for a directional light shadowArea (how far in front of the camera the shadows reach) and shadowDistance (how far back the light stands to look at it).

sprite-texture

{ "type": "sprite-texture", "id": "monitor", "key": "monitor-screen", "width": 160, "height": 120,
  "background": { "r": 0, "g": 0, "b": 0, "a": 1 }, "sees": "3d" }
{ "type": "sprite-texture", "id": "minimap", "key": "minimap", "width": 64, "height": 64,
  "background": { "r": 0, "g": 0, "b": 0, "a": 1 }, "sees": "world", "hide": { "tags": ["hud"] } }

A picture the box and everything under it is drawn into, instead of the screen. A model shows it by asking for key the way it asks for a loaded image. It is made before any box in the scene, so a model earlier in the file than the screen it shows still finds it.

sees says what it looks at from outside itself, written only when it does: left out, only what is under its box (a handheld's screen); "3d", the models of the world around it through its own camera (a security monitor); "world", the whole world of its scene through its own cameras (a minimap, a rear-view mirror, half of a split screen). A view of the world may carry hide and only, each { tags, kinds }: what it leaves out, and what alone it takes in. A document that says "scene", the old name of "3d", is read as "3d", and saving it writes the new name.

script

{ "type": "script", "id": "s1", "ref": "patrol", "props": { "speed": 2 } }

A name and not code: ref is the behaviour's name as it was registered (in a project, the file name). props is what this attachment was set to, absent when the behaviour declares no settings. Only the values are written; which keys exist is the behaviour's to say, so a setting it gains later is filled from its own default. A ref nothing registered is kept, props and all, and reported rather than dropped. Scripts are built last on their box, whatever their place in the list, so self is complete when they run.

store

{ "type": "store", "id": "player:store:save", "ref": "save" }

This box uses the store named ref. The store is not in the box and cannot be: it outlives every scene. The id is derived from the box and the name, the one identity in the format that is not kept, so a link survives a round trip unchanged.

motion

{ "type": "motion", "id": "intro", "motion": "intro", "autoplay": true, "loop": false, "speed": 1.5 }

A .motion file this box plays: values moved over time by keys. Like a song, written as it was asked to play, never where it had got to. motion is the asset key; loop is absent for what the file says, speed for 1.

sound, music, song, audio-listener

What is written is how it was asked to play, never what it is playing. A scene saved halfway through the music opens ready to start it. The knobs go the other way: a volume turned down is written down turned down.

mask

{ "type": "mask", "id": "m1", "by": "b7", "channel": "luminance", "soft": true }
{ "type": "mask", "id": "m2", "rect": { "x": 0, "y": 0, "width": 200, "height": 120 } }

The box, and everything under it, is drawn only inside a rectangle or the shape of another box (useMask). Written on the box it masks; a mask the game added to several boxes is written on each, with the same id. One per box.

filter

{ "type": "filter", "id": "f1", "effects": [{ "builtin": "outline", "uniforms": { "color": [1, 1, 1], "thickness": 1 } }, { "builtin": "glow", "enabled": false }] }
{ "type": "filter", "id": "f2", "effects": [{ "builtin": "crt" }], "rect": { "x": -48, "y": -36, "width": 96, "height": 72 } }

Effects run over what the box, and everything under it, draws (useFilter). Written on the box it filters; a filter the game added to several boxes is written on each, with the same id. One per box, inside the box's mask when it has one.

physics-body-2d, physics-body-3d

{ "type": "physics-body-2d", "id": "body", "name": "body", "body": "dynamic",
  "collider": { "shape": "rect", "width": 16, "height": 24 },
  "bounce": 0, "friction": 0.5, "density": 1, "sensor": false, "layer": 0, "collidesWith": 65535 }

One collider and body per box, a component and not a child: it is something the box is. Its place is the box's, so moving the box moves it.

The engine keeps, writes and reads these back with nothing installed to simulate them: a scene with physics opens and saves losslessly, and a tool can draw a collider's outline without a physics engine. Simulating is nacatamalon/physics2d or nacatamalon/physics3d, installed by the game.

physics-world-2d, physics-world-3d

The scene's simulation settings: gravity. They live on the root, because gravity is the scene's and the root is a box like any other; only the root's is read. The flat one measures in pixels with +y down, the 3D one in metres with +y up.

particle-collider-2d, particle-collider-3d

Something solid to particles, and to nothing else. shape: 2D rect, circle or plane; 3D box, sphere or plane, measured on the box that places it. enabled is written only when it is off. What an emitter does when it hits one (bounce, stop, die) is in its .particles file.

6. Materials

A material is a hook and its knobs. Two ways to say where the hook is, and the first is the one to use:

"material": { "name": "crt", "effect": "crt",
              "fragment": null, "fragmentGlsl": null, "vertex": null, "vertexGlsl": null,
              "uniforms": { "curve": 0.3 } }

effect names a .wgsl asset, and then the four hooks are null. The source itself is only written for a material built in code with its shader typed at the call site, which has no file to name and would otherwise be lost. A copy of a shader's source living in a scene is as wrong as a copy of a script's compiled behaviour.

maps are extra pictures its shader reads by name (sampleMap_<name>(uv)), up to four: each { "texture": "<asset key>" }, with wrap and smooth only when not the defaults. Left out when there are none. A sprite's, a text's and a map layer's material may have them, as a model's may.

There is no signature field. Which kind each knob is, is worked out from the values, so writing it down would be saying the same thing twice, and two statements of one fact can disagree.

A mesh material carries a surface as well: texture (an asset key), tint, emissive, specular, shininess, alpha, smooth, and wrap: what the picture does past its edge, 'repeat', 'clamp' or 'mirror', or { u, v } for each way on its own. Repeating is the default and is never written; a model from a file does what the file's sampler says unless wrap is there.

emissiveTexture (an asset key) is the picture the glow comes through, multiplied by emissive: written only when there is one. occlusion is where the light from all around is kept out of the model's creases: { "texture": "<asset key>" }, with "strength" (0 to 1) only when less than all of it, "uv": "uv2" only when it is laid out by the second picture coordinate, and "wrap" (as above) only when it has edges of its own, else it takes the colour picture's; written only when there is one. A piece of a model file writes none of these pictures (texture is null, emissiveTexture and occlusion left out): the file brings them back when the scene opens, and a picture kept inside a .glb has no file of its own to name.

lighting is how it is lit, written as the look: { "look": "toon", "bands": 2, "shadow": 0.4, "spot": 0.9 } or { "look": "unlit" }; steps is what is done to it once lit, in order (mangaDots, rim). A material with looks leaves its fragment out: the looks are the recipe, and the shader is made again from them when the scene opens. Its knobs, as turned, are in uniforms.

vertexSnap puts the model's corners on a coarse grid of the screen, the PlayStation's shiver: true for the game's own rows, or a number of rows (120 is the classic look). Written only when on. affine: true stretches the picture straight across the screen without correcting for depth, the PlayStation's swimming textures. Written only when on.

cull is the side of its triangles the material throws away: "front" (a shape seen from inside, lit from inside) or "none" (both sides drawn, each lit as the side it is). Written whenever the material says it, "back" (the default) included, so a model piece told "back" keeps it over a file that draws both sides; any other word is read as not said.

alphaCutoff cuts the picture out instead of blending it: a pixel whose alpha is below it is not drawn, the rest is solid (a fence, a leaf). A number from 0 to 1, written only when set; anything else is left out. A model piece whose file cuts it (MASK) is cut as the file says unless the document gives its own.

transparent says whether it is seen through whatever its alpha: true for a picture with see-through parts or a shader that works out its own (glass, water, smoke), false for solid at any alpha. Written whenever the material says it, either way; left out, it is seen through only below full alpha. A model piece whose file blends it (BLEND) is seen through unless the document says false.

A sprite material carries none of that, and that asymmetry is deliberate. In the plane, the colour and the sheet belong to the object: two sprites of one character tinted differently are two sprites, not two materials. In three dimensions they belong to the surface, because that is what a light is asking about.

7. What is not in the format

WhatWhere it lives instead
Window size, background, MSAA, input map, post chainproject.json
Scene transitionsAn argument to change, not a fact about a scene
Skeletons and animation clipsThe model file they came from
Sprite animation runsThe .atlas file; a sprite only says which run and how fast
A store's valuesIts .store file and the player's save; a box only names it
Palettes and grading tablesNamed by a post effect in project.json
Whether a scene is paused, under another or behind a transitionNowhere, on purpose: it is how the game got there (scene.pause(), launch, a transition), not what the scene is. Read again, a scene starts running, as it was made
Boxes born after the scene was builtNowhere, on purpose: saving a level three seconds into playing it must not write every bullet still in flight into it

8. A complete example

{
  "format": "nacatamalon-scene",
  "version": 1,
  "name": "Level1",
  "assets": [
    { "type": "texture", "key": "hero", "src": "/assets/hero.png" },
    { "type": "geometry", "key": "cube:1:1:1", "source": { "kind": "cube" } }
  ],
  "root": {
    "id": "level1-root",
    "name": "Level1",
    "transform": null,
    "components": [
      { "type": "camera-2d", "id": "cam", "transform": { "x": 0, "y": 0, "rotation": 0 }, "zoom": 1 }
    ],
    "children": [
      {
        "id": "player",
        "name": "Player",
        "transform": { "x": 40, "y": 120, "z": 0, "rotation": 0, "rotationX": 0, "rotationY": 0,
                       "scaleX": 1, "scaleY": 1, "scaleZ": 1 },
        "components": [
          { "type": "sprite", "id": "player-art", "texture": "hero",
            "tint": { "r": 1, "g": 1, "b": 1, "a": 1 }, "anchor": { "x": 0.5, "y": 1 } }
        ],
        "children": []
      }
    ]
  }
}