The built-in eases cover the usual shapes, but a game often wants its
own: a coin that bounces twice, a door that overshoots and settles.
A new asset type holds such a curve, and keys and the Lerp node can
pick it instead of a built-in ease.
- New asset type "curve": points like in Vector art, x is t and y is
the value. A new curve is the straight line from (0, 0) to (1, 1).
- One pure evaluation in src/core/model/curve.ts serves the timeline
preview, the running game and the Lerp node.
- The curve always has one value per t. The end points stay at t 0
and 1, points cannot pass each other, and a handle that reaches past
its neighbour is shortened along its direction. Loading a hand-edited
file applies the same rules.
- Curve tab: click on the line to insert a point that keeps the shape,
click on free space to put one there, drag points and handles,
double-click or Delete removes, Alt-click toggles round and sharp.
Every step is one undo step.
- Presets: all 30 built-in eases plus Linear, fitted with adaptive
subdivision. Each stays within 0.4 % of the built-in ease.
- Preview: a dot runs along the curve once a second, a second dot
shows only the value. The Preview button stops it.
- Animation keys get "Curve" under Easing with a curve picker. Lerp
gets "Curve" under Ease, and its result may overshoot b.
- A missing or unpicked curve runs linear. The inspector says so, the
node shows it as a problem, and deleting a used curve warns with the
number of keys and Lerp nodes.
Tests:
- ./check.sh: 180 files, 2612 tests, CHECK EXIT 0
- GM_STEPS=1-6,25,53: ALL PASSED, 309 checks
- New e2e module 53 with 13 checks, all with the real mouse. Expected
values come from its own Bezier evaluation.
- Counter-checks bite: without Bezier evaluation 4 unit tests fail,
without handle clamping 2. In the browser, a key that cannot find its
curve, a click that sets no point and a handle drag that moves the
point all turn checks red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A container is not drawn, so it had no place in the draw order. The
chain from its content to the parent of the container broke there, and
a container under a character could not be drawn together with it.
- A container gets the switch Sort with parent under Z. It defaults to
off, also for new containers, so projects draw as they did.
- With the switch on, the whole content is drawn in the slot of the
container's parent. The content's own switch does not count there.
- Containers in a row pass the content on as long as each has the
switch on. On the top level or under a tilemap it has no effect.
- drawSlots follows the new sortsWith field of a draw item. Scene view,
game and picking all use drawSlots, so all three agree.
Found on the way: a scene object hung directly under a container
spells the container out, and its template content is dropped. That is
the existing spelledOut rule, the tests now keep both cases apart.
Tests:
- ./check.sh: 178 files, 2573 tests, CHECK EXIT 0
- 7 new unit tests in sort-with-parent.test.ts: the switch, template
content, override per container, a chain, top level and tilemap,
game and picking
- e2e 5b-iii: a real click on the switch in the inspector, with undo
- 4 counter-probes over #242 and #243 bite (2 unit, 2 browser)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Structs and lists were capped at two fixed levels (list of structs,
struct with a list field). Anything deeper was dropped silently on
load, in node settings and when written to a variable. The cap came
from an early design choice, not from a technical need, so it is
lifted completely instead of adding a third fixed level.
- One kind list NESTED_KINDS replaces ListItemKind and
StructFieldKind. The flat special cases in listItems.ts are gone,
every value goes through one recursive path.
- A struct inside a struct is declared, like a top-level struct.
Values coming in over an edge are brought into that shape. Struct
items in lists still take their fields from the value.
- New valuePath.ts addresses any nested spot by keys and indices.
ListSlot.field is now ListSlot.path.
- Field commands (add, rename, remove) take the path to the struct.
They change declaration, own value and all overrides in one undo
step, as before on the top level.
- Inspector: "+ Field" offers Struct and List on every level. A
nested struct is a header row with its own "+ Field", its fields
are indented one step further.
- List tab: a list item shows "n items" and opens one level deeper.
The title shows the path, e.g. "Listy · nums · [3]".
- Pack in Pack now writes a struct into a struct field instead of
text. Objects are kept at any depth and stripped at every exit.
- A list edit in a struct field without an existing object override
now starts from the shown value instead of an empty struct.
- Old projects load unchanged. Save, load and save again gives the
same result.
Tests:
- ./check.sh: 172 files, 2498 tests, CHECK EXIT 0
- e2e 21 + 22: ALL PASSED, 90 checks (new 21d-iii, 22f-ii,
21d and 22f updated)
- 20 new unit tests in nested-values.test.ts and
nested-list-slot.test.ts, three tests that pinned the old cap
turned around
- 6 counter-probes, each fails 1 to 8 tests, originals restored
and checked with cmp
- Not covered: a nested struct inside a struct item in the list
tab has no e2e click
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An object could only live in a dedicated object variable, kept in slots
beside the variable bag. Struct fields and list items could not hold
one, so a spawned object packed into a struct or list was lost. This
replaces the slots with one mechanism for all three places.
- "No object" has one resting form everywhere: {"$object": ""}. A
running game can tell an object slot from an empty text that way.
- During play the object handle lives directly in the value, also
nested in structs and lists. objectSlots.ts and the special paths for
Keep and Properties are gone.
- liveObjects.ts strips objects to (null) at every exit: overrides the
editor run writes back to the scene, saved values of persistent
Variables assets, live values in the inspector, and the world
snapshot of the test hooks (objects as names there).
- A destroyed object reads as (null) through isAlive, alone, in a
struct field or as a list item.
- "+ Field" and "+ Item" offer Object. The inspector and the list tab
show a fixed (null) row without input.
- Lists from Raycast all stay objects when written to a variable.
Before they turned into names.
- A struct that only changes by an object writes no override. A list
that grows by an object writes [(null)], its length really changed.
Found on the way: CI at 98211a8 failed only in e2e 21d. #237 added
asset to the field kinds without updating that fixed expectation. 21d
now expects ...,animation,asset,object,struct.
No project on the server has object variables, so no old data is
affected.
Tests:
- ./check.sh: 169 files, 2475 tests, CHECK EXIT 0.
- e2e 6, 21, 23, 32: ALL PASSED, 213 checks.
- object-variable.test.ts rewritten: variable, struct field and list
item over several frames, destroyed objects, nothing in scene, file
or save, editor run reload, Keep.
- New e2e 21d-ii adds an Object item by click. 21d and 23k updated.
- 7 counter-probes, each fails 1 to 9 tests. Originals back via cmp.
- Not tested on its own: stripping of inspector live values and the
names in the test snapshot, the "+ Field -> Object" menu by click.
- e2e check total 1407, only calculated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A spawned object was only reachable in the run that spawned it. The
Spawn output could not go into a variable because "object" was a
port-only kind, so the next run had no way back to the instance.
- "Object" is now a variable kind. The reference lives for the run
only. The file stores the declaration and an empty value.
- The live reference sits in slots beside the variable bag
(graph/objectSlots.ts), not in it. The bag is the behavior's props,
and syncToModel would have written an object into the scene.
- A destroyed object, or one from a restarted or left scene, reads as
nothing. A dead reference is dropped from its slot.
- Keep (#229) hands the slots of kept variables on to the next call
of a sub-behavior.
- Properties of kind Behavior reads and writes another behavior's
object variable through the same slots.
- The inspector shows "(null)" for it and offers no input.
- A persistent Variables asset with an object variable shows a
warning under "Persistent". Saved values never keep the object,
every run starts it at (null).
Objects inside lists and struct fields are left out on purpose.
Emptiness is checked with Equals against an empty b. A node "Is
valid" does not exist yet.
The test helper graphEditor().variables() now also reads the kinds
of a Variables asset a node points to.
Tests:
- ./check.sh: 169 files, 2463 tests, CHECK EXIT 0
- e2e 6 + 23: ALL PASSED, 146 checks (new 23k, 2 checks)
- e2e 32: ALL PASSED, 18 checks (new 32f-ii)
- 10 unit tests in tests/core/object-variable.test.ts, one of them
runs a persistent asset over two runs
- 5 counter-probes (value in bag, isAlive always true, no Keep
hand-over, Properties without slots, object in saved values) each
fail their test
- e2e check total 1406, computed only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An animated or set Z of -1 on a weapon put it behind everything on Z 0,
not just behind the player holding it. A child can now opt in to being
drawn at its parent's place, so its own Z only orders it against the
parent and its siblings.
- New property "Sort with parent" on sprites, texts and particle
emitters, shown in the inspector right under Z. It is a normal
property, set on the asset and overridable per object.
- With it on, the rest of the scene sees only the parent's Z and, where
the layer sorts by Y, the parent's feet. Below 0 the child is drawn
behind the parent, from 0 on in front.
- It works across levels: a grandchild with the switch hangs on the top
object of the chain.
- It has no effect under a parent that is not drawn itself (container,
behavior, camera, collider) or under a tilemap.
- New assets start with it on, saved ones read as off, so old projects
draw unchanged. For that a schema row now has "initial" next to
"default". The gold "changed" mark and reset follow "initial", so a
fresh sprite is not marked as changed. No load migration is needed.
- parentGroups() in drawOrder.ts gives a slot only to the top of a
chain and inserts the children there. Editor, play mode and picking
all use it, so they agree.
Tests:
- 10 new unit tests in tests/viewport/sort-with-parent.test.ts.
- 8 existing tests list the sprite rows or defaults and got the row.
- New e2e check 5b-ii clicks the switch and checks order and undo.
- Counter-checks, all originals restored by cmp: switch ignored fails
7, children not sorted by Z fails 6, no chain fails 1, "initial"
ignored fails 9, row missing in the inspector fails e2e 5.
- ./check.sh: 166 files, 2426 tests, CHECK EXIT 0.
- e2e 5: ALL PASSED, 13 checks. Check total 1398, computed only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tilemap layer was one piece in the draw order, so a figure stood
either in front of a whole layer of trees or behind all of it. For a
top-down game the trees in front of the figure's feet have to cover
it and the ones behind must not.
- A new "Sort by Y" box under the layer list sets `sortByY` on the
painted layer. Every change is its own undo step.
- Such a layer comes as one draw slot per row. On its `z`, the rows
and all objects on that `z` are sorted by where they stand: the
bottom of an object's box, the bottom of a row. Ties keep the
hierarchy order, and other `z` values are untouched.
- A layer drawn as one piece and the map's own node stay behind the
sorted ones on the same `z`, since neither has a single place to
stand. Ground belongs on a lower `z` or stays unsorted.
- A new tile property "Sort offset" (whole rows, -16 to 16) lets a
crown sort with the trunk below it.
- `TilemapField` draws a container per row for such a layer, and
picking takes the row into account, so a click picks what is drawn
in front. Scene view, game view and play share the same rule.
Limits: on a rotated map "down" is world Y. A layer sorted by Y costs
about one draw call per row and tile instead of one per tile.
Tests:
- ./check.sh: 160 files, 2350 tests, CHECK EXIT 0 (before 2340).
- 10 new unit tests in tests/viewport/sort-by-y.test.ts.
- e2e 39-43: ALL PASSED, 148 checks. New check 40p-iii ticks the box
and finds the slot of row 0, undo brings back the whole layer.
- Counter checks: without the Y sort 5 unit tests fail, with the
offset ignored 5, with no row in picking 1. If TilemapField draws
no rows, exactly 40p-iii fails in e2e.
- The e2e sum in the README is 1378, counted, not yet seen in CI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sheets such as "TD - Tileset - Plains" draw an autotile as a 6 x 4
block: a 3 x 3 nine-slice, a vertical and a horizontal strip and a
single tile on the left, the inner corners on the right. The floor
kind only knew the RPG Maker 2 x 3 layout, so these sheets could not
be used as autotiles at all.
- New TileKind "terrain" with 16 px tiles in a 6 x 4 block. All 47
neighbour combinations are resolved per quarter, so the renderer,
the collision and the palette stay untouched.
- Whole tiles are used where the sheet draws them: the 16 side cases,
the four single notches and the two diagonal pairs. Hand-drawn
strips and single blocks therefore look as drawn.
- Only the missing cases are composed, for example an edge with a
notch or two notches side by side. They take the side tile and put
the notch quarters in.
- A notch also takes the quarter above or below from its notch tile
when fill would stand there. Tall cliff notches keep their wall.
On ground and stones fill meets fill, so nothing changes there.
- The label rule for floor and terrain now runs through one notch
function. Floor still passes the MZ table test.
Not touched: the tile board only refits on open and on Fit. Switching
an open tile to a kind with another tile size leaves it small until
Fit is pressed. That was the case before.
Tests:
- ./check.sh: 156 files, 2290 tests, CHECK EXIT 0 (before 2283).
- e2e 39,40: ALL PASSED, 90 checks (before 86). 39j checks 47 tiles,
pixels of single, notch, pair and composed tiles, and undo to floor.
- Counter-checks: without whole notch and pair tiles one unit test
and one e2e check fail. Without the taken quarter one of each
fails. Originals restored by cmp and rebuilt.
- Check sum: 1363 measured by CI plus 4 is 1367, computed only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Theme named Inter and JetBrains Mono, but neither was ever shipped,
so every machine drew the editor in whatever it had installed. The
e2e runs measured text in a different face than the user saw.
- Five Latin woff2 faces live in src/ui/fonts/ next to their OFL
licences: Source Sans 3 400/500/600/700 and Source Code Pro 400.
- fonts.ts loads them through FontFace, and main.ts waits for them
before the first widget exists, so Pixi measures the real face.
- A face that fails to load is reported in the console and the
system fallback in Theme takes over.
- Theme is retuned by hand: 12 px body, 22 px rows, smaller gaps
and padding, radius 8, and weight 500 for bold and headings.
- The scene background default follows Theme.colors.graphBackground.
This makes core import from ui for the first time.
Findings:
- The 500 file is in the folder but fonts.ts does not register it
yet, so weight 500 currently falls back to the 400 face.
Tests:
- ./check.sh: 141 files, 2080 tests, exit 0.
- e2e not run. Checks that measure widths or row positions are
expected to need updating for the new metrics.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Textures are uploaded files named by their content, so they cannot
be edited in place. Pixel art is therefore its own asset type, like
vector art. Its pixels live in the project and the tab rasters them
into a texture that sprites, tiles and animations can use.
- New asset types Pixel art and Palette. The first pixel art of a
project gets a palette with the 16 PICO-8 colours in the same undo
step. Every pixel is a palette index, 0 is transparent.
- Each layer on each frame is its own file data/pixels/<sha256>.px.
The project only holds the names, so undo swaps names. The browser
computes the hash and uploads new cels before the project is saved.
- New route POST ?r=pixels in api/lib/PixelStore.php. It answers 201
and 422 for broken data. data/pixels/ is git-ignored.
- Tools: pencil, eraser, fill (contiguous or all pixels of a colour),
eyedropper, line, rectangle and ellipse (outline or filled), brush
size 1 to 8.
- Layers can be added, deleted, renamed, reordered, hidden, faded and
merged down. Frames can be added, duplicated, deleted and reordered,
with onion skin and a small playing preview.
- Zoom 1x to 32x in whole steps, grid from 8x, a 3x3 tile preview and
Resize... with a 3x3 anchor.
- All frames sit side by side in one image. Each frame is a region
like a cut texture frame, so texture rows offer "Asset / Frame 2"
and the context menu has Create animation from frames.
Decisions:
- Merging down drops the opacity of the upper layer. Indexed colours
know no blends.
- Only the last palette colour can be removed. Otherwise the indices
of every image using the palette would shift.
- Undo stores the whole layer per stroke instead of pixel diffs.
- Alt+click picks a colour. It is a mouse gesture, not a shortcut.
Follow-up issues: #197 import from texture, #198 selection and
flipping, #199 PNG export, #200 more tools.
Tests:
- ./check.sh: 136 files, 2049 tests (before 135 / 2011). 34 new in
tests/core/pixel-art.test.ts, 4 from the icon tables.
- e2e module 48 fresh: ALL PASSED, 29 checks, console empty.
- Counter-probes bite: five on the unit tests, upload order (2 tests
fail) and cels not uploaded in the browser (1 check fails).
- Check total 1259 in README is computed (1232 measured in CI + 27).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A vector shape could only be filled and outlined with one flat colour.
Shading, glows and patterned surfaces had to be faked with stacks of
shapes. Every shape now has a paint type for its fill and its outline.
- Fill type: solid colour, linear gradient, radial gradient or
texture. Line type offers the same without texture.
- Colour stops are edited on a bar in the inspector, as agreed in the
issue. A click on the bar adds a stop in the colour the gradient
already has there, so adding never changes the picture. At least two
stops remain.
- The new Paint tool in the tab shows two handles for the selected
shape: axis start and end, centre and radius, or the corners of one
texture tile. "Handles: Fill / Outline" picks which paint is edited.
- Moving a whole shape moves its gradients and tile with it. Every
gesture is one undo step.
- A texture fill always tiles, because Pixi turns a non-repeating
pattern into a repeating one anyway. When switched on, one tile
covers the whole shape.
- Only uploaded textures and frames can be a fill, never a drawing.
A drawing filled with itself would rasterise itself mid-raster.
- The fingerprint of a drawing includes the texture and frame a fill
points to, so the picture redraws when the image is swapped.
Findings:
- graphics.destroy(true) also destroyed the textures of the fills.
With a texture fill it deleted the tile slice and the next redraw
crashed on reading 'scaleMode' of null. It is now destroy().
- The gradient cache destroyed old gradients itself, which gave 20
Pixi warnings "destroyed while still bound". They are now handed to
Pixi's texture GC with autoGarbageCollect switched on. The console
of module 47 is empty again, the last CI log had 0 of them.
Tests:
- ./check.sh: 135 files, 2011 tests, exit 0 (before 134 / 1986)
- npm run build: exit 0
- GM_STEPS=47, freshly seeded: ALL PASSED, 54 checks (before 44),
0 console warnings
- 25 new unit tests, 10 new e2e checks in block 47i
- Four counter-checks bite, originals restored and verified with cmp
- Check sum 1232 in README and wiki, calculated (1222 + 10)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Until now only the editor toolbar could pause a running game. A pause
menu inside the game had no way to stop the world while its own graph
kept running, and there was no slow motion.
- Five new nodes: Pause game, Resume game, Is game paused, Set time
scale and Time scale. Pause and resume take effect on the next frame.
- A game pause stops world time, physics, animations, particles and
shader time. Only behaviors with the new "Run while paused" switch
keep running.
- Set time scale 0 freezes the world but graphs keep running, as
agreed in the issue.
- Play and Stop reset the time scale to 1, Load scene keeps it. The
pause belongs to the scene, so Load scene and Restart unpause.
- The toolbar pause stays separate and also freezes the menu. When the
game pauses itself the status line reads "The game paused itself".
- While paused, collision events are not reported, sounds keep
playing, and the editor run ignores both nodes with a log line.
Findings:
- The first status line was longer than any before and got cut with
an ellipsis. It is shorter now.
- The e2e harness did not know the key "p". It does now.
Tests:
- ./check.sh: 133 files, 1968 tests, exit 0 (before 132 / 1952)
- npm run build: exit 0
- GM_STEPS=18 fresh: ALL PASSED, 33 checks (before 26)
- 16 new unit tests: 8 catalog cases, game-pause.test.ts, editor run
- 7 new e2e checks in block 18i, driven by real keys in the game
- Six counter-checks all bite: behavior filter, collision lock, world
time, editor run, clock reset and the game clock in the browser
- Check total 1217 in README is computed (1210 + 7), CI measures it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue #193 asked where friction, bounciness and density of a tile
live. Tiles had a fixed surface (friction 0.5, bounciness 0) and a
collider could not use the outline drawn on a texture frame (#141).
Both gaps are closed here.
- A collider has a fifth shape "Frame collision" with a Frame row.
The frame is picked like a sprite's texture and stored as the
value 'frame'.
- The shape is linked, not copied. Redrawing the outline in the
texture tab changes every collider using that frame, in the editor
and on the next play.
- The frame centre sits on the collider origin, one image pixel is
one unit. The shape turns, scales and mirrors like any other.
- Every outline ring blocks. Each ring is split into convex parts the
same way a polygon is.
- Without a frame, with a whole texture or with a frame that has no
outline, the collider is drawn red as a box and blocks nothing.
- Move, Rapier, raycast, detection, hit test, drawing and footprint
all resolve the frame. At runtime an entity looks the frame up
live in the current project.
- A tile has Friction and Bounciness next to Solid and One-way. The
rows only show when Solid is on. Defaults and mixing rules match
the collider. Density is left out because a tilemap never moves.
- Tilemap cells are grouped by surface as well as by one-way angle.
Ice and stone never merge into one rectangle, and every cell brings
its surface to Move and Rapier.
Findings:
- A tile with a collision scale below 1 is no longer flush with a
neighbour of another surface. It is inset there like at an open
side, the same as next to one-way tiles today.
- A bounce test first measured 0.84. The ball had landed on the hero
at the origin, not on the tile. On the tile it measures 0.993.
Tests:
- ./check.sh: 130 files, 1931 tests, exit 0 (was 129 / 1917).
- 14 new unit tests in tests/core/surface.test.ts. The Move tests run
against the editor run and the physics system.
- 3 new e2e checks: 40l (surface rows follow Solid), 42e (collider
has frame size, a walker climbs the slope in the frame).
- GM_STEPS=1-8,39-43 freshly seeded: ALL PASSED, 376 checks.
- Three counter-probes bite: Move ignores the tile surface (2 fail),
Rapier ignores it (1 fails), frame shape falls back to a box
(5 fail).
- Check sum 1198 is computed (1195 + 3), the full run belongs to CI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Part (a) of the animation rework: model, commands, saving, runtime and nodes.
- AnimationAsset holds a root (sprite, text, container or scene) and rows; a row keys one
property of the root or of an object inside it, keys sit on whole frames.
- The last key decides the length (its frame counts as a frame of its own). The playhead runs
between frames, so numbers and colors blend every tick even at 10 fps; everything else holds.
- Playback gains `once-reset`, which puts back what was there before the start. A row can be
relative, adding to where its target stood.
- Play / Pause / Stop animation act on an object; each instance plays on its own, the later
start wins on a shared property, and a behavior writing the same property still writes last.
- A sprite no longer wears an animation through `texture`. "Create animation from texture"
builds a sprite plus a texture row keyed frame by frame instead.
- The Animation tab shows a placeholder until the timeline editor (part b) lands.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sliced frame drew its borders one source pixel to one sprite unit. Pixel-art sheets are small:
a 16px corner on a 400-unit button is a thin line, and the only way around it was a larger image.
The texture now carries `Nine-slice scale` (default 1), next to `Filter`. It sets how many sprite
units one border pixel takes, for every sliced frame of that texture. The UVs stay as they are -
a corner shows the same pixels, only larger or smaller. When the sprite is too small for both
borders they are still scaled down together, now starting from the scaled size. With `Repeat`
the tile size scales too, otherwise a doubled border would sit beside a pattern of the old size.
The value is a schema row, so inspector field, saving and undo come without their own code.
Anything that is not a size above 0 reads as 1, and documents without it draw as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#63: a shader Property node named "width" quietly took over the sprite
row of the same name. isSpriteProp lives in PropertySchema now and is
derived from FULL_SCHEMAS.sprite instead of being a second list that
drifts. isValidPropertyName rejects those ten names, and the compiler
reports it at the node as its own problem: "width" is a sprite
property, told apart from "not a word" and from a duplicate.
#64: Divide emitted (a / b) and handed NaN to the driver. It emits
divide(a, b) now, with the guard in the prologue next to straight(),
scalar and per channel: zero divided by zero is zero. That was already
the house rule in the behavior and particle graphs - only the shader
had never been told. Clamp comes along as a shader node for the same
reason: the behavior catalog has had it all along.
Sounds follow the road fonts already paved: the file goes to the server
content-addressed under its sha256, the project keeps a named asset that
points at it, and nothing is ever re-encoded. The format is read from the
file's own signature, not from what the browser claims - the same .m4a
arrives as audio/mp4, audio/x-m4a or as nothing at all.
Playing is two nodes. "Play sound" takes the asset and may raise or lower
its volume; every call is its own voice, so two jumps in a row are two
jumps heard. Its loop input is a choice of three - from the asset, on,
off - because a checkbox has to spend one of its two positions on "not
set" and would lose a case: an asset that loops could then never be
played once.
Below the graph core stays browser-free: NodeContext hands out an asset
id, BehaviorSystem resolves it against the project and folds the asset's
own volume and loop into the call, and only viewport/SoundLibrary knows
about Web Audio. A run without a sink plays nothing instead of throwing.
The CI browser is muted, so the ear is no witness: the debug hook counts
started and stopped voices, and step 34 reads that counter.
This reverses the design of the previous commit. `PropertyDef.scope` is gone
without replacement: a behavior variable is an object property again, without
exception, and sharing is something you point at rather than something you
declare twice.
A **Variables asset** is declarations and nothing else -- no graph, no position,
never placed, no tab of its own (`TAB_OF_ASSET` is already `Partial<>`, so that
costs no line). A `Variables` node names one through its new `variablesAsset`
setting, empty meaning the behavior's own variables as before, and then reads
and writes one value per key for the whole run. Its property list follows the
chosen source, which was the actual request: a value another graph declared is
now offerable, where before every participant had to declare the name itself.
That deletes more than it adds (-72 lines net): `globalConflicts` and its
run-stopping kind clash, `SetVariableScopeCommand`, the scope picker, both
object-inspector filters and the "shared by the whole run" note on the placed
object. A typo is no longer a silent second variable -- naming an asset is
picking from a list. And two Variables assets may carry the same key without
meeting, because the asset is the key, not the name. The one shape it copies is
the one that already existed: `Set state` picks its machine the same way.
Lifetime is unchanged. The bag is built at `BehaviorSystem.start()` from the
declarations and never written back, so playing in the editor cannot change the
project -- which is the whole reason a counter must not be an object property
(`World.syncToModel` would `commit()` it, and the coin counter would stand at 7
for good).
Full symmetry across that gap, without mirroring anything. Two named wires:
`Inspector.liveVariables` shows the values as they stand while a run is on and
falls back to the declarations when it ends, and `adoptDeclarations()` takes a
key into the run *only where the document changed it since the last sync*.
Without that condition any unrelated project edit would snap a running counter
back to its starting value; a counter-test bends exactly that and falls over.
Nothing was ever cached, incidentally: `GraphInterpreter` only pulse-caches
nodes without a `read`, and the variable node has one. The wire was missing.
The state machine `Scope` row is now an ordinary `enum` row through
`createFieldRow`. It was hand-built from an `HStack` with a ghost dropdown and
began ~34px left of every value above it; the explanations beside `Local` and
`Global` are gone from the menu, the sentence below the row keeps saying it.
Machine scope is otherwise untouched -- `StateMachineAsset.scope`,
`SetMachineScopeCommand`, `stateBag()` and its persistence all stand, so #59
stays closed as built and `Scope` as a type now carries machines alone.
Six counter-proofs, each biting at its own guard. One of them did not bite at
first: the suggestion list was computed twice, in `nodeSection` and
`portSection`, so only the browser test covered the request itself. It is one
named function now, `variableChoices`, with a unit test of its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A variable or a state machine declared `global` keeps one value for the whole
run instead of one per behavior instance. The scope sits on the declaration --
`PropertyDef.scope` and `StateMachineAsset.scope`, both optional, absence
meaning `local` -- and `local` is never written, so every project made before
this saves back unchanged.
**The name is the key.** Two behaviors declaring a global `score` mean one
variable; a behavior declaring `score` locally keeps its own. Kinds that
disagree about one global name stop the run before it starts, with the two
behaviors named; starting values that disagree are a warning on the behavior
that declares it, and the first declaration wins (`globalConflicts`).
The runtime needs no redirection layer. A global variable falls out of
`schemaForObject`, so it never reaches `entity.props` -- and then the whole rule
is "whoever declares the name takes their own value" (`graph/sharedState.ts`).
The `Variables` node is untouched and stays transparent: switching a variable to
global breaks no existing graph, because `GraphInterpreter.readAll` builds a
fresh object per read anyway and only asks `port.key in all`.
That same cut answers the inspector. A shared value has no owner, so the placed
object shows no row for it and can hold no override -- `SetVariableScopeCommand`
takes the deviations that were there and undo gives them back.
Both places say it in words rather than leaving a glyph to be guessed: the
declaration explains the mark below the list, and the placed object names the
variable it no longer holds instead of claiming the behavior declares none --
which is what it said before, untruthfully. Screenshots of the e2e run turned
that up; no check had been looking at it.
`SharedState` hangs off `NodeContext`, is built in `BehaviorSystem.start()` from
the *declarations* rather than from any instance (a global variable exists
before its behavior is placed), and is thrown away with the run: nothing is
written back, not even in the editor run. `GraphInterpreter.call` passes it on
through `...ctx` while nested calls keep getting fresh local variables, which is
why `program.defaults` is stripped of global keys.
Two traps this hit. `defSignature`/`assetSignature` have to carry the scope or
the inspector does not rebuild after the switch -- and a state machine has no
rows at all, so its scope is appended to the signature by hand. And the object
inspector does not go through `schemaForObject` for behaviors, it reads
`asset.variables` directly; filtering in one place was not enough, and no unit
test covered that path. The e2e caught it (`global: true` where false belonged),
and the rule now lives in one named function, `objectRows`.
Scopes `object` and `scene` are deliberately not built. The object case already
has a route (`Object by name` -> `Properties`), and a run knows exactly one
scene, so `scene` would be indistinguishable from `global` until #39 exists.
Closes#59: a machine's scope is a word on the machine asset, not a setting per
`Set state` node, so the same machine cannot live in two places at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A particle source can wear a shader asset the way a sprite does, and it is the
cheapest place in the editor to wear one: a shaded sprite gives up its batch and
costs a draw call of its own, while `ParticleContainerPipe` breaks the batch for
every field regardless. Five hundred shaded particles are the one call five
hundred plain ones were.
The fragment the compiler emits needed no change, only a second vertex stage:
Pixi's particle vertex is GLSL ES 1.00 and ours is ES 3.00, so it is translated
rather than invented. What it sees is a particle's picture, not its life — the
buffer carries five fixed attributes and takes no sixth, so age, index and seed
stay on the graph, which is what the graph is for.
Two things the issue did not ask about:
`Shader.resources` is an accessor over the names its constructor declared, so a
slot that is not declared becomes a loose JS property and is never bound. The
adaptor writes `uTranslationMatrix` into whichever shader the container names —
without an empty block for it the vertex gets no matrix and the particles land
elsewhere.
`schemaOf` in serialize.ts hung on `schema !== schemaFor('sprite')`, an identity
check a source never satisfies, so its shader values would have been dropped on
save.
A source is the one asset drawing rows from two graphs, so a name can arrive
twice. Its own row wins and the uniform keeps its default; nothing is rejected
on the shader's side, because the collision exists only at this one wearer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Speed and Prewarm are not state and not a second loop: both are arithmetic
on the clock argument the field already passes to every particle formula.
fieldTime = elapsed * timeScale + prewarmOffset, a pure rule next to
liveRange, called at the one place ParticleField.sync forms its time, so
preview and world inherit it together.
Prewarm offsets by one lifetime because that is the smallest offset which
fills a continuous emitter; a burst is dead at time >= lifetime, so the
same offset would skip it entirely and it is suppressed there.
The tab's slider counts wall-clock seconds, so its span divides by the
scale. A missing row falls back to 1 / off, so old project files need no
migration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The asset type `particles` is placeable and carries a graph at the same time —
`behavior` was the blueprint. Because a continuous emitter gives birth to particle n
at time n/rate, the whole field is a function of the clock rather than of the frames:
no ring-buffer bookkeeping, exact time jumps, reproducibility as a measurable property.
compile.ts follows compileShader's frame but emits JS closures into a Float64Array
instead of GLSL, and without eval; a node with two consumers is evaluated once.
Drawing is one Pixi ParticleContainer per source, so one draw call.
Collision, wind and braking in flight are out for good — that is the price of the
formula model, not an omission.
The tab count goes from nine to ten, which is exactly the case #84 was built for one
day earlier: at 1280 px the row no longer holds every tab, so guard 14b now asks for
hidden.length > 0 instead of === 0.
A font asset holds a file and nothing else, the twin of a texture: the server keeps it
content-addressed under data/fonts/{sha256}.{ext}, with the format read from the file's own
four-byte signature (finfo reports fonts inconsistently across libmagic versions). A text
wears one through its ordinary Font property, so overrides, reset, undo and the Properties
node come for free; no font means the editor's own typeface.
The picker lists everything the server holds and renders each row in the face it offers.
The family name is gm-font-<hash16>, never the name inside the file, so two "Regular" cannot
collide. WorldViewport.syncText is the single place that draws it, which covers scene, game
preview and play at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four new rows on sprite, text, collider and container (block TRANSFORM):
scale, rotation in degrees clockwise, mirrorX, mirrorY. As properties they
get override/reset/apply, loading of old projects, write-back after play and
the Properties node for free; camera and behavior have none.
The hierarchy composes T*R*S per level (composeAlongChain replaces
sumAlongChain), so children rotate and scale with their parent. Reparenting
and grouping keep the look (carriedTransform). Drawing goes through
placeNode, the gizmo is a polygon, cull and objectRect use the AABB, drags
are computed in the parent's system.
Colliders are oriented boxes: SAT for box/box, box-circle in the box frame;
exitDistance replaces axisSeparation/axisCorrection, moveAndSlide moves along
the object's own axes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The + Property menu of the Properties node offered every shader's params
for every sprite. Each shader row now carries its shader name, and a name
shared by two shaders lists both: "brightness (Adjust colors, Neon glow)".
Sprite-owned rows stay unlabelled.
The note hangs off a map keyed by the def object, not by the name -- a
sprite property and a shader param may share a name (#63), and keying by
name would label the wrong row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dropdown labels are now Linear (default) and Nearest; the stored values
linear/nearest are unchanged, old project files load as before.
e2e 12c-ii measures in Play at camera zoom 1.37 (screen scale 1.434): a
Nearest edge sampled across seven pixels is byte-identical with and without
a neutral Adjust colors shader, and brightness 0.5 on the same path darkens,
so the pass really runs there. New hook __gm.gameRect(id).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A shader is an asset carrying a node graph (Texture color, Output, Property,
Color, Add/Subtract/Multiply/Divide, Mix, Luminance) that compiles to the GLSL
fragment of a Pixi filter. The Shader tab reuses the Behavior canvas; a sprite
wears a shader through its Shader picker, and the rows below it are the
graph's Property nodes. Property schema, resolution and deviation take the
project because a sprite's rows now live in another asset. The built-in
registry of the prototype is gone; "Adjust colors" ships as a graph in the
fixture and the three example games.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A state machine is an asset whose graph holds one `State` node per state
(no ports, name as title), edited in a new "State machine" tab that reuses
the graph canvas: right-click places "State n", snap toggle shared with the
Behavior tab via SnapToggle. `statemachine` is a value kind like texture
(port, literal node, variable, struct field, tagged list item).
Behaviors use it through `State branch` (one exec output per state) and
`Set state` (one exec input per state; the interpreter now hands `run` the
entry port). States live per behavior instance in NodeContext.states, with
no initial state. Ports derive from the inline picker or a wired
`State machine` literal; BehaviorLookup became GraphLookup with
behavior(id) and machine(id).
The Graph tab is labelled "Behavior" (key unchanged). Unit tests
(state-machine, kinds, catalog, ports), e2e step 26 and README added.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A kind like colour or texture used to be spelled out in five parallel
lists, its port type, its empty value, its glyph, its label and its list
tag - 22 literal places for texture alone. core/properties/kinds.ts now
holds one row per kind and the rest is derived from it, so adding a kind
is one row. The five literal nodes collapse into literals.ts, the six
kind menus into one helper.
Number, Text, Boolean is now the one order everywhere; the variable and
port menus used to list Boolean before Text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- `Texture` and `Color` can be picked as a variable kind, a struct field
kind and a declared port kind; a new `Texture` node hands a picture id
into the graph. Declared, a texture is its bare id – the `$texture` tag
stays a list-only thing.
- The `String` node is labelled "Text" like its kind; the type id stays.
- Whole-number inputs (`Item at`, `Set item`, `Sub list`, `Fill`,
`Repeat`, `Substring`, `To text`) show no decimals (`int()` in shared.ts).
- All server projects are reseeded, so `legacyNodes.ts`,
`migrateDeclaredLists` and the `ownerId` fallback go, with their tests.
- Note `reseed-because-of-list` moved to done/; new note `refactor-types`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A list no longer says what its items are. ListDef.items, ListDef.fields,
ListFieldDef.items and SetListItemsCommand are gone; the single seam is
src/core/properties/listItems.ts. JSON already tells number, text, boolean
and struct apart, so only two kinds need a tag: {"$color": n} and the new
{"$texture": id}. Field names cannot start with "$", so a tag never
collides with a struct field. On a data link a texture item arrives as
its bare id; the graph has no images.
List tab: the mark in front of each row is the kind picker, "+ Item"
asks for the kind, both are ordinary value edits (one undo step). A list
inside a struct field only offers the flat kinds because the depth rule
forbids a struct item there (ListAccess.kinds); the menu used to offer
it and silently do nothing.
Legacy lists that declared items: 'color' are tagged on load.
Actor demo: the four walk animations and four standing frames live in
two list variables of texture items indexed by facing (Item at), so the
graph can be rebuilt in the editor. The stand frames get their grid ids
back; 44 nodes instead of 46.
Also: the user's edits to on-start-during-play.md and the new note
reseed-because-of-list.md.
Verified: check.sh 794 tests, full e2e 631 checks, smokes 24/19/20.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An animation asset is a list of pictures (whole textures or frames) with
frames per second and Loop/Once/Ping-pong playback. A sprite wears it
through its texture property, so the picker, instance overrides and the
Properties node work unchanged; there is no dedicated play node.
A different animation starts from its first frame, the same one set again
runs on, so a graph may choose Run or Idle every frame. The clock lives on
the entity, not in props, which the world writes back as overrides. The
AnimationSystem ticks before behaviors, and On animation finished fires
once when a Once animation reaches its end.
The Animation tab plays a preview above a frame strip: reorder by dragging,
Delete, duplicate, + Frames, pause/resume, and wheel zoom about the pointer
with pan and Fit. Scene and Container tabs show the first frame; edit mode
has no clock. Right-clicking a texture creates an animation from its frames.
Button now measures its label with the bold font it draws in, which cut
the Animation tab and red dialog buttons short.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Get/Set property, Get/Set properties, Set enabled and Set text are gone.
A single Properties node takes a fixed "object" input; every chosen
property is an output that reads the current value, and one switched to
Set is also an input written when the flow arrives. Exec pins only exist
while at least one entry writes.
The node carries a type (Sprite, Text, Collider, Camera, Container,
Behavior, Any) that decides what "+ Property" offers: the fields of that
type, the variables of the chosen behavior, or for Any a kind to pick
and a name to type. Each port takes the default and field of its type,
so a Camera width starts at 640. Switching the type keeps the ports and
marks those the new type lacks. At runtime the type is only an editor
aid; the node writes to whatever object it gets.
Old project files are converted on load. The type is inferred from the
keys: the first object type that has them all, else the one behavior
that declares them all, else Any. enabled belongs to every object, so a
converted Set enabled lands on Any.
The jump-run and tetris seeders use Properties throughout; draw-piece
switches its cell on in the same node that sets the colour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Get properties / Set properties work like their variable counterparts:
one port per chosen property, an unconnected input writes its inline
value. A port chosen from the built-in list keeps that property's field
and default (height starts at 100, color is a colour field). "object" is
reserved for the fixed input, checked by the command and on load.
The property picker names the owning type ("Sprite width"), and text
fields can now carry a suggestion list; Get/Set property use it.
Double-clicking a call node shows the path of opened behaviors under the
graph header. Clicking an entry jumps back and trims the path; choosing
a behavior any other way hides it. Reopening a behavior already on the
path steps back to it instead of repeating it. The bar shows before the
graph switches, otherwise it clips the graph that was just fitted.
The "Add node..." header button is gone; nodes come from the context menu.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus;
the port and marker menus were the same list and are now one function.
Value specs of the inspector and the list tab share valueSpecs.ts, and
the remaining copies (compound commands, type glyphs, typed asset
lookup, name rule, play/editor world setup, marquee) are merged.
Four copies had already drifted apart; merging them fixes:
- "Disconnect N links" in the port menu is one undo step, not N
- "+ Add collider" is hidden on a placed container instance, as in
the hierarchy menu, instead of always failing
- a bool property coerced from the text "false" becomes false
- a port command on an unknown node type reports instead of crashing
Comment-only edits leave the bundle byte-identical. README corrected in
35 places (step limit, jump-run clock, suite duration, 409 body, ...).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An unconnected list input is a button into the List tab instead of a number
field; a struct field can hold a list, so ListSlot carries an optional field
name and the three owner cases only say what sits at a key; and a list
variable picks its item kind in the inspector (marker doubles as the chooser),
so a list of structs no longer has to be born from a project file.
The declaration keeps the storable shapes finite: a struct field may be a list,
but that list holds primitives only, so list->struct->primitive and
struct->list->primitive are the two deepest forms.
check 674, e2e 483, jump-run 24, Tetris 19.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A list item may now be a struct. The depth is bounded in the declaration
rather than by a comment: FieldKind (number|text|boolean|color) says what a
struct field holds, ListItemKind adds struct on top and says what a list
holds, so list -> struct -> primitive is the deepest storable shape and
sameValue stays finite. A struct item renders inline in the List tab, which
is why it needs no path in the slot address.
Four paths silently assumed a list item was a primitive: sameValue compared
items with ===, textOf would have printed [object Object], asListItem turned
a struct into text on Set item and Fill, and Index of used indexOf, which is
reference equality and could never have found a struct.
Tetris now runs on bundles. shapes is 112 {x, y} structs instead of 224 loose
numbers, piece and the candidate are structs, so Next move hands back a whole
candidate and each strand packs only the field it changes, since mergeStruct
leaves the rest standing. Declared behavior ports drop 49 -> 35, the master's
variables 18 -> 13, its graph 84/94 -> 69/76 nodes/edges.
Get variables and Set variables declare their ports from a list of variable
names, the way Pack and Expand already do, so one node can read or write
several at once. Renaming a variable rewires them, because there the port key
is the variable name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The value type was already there: StructValue, textOf, sameValue,
writeTyped/mergeStruct and the struct port type came with the first
part, and Get/Set variable carry any-ports, so a bundle already flowed
through the graph unchanged. What was missing was the declaration.
PropertyDef gains kind 'struct' with a list of fields, VARIABLE_KINDS a
fifth kind, variableDef a fifth parameter. A declared field is a
primitive - a list field would need a nested editor and therefore the
path in the slot address, the same step "a list of structs" is waiting
for. coerceValue now returns PropertyValue rather than FieldValue, the
first value type that is not a field; none of its five callers break.
In the inspector a struct variable is a block, not a tab. The list tab
exists because hundreds of rows fit in no inspector row; a struct is the
opposite case - few fields that fit under each other. It is the port
editor Pack already has, one level down and with a value column: type,
key and default value in one place. A placed object sees the same fields
as plain value rows: overriding yes, declaring no.
Adding or removing a field touches the declaration, the default, the
behavior's value and every instance override in one command, so a stored
value can never keep a shape its declaration no longer promises. The row
handlers read the bundle at the moment of the change rather than closing
over a snapshot - a field writes the whole bundle, so with a snapshot
the second edited row revives the first one's old value. defSignature
carries the fields, otherwise a new row appears only at the next
rebuild.
Fixes a fixture fault this uncovered: step 10 pushed its text object
into the scene without instanceEdits, which overrideSites walks with
Object.entries. AddStructFieldCommand is the first caller to reach it
from that state; the step was green alone and failed three checks in the
full suite.
Moves the dynamic-ports note to done - all three of its parts are built.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>