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 JSONOr 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 itWhat 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 */ }
}| Field | Meaning |
|---|---|
format | Always "nacatamalon-scene". A file that does not say this is refused, not half read. |
version | The format version. A higher number is read anyway, with one warning. |
name | What the scene is called and what starts it. |
assets | Everything to fetch, keyed by the name the tree refers to it by. |
root | The 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.
kind | Fields (all optional) |
|---|---|
cube | width, height, depth |
plane | width, depth, widthSegments, depthSegments |
circle | radius, segments |
uvSphere | radius, segments, rings |
icoSphere | radius, subdivisions |
cylinder | radius, height, segments |
cone | radius, height, segments |
torus | radius, tube, radialSegments, tubularSegments |
lathe | profile (required: an array of { "x", "y" }, from the bottom up, at least two, x not below 0), segments |
gltf | src (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 field from a newer version of the format;
- a field belonging to somebody else entirely: a tool's link back to a reusable box, a note an importer left.
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.
| Group | Kinds |
|---|---|
| Drawn | sprite, nine-slice, text, shapes, mesh, tilemap, particles, particles-3d |
| The scene's view | camera-2d, camera-3d, fog, light, sprite-texture |
| Where it is drawn | mask, filter |
| Behaviour and state | script, store, motion |
| Sound | sound, music, song, audio-listener |
| Physics | physics-body-2d, physics-body-3d, physics-world-2d, physics-world-3d |
| Particles hitting things | particle-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 }| Field | Notes |
|---|---|
texture | An asset key, or null. |
atlas, frame | A sheet cut into a grid, and which cell. |
width, height | Left 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. |
tint | Four channels from 0 to 1. |
anchor | Which point of the sprite sits on its transform, in 0 to 1 of its own size. |
uvOffset, uvScale | Which 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. |
pivot | The 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, tileOffset | The picture laid across width × height instead of stretched, and where the pattern starts. |
flipX, flipY, smooth, visible, zIndex | |
material, uniforms | See §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 } }| Field | Notes |
|---|---|
texture | An asset key. A panel with no picture is said and left out of the scene that is built. |
atlas, frame | A sheet cut into a grid, and which cell. |
uvOffset, uvScale | Which 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, height | Always written: a panel is always stretched to a size. |
slice | The four borders, in pixels of the picture (of its frame, with a sheet). |
mode | How 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, zIndex | As on a sprite. |
material, uniforms | See §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" } }
] }| Field | Notes |
|---|---|
shapes | Each 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, stroke | A 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. |
visible | On a shape, written only as false. |
transform, tint, parallax, visible, zIndex | As 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.
sound:audio(an asset key),volume,loop,rate(also changes the pitch),pan(-1left to1right, absent for the middle),channel(the volume group, so an options screen moves all the music at once),autoplay, and for a sound placed in the worldspatial,refDistance,maxDistance,cone(louder ahead than behind) orzone(fills an area instead of coming from a point; wins overspatial).loopStartandloopEnd(seconds into the file; what is beforeloopStartis an intro that plays once), andduck({ channel, to, fade?, release? }: another channel held down while this plays). A sound made in code from a preset ({ sfx }) has no file and is not written.music:layers({ name, audio, volume? }, the same tune in several files played together, each moved by its name),volume,channel(absent for'music'),autoplay,fadeInandfadeOut(seconds to come up from silence onautoplayand to go down to it when the box goes away, absent for 0),loopStart,loopEnd, andbpm,beatsPerBar(absent for 4),firstBeat(absent for 0) for whatever follows its beat.song:song(the asset key of a.songfile),volume,channel(absent for'music'),autoplay,fadeInandfadeOut(as formusic),voices(each voice's volume, only those not at 1),pan(where each voice is heard, only those changed from the file's),tempo(absent for what the file says) andtranspose(semitones, absent for 0). A song written in code has no file and is not written.audio-listener: the game's ears on this box rather than on the camera.enabled, absent for on.
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.
rect: in the box's own space, turned and grown with it.by: theidof the box whose shape it keeps to, found once the whole scene is built (it may come later in the file). It wins overrect, and that box is not drawn unlessshowShape. A copy of a reusable box that holds its own shape keeps to the copy's.channel: absent for'alpha';'luminance'for a black and white cut-out.soft,invert,showShape: absent forfalse.enabled: absent for on.
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.
effects: in order, each written as an entry of the project'spostchain:builtinwith the engine effect's key, oreffectwith the path of a.shader, anduniforms,options,paletteorlut,enabled(absent for on),name. An entry that names nothing that can run is dropped, and a filter left with no effects is kept as it was written, unread. An effect written in code has nothing to be written as and is left out, with a warning.rect: the part of the screen the effects work on, in the box's own space. Absent, it is what the box draws, with room around it.padding: that room in pixels, when not what the effects ask.enabled: absent for on.
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.
body:static,dynamicorkinematic(moved by the game, pushes and blocks, never pushed).- 2D
collider:rect,circleorpolygon. 3D:box,sphere,capsule, orhull/meshnaming ageometryasset.physics-body-3dadds anoffsetfrom the box. - The surface:
bounce,friction,density,sensor(reports overlaps, blocks nothing),layer(one layer,0to15: what this thing is) andcollidesWith(a bit mask of the layers it meets; two bodies meet only if each one's mask includes the other's layer).
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
| What | Where it lives instead |
|---|---|
| Window size, background, MSAA, input map, post chain | project.json |
| Scene transitions | An argument to change, not a fact about a scene |
| Skeletons and animation clips | The model file they came from |
| Sprite animation runs | The .atlas file; a sprite only says which run and how fast |
| A store's values | Its .store file and the player's save; a box only names it |
| Palettes and grading tables | Named by a post effect in project.json |
| Whether a scene is paused, under another or behind a transition | Nowhere, 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 built | Nowhere, 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": []
}
]
}
}