Commit Graph
97 Commits
Author SHA1 Message Date
adminandClaude Opus 5.5 320d3e27e1 Add a Lerp node that blends from a to b with an ease (#246)
game-maker / gate (push) Successful in 3m23s
game-maker / suite (push) Failing after 9m24s
Every animation in a behavior had to build its interpolation from
Subtract, Multiply and Add, once per axis. The gold pickup in
"Garden of Sacrifices" needed about 30 nodes for that alone. A single
Lerp node with an ease makes those chains one node each.

- Lerp has the inputs a (default 0), b (1) and t (0) and the output
  result. It sits in the data category next to Clamp.
- The Ease setting offers Linear, In, Out and In-out, all quadratic.
  The node row shows it as "Ease: Out".
- t is held in 0..1. A timer that overshoots in the last frame still
  lands exactly on b.

Tests:
- ./check.sh: 178 files, 2583 tests, CHECK EXIT 0
- 7 new cases in the node catalog test. Without the clamp on t,
  2 of them fail.
- e2e 6z-iii (2 checks): typing "lerp" in the palette adds a linear
  Lerp, and picking "Out" in the inspector reaches the node and its
  row. GM_STEPS=1-6: ALL PASSED, 267 checks.
- README check sum is 1459, computed, not counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:49:22 +02:00
adminandClaude Opus 5.5 0c5ac32b59 Let structs and lists nest to any depth (#239)
game-maker / gate (push) Successful in 3m52s
game-maker / suite (push) Successful in 10m24s
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>
2026-09-26 22:33:20 +02:00
adminandClaude Opus 5.5 483c34bb5c Let Destroy keep the children and hand them back as a list
game-maker / gate (push) Successful in 3m15s
game-maker / suite (push) Successful in 9m41s
Destroying a container used to take everything below it along. Now
Destroy can remove just the object itself and pass its direct children
on, so a behavior can keep working with what was inside.

- New input keep children (bool, default off). When on, only the
  object goes. Its direct children move up to the object's parent, or
  to the top level if there is none. Grandchildren stay where they are.
- New output children (object list) with the direct children that were
  kept. It is empty when keep children is off.
- The children keep their place on screen. carriedTransform folds the
  destroyed object's position, rotation and scale into their own, the
  same as reparenting in the editor.
- Behaviors and the rigidbody of the object are always destroyed with
  it and are not in the list. Moved up they would steer or simulate
  the parent.
- World remembers which children it reparented, and syncToModel writes
  their new parent into the scene. A reload of the model keeps it.
- Limit, read from the code only: in the editor run the contents of a
  placed container are not baked. Moved children there last for the
  run only.

Tests:
- ./check.sh: 170 files, 2478 tests, CHECK EXIT 0
- e2e 34 (the module with a Destroy node): ALL PASSED, 21 checks
- world-sync: world matrix of child and grandchild unchanged under a
  rotated and scaled parent, parent survives syncFromModel, top level
  case, already destroyed object gives []
- destroy-keep-children: real graph Destroy(Owner) -> Log(children)
  logs Lid, Label without the grandchild Nail
- 3 counter-probes, all caught: no world transform (1 test fails), no
  parent in the scene (1), behaviors and rigidbody moved up (2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:45:27 +02:00
adminandClaude Opus 5.5 7979b8ff19 Let structs and lists hold objects across behavior runs (#238)
game-maker / gate (push) Successful in 3m28s
game-maker / suite (push) Successful in 10m14s
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>
2026-09-26 21:12:44 +02:00
adminandClaude Opus 5.5 98211a8d54 Let a variable hold an object across behavior runs
game-maker / gate (push) Successful in 3m15s
game-maker / suite (push) Failing after 9m2s
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>
2026-09-26 20:15:16 +02:00
adminandClaude Opus 5.5 923baf2557 Let Spawn take an asset over an edge and find a child by name (#237)
An asset could only be picked as a setting on the Spawn node. It could
not be wired in, held in a variable or put into a list. Finding an
object below the own one needed a scene-wide search by name.

- "Asset" is now a kind like Animation or Sound: port, variable, list
  item and a new "Asset" literal node, with its own cube icon.
- Spawn and Is asset have an `asset` input. It uses the key of the old
  setting, so a saved asset reads as the value of the input and older
  projects keep spawning without a migration.
- New node "Child by name" searches below an object level by level.
  A child wins over a grandchild of the same name. Nothing found or no
  object gives null.

Tests:
- ./check.sh: 168 files, 2453 tests, CHECK EXIT 0.
- 9 new unit tests in tests/core/asset-kind.test.ts, catalog cases for
  Spawn, Is asset, Child by name and the literal.
- e2e 6z-ii (2 checks): node rows and a real drag of the literal onto
  `asset`. e2e 6: 119 checks passed. Spawn checks in 3 and 9 passed.
- Counter-checks: depth-first search fails 1, Spawn reading only the
  old setting fails 3, runtime ignoring the parent fails 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 19:36:49 +02:00
adminandClaude Opus 5.5 1d18862ed6 Give unconnected node inputs the default the node shows (#234)
A new node handed null to every unconnected input that had no port
default, even a Bool. The inspector showed false, 0, "" or an empty
list at the same time, so what the node read was not what the editor
said. 97 of 135 inputs of the built-in nodes were affected.

- inlineValue() falls back to the default the inspector shows. One
  place covers every node, also future ones and saved projects.
- A default list is handed out as a fresh copy per read, so a node
  that edits its input in place cannot change the shared default.
- The node shows the value it reads: an empty text or list as
  (empty), an object or struct input without a link as (null).
  Live values while playing use the same words.
- A key input without a key keeps showing a dash, like the key field
  in the inspector.
- Visible changes in old projects: To text without input gives ""
  instead of "0", Select and First not empty give "" instead of null.

Tests:
- 8 new unit tests in tests/core/inline-defaults.test.ts, among them
  one over all built-in nodes that no typed-in input reads null.
- The e2e check in module 6 reads the rows of an unlinked Name of
  node. The check sum stays the same.
- ./check.sh: 167 files, 2434 tests, exit 0. e2e 6: 117 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 14:35:43 +02:00
adminandClaude Opus 5.5 21d7149756 Let a nested behavior without a start node run from On start (#230)
A behavior built as a plain behavior, with On start and no Behavior
start, did nothing when another behavior called it through Run
behavior. The node only showed a warning. Such a behavior should run
as a call too, without the author having to rebuild its entry.

- New callEntries() in nested.ts returns the Behavior start node or,
  when there is none, every On start node. callProblem uses it too.
- GraphInterpreter.call() follows all entries one after the other,
  in graph order, in the same frame and with the same variables.
- Behavior end still returns what reaches it. A call through On start
  has no inputs, because only the start node declares them.
- It is a fallback only. With a Behavior start present, On start
  nodes stay out of the call.
- The node warning now reads "No start or On start node - does
  nothing" and only shows when neither exists.
- Keep and local state machines behave as before.

Tests:
- ./check.sh: 163 files, 2396 tests, CHECK EXIT 0 (before 2392).
- 4 new unit tests in tests/core/nested-behavior.test.ts: the flow
  after On start runs, the return value arrives, several On start
  nodes run in graph order, a start node keeps On start out.
- e2e 19: ALL PASSED, 35 checks. Only the expected warning text
  changed, the check sum stays at 1385.
- Counter-checks: without the fallback 3 tests fail. With only the
  first On start exactly the order test fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:13:14 +02:00
adminandClaude Opus 5.5 454d202109 Let each variable of a behavior choose whether calls keep it (#229)
game-maker / gate (push) Successful in 3m13s
game-maker / suite (push) Failing after 8m46s
The first version of #229 put a Keep state switch on the Run behavior
node. The user wanted it where the variable is declared instead. Whether
a value lives on belongs to the variable, not to every call site.

- The node switch keepState is gone again. RunBehavior, CallResult and
  nested.ts are back to what they were before #229.
- BehaviorAsset.keptVariables lists the kept names. It lives on the
  asset and not in PropertyDef, because that is the schema of every
  inspector row.
- A variable added from now on starts with Keep on. Variables in
  existing projects start with Keep off, so old projects run unchanged.
- SetVariableKeptCommand flips the switch as one undo step. Add, remove
  and rename carry the list along. Loading drops names that are not
  declared.
- The interpreter keeps only the listed variables, per Run behavior
  node and per object as before. The others start at their defaults.
- Local state machines in a called behavior start empty on every call
  again, as before #229. Global and scene machines are unchanged.
- The inspector shows a Keep button in each variable row of a behavior.
  Its state is part of the asset signature, so a click redraws the row.
  Without that the button kept its old look until the inspector was
  reopened.

Found: the project Garden of Sacrifices still has keepState = true on
the call Player -> Character Movement. It is ignored now. Direction
there predates the switch, so Keep is off until set by hand.

Tests:
- ./check.sh: 163 files, 2392 tests, CHECK EXIT 0 (before 2388)
- tests/core/keep-state.test.ts rewritten, 15 tests
- e2e 19: ALL PASSED, 35 checks. 19c-ii now clicks the Keep button
  of a new variable: on when added, off by a click, on again, off
  after undo.
- Counter-checks, each fails its own test: keep list ignored (2),
  states kept (1), new variable not kept (8), rename drops the list
  (1), no Keep button (e2e 19c-ii).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:50:59 +02:00
adminandClaude Opus 5.5 7c70245c79 Let a nested behavior keep its variables between calls (#229)
game-maker / gate (push) Successful in 3m3s
game-maker / suite (push) Failing after 8m39s
A Run behavior call started with fresh variables every time, so a
nested behavior could not count, remember or keep a local state
machine across calls. A new "Keep state" switch on the node lets the
callee resume where its last call left off. It is off by default, so
existing projects run unchanged.

- RunBehavior gets the boolean parameter keepState and its node
  description explains it.
- GraphInterpreter keeps the state in keptCalls, keyed by the call
  path, the chain of Run behavior nodes. Two nodes calling the same
  behavior each keep their own state.
- One interpreter exists per behavior instance, so every object gets
  its own state without further code.
- Local state machines of the callee are kept like its variables.
- resumedVariables() matches kept values against today's
  declarations. A new variable starts at its default, a removed one
  drops out, and one whose kind changed starts over.
- The kept state lives as long as the object in the run. It is not
  written to save games.

Limit: a callee input named keepState collides with the switch. The
same already holds for the name behavior, so it is not caught.

Tests:
- ./check.sh: 163 files, 2388 tests, CHECK EXIT 0 (before 2377)
- 10 new unit tests in tests/core/keep-state.test.ts and 1 new
  node catalog case.
- e2e 19: ALL PASSED, 35 checks, with the new check 19c-ii for the
  checkbox, its click and undo.
- Counter probes: ignoring keepState fails 5 tests, state per
  behavior instead of per node fails 1, dropping the outer path
  fails 1, skipping the kind check fails 1, and without the node
  parameter the e2e check fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:41:17 +02:00
adminandClaude Opus 5.5 5dbac3a8df Add a Select node that picks one of two values by a condition
Choosing between two values meant a Branch with the whole action
twice behind it, for example two Play animation nodes that differ
only in the animation. Every later change had to be made in both.
Select picks the value, so the action stays single.

- Select is a data node with the inputs condition, false and true
  and the output value. It passes on "true" if the condition holds,
  else "false". An unconnected condition counts as false, and an
  empty picked input gives null.
- The ports are named false and true, not a and b, so nobody has to
  guess which input is which. The order false, true follows Unreal's
  Select and Blender's Switch.
- false, true and value share one type through `sharedType`, like
  First not empty. With two Animation literals plugged in, value
  takes their type and fits Play animation directly.
- Both inputs are always evaluated. Data nodes have no side effects,
  so that does not matter.

Tests:
- ./check.sh: 160 files, 2363 tests, CHECK EXIT 0 (before 2356).
- 5 cases in tests/core/node-catalog.test.ts and 2 in
  tests/core/shared-port-type.test.ts.
- Counter checks: with the inputs swapped all 5 catalog cases fail,
  without sharedType exactly the type test fails.
- No e2e change, the sum stays at 1380.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:39:27 +02:00
adminandClaude Opus 5.5 5bf1ee7a4e Let Equals match a number against a typed number like 130 (#224)
The float tolerance from the last commit never reached the case it was
built for. The inputs of Equals are of type any, and their inline field
is a text field. A typed 130 therefore arrives as the string "130", and
the tolerance only applied when both sides were real numbers. The node
fell back to comparing "129.99999999999997" with "130" as text.

- A number against a text that parses as a number now uses the same
  1e-9 rule as two numbers.
- An empty text does not count as 0.
- Two texts are still compared as text, so "1.0" and "1" stay
  different.
- Compare was not affected, its inputs are real number fields.

Tests:
- 3 new cases in node-catalog.test.ts: 129.99999999999997 vs "130"
  is equal, 0 vs "" is not, "1.0" vs "1" is not.
- Counter-check: with the old rule (two real numbers only) exactly the
  "130" case fails. Original restored and compared with cmp.
- ./check.sh: 156 files, 2314 tests, CHECK EXIT 0 (before 2311).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 08:44:25 +02:00
adminandClaude Opus 5.5 9d6a0aebd3 Let Compare and Equals treat float noise as equal numbers
game-maker / gate (push) Successful in 3m4s
game-maker / suite (push) Failing after 9m47s
A graph that checks "time - late == 6" after On timer (#224) failed,
because the difference comes out as 5.999999999999972. Both values are
sums of measured frame times, and they round along different paths.
The only workaround was a Round node in front of every comparison.

- New helper nearlyEqual in values.ts. Two numbers count as equal when
  |a - b| <= 1e-9 * max(1, |a|, |b|). The tolerance grows with the
  size of the values, so it holds for hours of summed dt.
- Compare uses the rule for all six operators. "<" and ">" only hold
  when the values are not nearly equal, "<=" and ">=" also hold when
  they are. So "==" and ">" are never true at the same time.
- Equals uses the same rule when both inputs are real numbers. Objects
  still compare by identity, everything else as text. The node
  description says so.
- The tolerance is fixed, there is no parameter on the node. Physics
  values come from float32 in Rapier and are not covered on purpose.
- The log display is unchanged and still prints the raw number.

Tests:
- ./check.sh: 156 files, 2311 tests, CHECK EXIT 0 (before 2300).
- 11 new cases in node-catalog.test.ts, including the value from the
  log against 6, growth with magnitude, 6.001 != 6 and 1e-6 > 0.
- Counter-check exact compare: exactly the 8 noise cases fail.
- Counter-check "<"/">" without the exclusion: exactly 2 cases fail.
- No e2e change, the check sum stays at 1374.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 08:30:45 +02:00
adminandClaude Opus 5.5 6fecb2e841 Tell On timer how late its frame is, so time minus late is exact (#224)
game-maker / gate (push) Successful in 2m55s
game-maker / suite (push) Failing after 9m22s
A timer can only fire on a frame. It fires on the first frame past each
due time, so Time.time at that moment is the due time plus up to one
frame. On a display that is not exactly 60 Hz this delay creeps up by
a millisecond every few seconds and falls back to zero once it reaches
a whole frame. The issue saw 82.009 after 82 ticks. The timer itself
does not drift, it keeps the remainder when it fires.

- On timer gets a number output late. It is what is left of the
  timer's clock after taking one interval, so time - late is exactly
  the due time.
- When one long frame catches up several ticks, each gets its own
  late. The first one was due longest ago and is the latest.

Tests:
- New unit test in behavior-events: late 0.125 after a frame past
  1.0 s, 0 on the dot, 1.5 and 0.5 for two ticks caught up in one
  frame.
- Counter-check: late taken before the interval is subtracted fails
  exactly that test. Original restored with cmp.
- ./check.sh: 156 files, 2300 tests, CHECK EXIT 0 (was 2299).
- No e2e change. The e2e modules only wire the exec of On timer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 07:44:53 +02:00
adminandClaude Opus 5.5 d444b27fae Let behaviors read and set tilemap cells while the game runs (#135)
game-maker / gate (push) Successful in 2m59s
game-maker / suite (push) Successful in 9m22s
A behavior could move sprites around but had no way to look at or
change the tilemap it was standing on. Doors, breakable blocks and
"is there ground ahead?" checks all need that.

- New nodes Tile at, Set tile, World to cell and Cell to world. The
  conversion honours position, rotation, scale and mirroring of the
  placed map. A small Tile literal node lets a graph compare what it
  read against a fixed tile.
- The layer is picked by name from all tilemap layers in the project
  through a new field kind. "First layer" takes the map's first one.
  Because layer is an input, a text can also be wired in.
- Each placement keeps only its edited cells in liveTilemap.ts,
  layered over the asset's cells. A second placement of the same map
  stays untouched, and the asset itself is never written.
- A read sees a Set tile from earlier in the same flow. Drawing picks
  up the edit at once and neighbouring autotile edges adapt.
- Solids see the edits straight away, so a Move in the same frame
  already hits the new cell. Rapier rebuilds its shapes on the next
  step.
- After Stop the map is back to what was painted. The editor run shows
  the edit in the scene view while it lasts and does not touch the
  project.
- An unwired tilemap input means no map, like Get position. Cells
  outside the grid are refused and done reports false.

Tests:
- ./check.sh: 155 files, 2271 tests, CHECK EXIT 0 (before 2245).
- tests/core/tile-access.test.ts (14), one physics test for a floor
  set at runtime, catalog entries for the five nodes.
- e2e 39,40: all passed, 86 checks (before 81), five new in 40s.
- Counter-checks: collision ignoring edits fails 3 unit tests, the
  solids cache missing same-tick edits fails only the "at once" test,
  drawing ignoring edits fails the 2 drawing checks, the editor run
  not reporting a new tile fails only its check.
- 40s uses a still tile. The animated fixture tile redraws every frame
  and hid the last counter-check at first.
- Check total 1351 + 5 = 1356, computed. CI confirmed 1351 at cb32b4b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:45:29 +02:00
adminandClaude Opus 5.5 056a622524 Refactoring round 6: shared sheet canvas, fewer copies (#85)
game-maker / gate (push) Successful in 2m57s
game-maker / suite (push) Successful in 8m55s
Recurring cleanup round. Behaviour is unchanged. Duplicated code in
src went from 27 clones with 299 lines to 6 clones with 56 lines
(jscpd, the two identical OFL licence texts not counted).

- SheetCanvas is the new base of the texture, pixel art and vector art
  canvases. It holds background, mask, hint, panning, wheel zoom, fit,
  view save and restore and the screen mapping. Pixel art keeps its
  whole-pixel panning and fixed zoom steps.
- mixColor in src/core/util/color.ts replaces three copies in
  animation, gradient and particles. It clamps t like the particle one.
- fingerprint now lives once in generated.ts, next to the revision
  contract from #185.
- MoveCommand next to RenameCommand is the base of the three reorder
  commands for tile pictures, tilemap layers and vector shapes.
- The two raycast nodes share inputs, settings and filter via rayOf.
- canvasRect() lives once in AssetTabEditor.
- Removed unused code: findPixelArt, isDrawableShape,
  GeneratedPictures.has and SnapToggle.snapping. Tests now check the
  same things through equivalent paths.
- 38 values used only in their own file lost their export.
- Three comments that told history now describe the current state.
  The bundle stayed md5-identical for those edits.
- README lists vector art, pixel art and palette, and links the two
  wiki pages.

Findings:
- PaintTilesCommand.changes is meant to prevent empty undo steps but
  has not been called since #128. The rectangle tool therefore records
  a no-op undo step when it paints the same tile over itself. Left as
  is, because fixing it changes behaviour.
- No test covered middle-click panning in any sheet tab before. 13f
  and 48a now check it.
- The default values of the ray inputs were untested. Length 1000
  could become 10 without a red test. tests/core/raycast.test.ts now
  covers it.
- CI measured 1308 editor checks at d7c0b7c, not the 1307 counted for
  #164. The sum is now 1310 (1308 + 2), computed.

Tests:
- ./check.sh: 144 files, 2143 tests, exit 0 (before 2142).
- e2e 1-16,37,39-43,47,48 fresh: ALL PASSED, 729 checks.
- e2e 1-13,47,48 with the new checks: ALL PASSED, 518 checks.
- Counter-checks, each restored via cp and cmp: halved panning fails
  both new pan checks; unclamped mix fails 1 test, no blue channel 3;
  coarser hash 3; broken reorder undo 2; triggers setting forced on 2;
  bent ray length 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 06:57:43 +02:00
adminandClaude Opus 5.5 d7c0b7cfdb Variables and state machines can reset with every scene (#164)
game-maker / gate (push) Successful in 2m43s
game-maker / suite (push) Successful in 8m40s
A shared variable or machine used to live either per object or for the
whole run. Levels need a third lifetime: shared like global, but built
fresh from the declarations whenever a scene is loaded.

- New scope `scene` for Variables assets and state machines. A missing
  field keeps the old meaning: `global` for assets, `local` for
  machines, so existing projects do not change.
- Returning to a scene that was visited before is fresh as well. There
  is no memory per scene.
- A `scene` machine announces its start state again after a scene swap,
  so `On state entered` in the new scene hears it. A global machine
  stays quiet because it never left its state.
- The rule lives in two pure functions in `sharedState.ts`
  (`carriedForward`, `restartedAssets`). BehaviorSystem only calls them.
- The inspector shows a Scope dropdown on Variables assets and a third
  step on the machine dropdown, with a note on what `scene` means.

Decisions:
- `scene` and `persistent` exclude each other (variant A). The choice
  dialog was cancelled, so this is an assumption to revisit. A
  scene-scoped asset hides the Persistent switch, and switching to
  `scene` drops the flag (undo restores it). The guard sits in
  `isPersistent` itself, so no path can save values the scope throws
  away anyway.

Findings:
- `clickText('Scene')` hits the Scene tab, not the menu entry. One e2e
  check was red because of that, and its partner was falsely green
  because it compared `undefined` with `undefined`. Both use `clickLast`
  now.

Tests:
- ./check.sh: 144 files, 2142 tests, CHECK EXIT 0 (before 143 / 2133)
- 9 new unit tests in tests/core/scene-scope.test.ts
- e2e 32 fresh: ALL PASSED, 17 checks
- e2e 1-3,17,18,26,32,35 fresh: ALL PASSED, 213 checks
- 3 new e2e checks (35: scene-scoped asset resets on swap, 32h: dropdown,
  note, hidden Persistent switch)
- Counter-checks bite: ignoring `restarted` fails 2 unit tests and the
  new check in 35, the old announcement rule fails 1, dropping the scope
  guard in `isPersistent` fails 1. Originals restored, cmp IDENTICAL.
- Check sum 1307 (1304 + 3) is computed. Last CI measurement: 1293.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 06:06:53 +02:00
adminandClaude Opus 5 d7a2521ca7 A port can take its type from what is plugged into it (#207)
game-maker / gate (push) Canceled after 1m8s
game-maker / suite (push) Canceled after 1m8s
Until now every port of "First not empty" was grey and typeless, so the
editor could not tell you what actually travels through the node. The
output is now typed as soon as both inputs agree, and every any-input is
painted in the colour of its own edge. Nothing changes at runtime.

- A NodeDef can declare which of its ports carry the same value
  (`sharedType: { inputs, outputs }`). The rule itself lives as a pure
  function `agreedType` in `src/core/graph/portTypes.ts`.
- Only the output inherits. It becomes the shared type when every input
  of the group is connected and they all carry the same type, otherwise
  it stays `any`.
- The inputs stay `any` on purpose. `canConnect` checks against the port
  type and a busy input is replaced by the new edge, so a typed `a`
  could never be re-plugged with a text without disconnecting first.
- Chains do not propagate. When looking up what feeds an input, a node
  of the same kind reports the `any` it is registered with, so two of
  them in a row stay untyped and a data cycle cannot ask itself in a
  circle.
- An edge that the new type makes invalid stays where it is and turns
  red. `EdgeLayer` already paints a mismatching edge with `danger`,
  nothing is disconnected on its own.
- New `PortDef.paintType`: colour only, never truth. It is set in
  `drawnDef` and read in `NodeView.redraw`, so `canConnect`, the
  interpreter and the compiler keep seeing `any`. Every connected
  any-input is painted, not just members of a `sharedType` group; ghost
  ports keep their hollow red look.
- The pin now matches the edge that ends in it, because both take the
  colour from the resolved output type of the neighbour. Mixed sources
  therefore show two colours, which explains why the output stays grey.
- New hooks `__gm.graph.portTypes(id)`, `__gm.graph.edgeColor(id)` and
  `__gm.themeColor(name)`. `portTypes` reports type and paint colour
  separately so the type checks do not silently become colour checks.

Findings:

- `resolveDef` only appends derived ports to the registered ones, so a
  fixed port cannot be retyped that way -- it would appear twice. The
  shared type is therefore resolved before the ports are built.
- Answer 5 of the issue (inline field follows the type) has no case left
  under variant A: an unconnected input stays `any` and keeps its text
  field.
- `Item at`, `Join` and `Equals` do not profit: `Item at` has no
  any-input, the other two have fixed outputs. The next candidates are
  the shader arithmetic nodes, which were left alone.

Tests:

- ./check.sh -> 143 files, 2127 tests, exit 0 (was 142 / 2113)
- GM_STEPS=1-6 fresh -> ALL PASSED, 245 checks (was 242), 9 of them new
- 14 new unit tests in tests/core/shared-port-type.test.ts
- 9 new e2e checks in block 6z, one of them a pixel check straight on
  the pin via pixelAt + themeColor
- Counter-probes all bite: filtering unconnected inputs out of
  `agreedType` fails 3 unit tests, ignoring `inherit` fails 1, killing
  the derivation in the bundle fails exactly the 2 expected e2e checks,
  and never setting `paintType` fails 4 unit tests plus the 3 pin
  checks (#bdbdbd instead of #e6a8ff / #6fcf97)
- Screenshot read: `a` pink, `b` green, output `value` grey, edges in
  the same colours
- Check total 1302 (1299 + 3), 1293 was measured by CI at 1a3babf

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:15:21 +02:00
adminandClaude Opus 5 1a3babf9c5 First not empty gets a setting for what counts as empty (#206)
game-maker / gate (push) Successful in 2m46s
game-maker / suite (push) Successful in 8m46s
The node treated 0, false and an empty text alike, which is right for a
missing behavior parameter but wrong the moment a real 0 travels through
the graph. A setting now says how much counts as empty, so the same node
works for both cases.

- The new parameter "Empty means" has three steps: "Any empty value"
  (the default, the behaviour so far), "Nothing there (0 and false
  count)" and "Only a missing value".
- The default keeps every existing graph as it is, including the common
  setup where an unset animation parameter arrives as an empty text.
- The rule lives in isEmptyValue(value, mode) in values.ts; the node only
  passes the step through. An unknown setting behaves like the default,
  the same way Compare does.
- The node description is shorter because the old one was cut off in the
  inspector.

Findings:

- In the "Nothing there" step a struct is empty only when its fields are
  empty under the same step, so a position (0, 0) stays a real value.

Tests:

- ./check.sh: 142 files, 2113 tests, exit 0 (before 2104).
- 9 new cases in the node catalog table: the edge cases per step (0,
  false, empty text, empty list, position (0, 0), unconnected a) plus an
  unknown setting.
- Two counterproofs hold: letting "Nothing there" act like "Any" drops
  the 3 cases with 0, false and the position; removing the "Only a
  missing value" step drops the 2 cases with the empty text and list.
- Seen in the browser: the node shows "Empty means: Any empty value" and
  the inspector dropdown lists all three steps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:24:01 +02:00
adminandClaude Opus 5 d98c2898b1 A new node takes the first of two values that is not empty (#205)
game-maker / gate (push) Successful in 2m46s
game-maker / suite (push) Successful in 8m38s
A graph that reads an optional value needed a chain of Equals and
Branch just to fall back on a default. The new node does that in one
step: it passes a through unless a is empty, in which case b comes
out. Both ports are any, so it works for numbers, text, lists,
structs and objects alike.

- `First not empty` sits in the data category with two any inputs
  `a` and `b` and one any output `value`. Are both empty, b comes
  out, so the second input doubles as the default.
- `isEmptyValue` in values.ts holds the rule as a pure function:
  nothing connected, 0, an empty text, false, an empty list, and a
  struct whose fields are all empty.
- The struct case is what covers a position: behavior graphs have no
  vector type of their own, so (0, 0) arrives as a struct. `{x: 0,
  y: 2}` therefore counts as set, `{x: 0, y: 0}` as empty.

Findings:

- A real 0 or false on a is passed over. That follows from counting
  0 as empty and is written down in the issue.
- The output stays any. Ports that inherit their type from an input
  do not exist in the graph; `Item at` has the same shape.

Tests:

- ./check.sh: 142 files, 2104 tests, exit 0 (was 2094).
- 10 new catalog cases: a set, a unconnected, 0, "", false, an empty
  list, a position (0, 0), a struct with one field set, an object,
  and both empty.
- Counter-check: with structs never empty and 0 a real value exactly
  those two cases fail, the rest stays green. Originals restored and
  compared with cmp, then rebuilt.
- No e2e check: single data nodes have no e2e coverage here, the
  palette fills itself from BUILT_IN_NODES.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 19:58:30 +02:00
adminandClaude Opus 5 41772e53f7 Variables assets can keep their values from one run to the next (#166)
game-maker / suite (push) Successful in 8m21s
game-maker / gate (push) Failing after 3h5m52s
A game needs a high score or an unlocked level to survive the end of
a run. As agreed in the issue there are no save or load nodes. A
Variables asset gets a switch instead, and the existing Variables
nodes read and write the values as before.

- A "Persistent" checkbox in the Variables inspector. Toggling it is
  one undo step, and absent means not persistent.
- The behavior system loads the saved values at start and writes them
  after every frame that changed one, so a closed tab loses nothing.
- Values live in localStorage per project, not in the project file.
  Another browser or person starts fresh.
- Play and "Run in editor" save apart, so trying a behavior in the
  editor leaves the Play save alone.
- Saved values are read through the current declaration. A removed
  variable drops out, a new one starts from its default, and a changed
  kind is converted.
- "Clear saved values" in the inspector clears this asset in both
  areas. The main menu gets "Clear saved data" for the whole project.
  Clearing takes effect from the next run on.

Decisions:
- The switch covers the whole asset, not single variables. Mixing
  both means two assets.
- A changed default in the inspector shows only after the save is
  cleared. That is what persistent means.

Findings:
- The first e2e run died at "debug hook present" right after a Vulkan
  error from Chrome. The second run passed, so it was a GPU flake at
  start-up.

Tests:
- ./check.sh: 140 files, 2070 tests, exit 0 (before 138 / 2055).
- 15 new unit tests in tests/core/saved-values.test.ts and
  tests/editor/saves.test.ts.
- GM_STEPS=32,46 fresh: ALL PASSED, 24 checks. Six new checks in
  module 32 go through the UI. Module 46 knows the new menu entry.
- Counter-checks both fail as expected. Loading switched off: 2 unit
  tests and 2 e2e checks fail. Editor run writing into the Play area:
  1 e2e check fails.
- Check total 1278 (1272 measured by CI at bbbf341, plus 6).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 10:23:57 +02:00
adminandClaude Opus 5 3427f84a55 A game can pause itself and set its own time scale (#167)
game-maker / gate (push) Successful in 2m11s
game-maker / suite (push) Successful in 7m48s
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>
2026-09-21 10:27:17 +02:00
adminandClaude Opus 5 ffdf0a8ddb The new node "Raycast all" answers with every collider along the ray instead of only the nearest one: it carries the same inputs and settings as Raycast (Ignore owner, Hit triggers, Layers from #158) plus "max hits" where 0 means all, and it hands back parallel lists sorted by distance - objects, colliders, points x/y, normals x/y, distances - next to count, so index i of every list speaks about the same hit; a collider counts once no matter how many convex parts its shape is built from, while a tilemap counts per cell it is hit in, and the filter sits in Solids.raycastAll like the one of #158, the one path the editor run and Play both take, so all 12 new unit tests run through describe.each against both systems; the node needed the value system to learn something it could not do - a graph list carried numbers, text, booleans and bundles but no object handles, asList turned an object into its name, so objects/colliders would have been lists of names, which loses the object when two carry the same name, and collider children usually do; Value now knows a travelling list that may hold handles and "Item at" gives such an item out as an object, ready for Destroy, Apply force or Name of, while the single door into a stored file - writeTyped/asTypeOf - runs the list through the new storedList() so every object becomes its name again and nothing living reaches a project file, which is its own unit test over JSON.stringify; three list nodes follow the handles: "Index of" compares objects by identity because a name is not the object of that name, "Set item" and "Fill" write an object as an object, and "Join text" prints the names instead of the innards; this decision was mine to make because the question ran into the MCP timeout - the alternative was a list of names plus "Object by name", which is the smaller change but lossy; 4 new e2e checks in the new block 43e (shield and floor in one go reading 2 and "145, 280", the second object answering to its name, which only a real object in a list can do, "max hits" 1 stopping at the nearest, and the node showing the row) and 18 new unit tests, 12 in raycast.test.ts, 4 on the value system and 2 in the node catalog; two counter-checks hold: not counting tilemap cells one by one drops 2 unit tests, and answering with the nearest hit only drops module 43 with 2 FAILs; one finding on the way - the node read "maxHits" while every other input carries a spoken label, because int() took the key as its label, so it has a third parameter now, and the e2e check caught that, no unit test did (#157)
game-maker / gate (push) Successful in 2m4s
game-maker / suite (push) Successful in 7m26s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 18:54:12 +02:00
adminandClaude Opus 5 0fdde6cb1e The Raycast node filters by collision layers: the node gained a "Layers" setting, a tickbox per collision layer of the project - the same field kind the collider uses for "Collides with" - and the ray only hits what sits on a ticked layer, tilemaps counting as colliders; the default is every layer, and a graph without the param answers exactly as before, which is its own unit test; the check is one-sided on purpose, unlike the symmetric rule of #152: a ray is not a collider, so it never reads the collidesWith of the thing it hits - a collider that meets nothing is still seen by a ray - because who a ray may look at is the ray's business, and a unit test holds that case; the filter sits in Solids.raycast in front of the intersection test, which is the one path the editor run and Play both take, so every unit test runs through describe.each against both systems; NodeView did not know the field kinds layer/layers, so the node would have read "Layers: 65533" - it now shows the name of the layer, or "All layers"; 5 new e2e checks in the new block 43d (a shield on its own layer between the caster and the floor stops the ray, the "Layers" row on the node, the tickbox menu with the project's layers, the ray reaching 280 instead of 145 units once the shield's layer is unticked, and the mask read as a name) and 6 new unit tests, three against each system; two counter-checks hold: disarming the filter line drops 6 unit tests and the unticking check in module 43, and switching the formatting off makes the node check fail with the raw bits; two findings from measuring came out of it and stand in the wiki and the notes - __gm.rays() only measures while the Scene tab is in front, so a node selection blinds it and the block measures through a Log node on distance instead, and a running behavior instance keeps the graph it started with, so the editor run casts with the old mask until the instance is built anew (#158)
game-maker / gate (push) Successful in 2m7s
game-maker / suite (push) Successful in 7m22s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 18:15:35 +02:00
adminandClaude Opus 5 25ec7879a0 Several transitions between the same pair of states: make transition onto a state that is already connected in that direction adds another transition, which gets its own name right away (Transition 2, Transition 3, skipping names that already leave the state), so two unnamed transitions between one pair never exist; renaming is refused when another transition between the same pair in the same direction already carries the name, the empty name included, while equal names to other targets are only warned about as before; the behavior graph keeps Already connected; self transitions may repeat too, each further loop drawn as a ring around the previous one with the preview showing the next ring, and transition labels that would cover another one slide along their own edge until they are free (pure function freeLabelSpot); test hook transitionLabelAt also returns width and height, e2e 26e6 (#162)
game-maker / gate (push) Successful in 1m58s
game-maker / suite (push) Successful in 6m50s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:30:52 +02:00
adminandClaude Opus 5 010b6a7f6c A state can transition to itself: make transition and a second click on the same state connects a self transition, taking it fires On state exited and then On state entered every time, for local and global machines, while Set state into the current state stays silent as before; only states may connect to themselves, every other node is still refused, one self transition per state; the loop is drawn over the middle of the top edge with its arrow pointing down and its name at the apex, the preview already shows it while armed, and the inspector lists it once under Transitions out; new test hook transitionLabelAt, e2e 26e5 (#163)
game-maker / gate (push) Successful in 1m56s
game-maker / suite (push) Successful in 6m45s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:10:02 +02:00
adminandClaude Opus 5 6a31f5e0ad One-way platforms, raycast and collision stay with contact data: a collider can be one-way with a side it blocks from (turning with its object, drawn thick in the scene view) and a tile can be one-way from above, one-way cells never merge with solid tiles, a contact blocks only when its normal is within 45 degrees of the blocking side, the new node Ignore one-way lets an object drop through for a while, and it all holds for Move and for dynamic bodies in Rapier, where the decision is made before each step because the hook may not touch the engine (#155); the new Raycast node answers hit, object, collider, point, normal and distance in our own f64 geometry, the same in editor run and play, and the editor run draws each ray for 0.25 s (#153); On collision stay gains an interval with its own clock per pair, and stay and enter gain the contact point next to normal and impulse (#149); fixes that every Rapier collider of a tilemap had the shape of its last cell
game-maker / gate (push) Successful in 1m55s
game-maker / suite (push) Successful in 6m50s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 11:36:11 +02:00
adminandClaude Opus 5 1b5e386b60 Move with collision reacts along the contact normal: it travels to the surface, stays flush and slides the rest of the move along it with Coulomb friction (the mean of both colliders, a tile counts as 0.5), a circle now rolls over an edge instead of catching on it, pushed dynamic bodies move off along the normal, Move gains the outputs hit, normal x and normal y, an object without its own collider moves freely, and editor run and play share the same path through Solids; contact point and normal come from our own f64 geometry (contactBetween), because Rapier's castShape lands 3e-6 px inside the wall in f32 (#150)
game-maker / gate (push) Successful in 1m48s
game-maker / suite (push) Successful in 6m23s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:52:11 +02:00
adminandClaude Opus 5 0ff3c8b742 Physics stage 1b: six new nodes act on dynamic bodies in play (Apply force over the frame, independent of the frame rate, Apply impulse, Set velocity, Set angular velocity, Get velocity, Set gravity for the rest of the run), a node on an object without a dynamic body and moving a dynamic body directly each log a hint once per object, and Move with collision now pushes dynamic bodies with a mass-dependent push while the mover stays flush; Move keeps its own move-and-slide instead of Rapier's character controller, which measurably snags on seams between colliders (#147)
game-maker / gate (push) Successful in 1m46s
game-maker / suite (push) Failing after 5m35s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:19:21 +02:00
adminandClaude Opus 5 78899b1ace Physics stage 1a runs on Rapier: a new Rigidbody child object (body type, mass, gravity scale, damping, fixed rotation, CCD) makes its parent fall, bounce and come to rest in play, colliders gain friction, bounciness and density, the scene gains gravity (0, 980), the new PhysicsSystem steps at a fixed 60 Hz with interpolation in world coordinates and treats a tilemap as static ground, On collision enter gains normal and impulse, play waits for Rapier and does not start without it, and the editor run keeps the old CollisionSystem; the existing collision tests run against both systems unchanged (#146)
game-maker / gate (push) Successful in 1m46s
game-maker / suite (push) Successful in 6m17s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:11:43 +02:00
adminandClaude Opus 5 195fc94e08 Animations blend out on Stop and at the end of Once (reset), and Play can restart a running animation (#106)
game-maker / gate (push) Successful in 1m33s
game-maker / suite (push) Successful in 5m37s
Stop animation gets a blend (s) port that fades the animation back to what
lies underneath. Play animation gets blend out (s) for Once (reset) and a
restart port that starts a running, paused or fading animation from frame 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:50:10 +02:00
adminandClaude Opus 5 8a377d9239 Play animation's blend is given in seconds, not in frames of the new animation (#106)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:14:15 +02:00
adminandClaude Opus 5 04dc254b3d Play animation can blend into the new animation over a number of frames (#106)
game-maker / gate (push) Successful in 1m33s
game-maker / suite (push) Successful in 5m34s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 10:03:53 +02:00
adminandClaude Fable 5.1 89c46c30a0 An animation is previewed above its timeline: the root at the playhead, drawn but never written (#99)
game-maker / gate (push) Successful in 1m47s
game-maker / suite (push) Failing after 4m55s
The Animation tab shows the root asset over the timeline, with every row's value at the
playhead written onto a copy of it – the sprite, text or container placed at the origin as a
scene would place it, a scene as itself. Rows reach the root through that placed object and its
children through the object's instance edits, the route a placed container's content takes,
so the ids match what the runtime gives them. The runtime player and the preview evaluate a row
through one function (trackWriteAt), relative rows included; past the end of a once-reset pass
the preview shows the starting values, as the player would. Play, pause and the ruler drive the
preview through the timeline's playhead; a drag pans, the wheel zooms, Centre frames it. The
document is never touched and undo never hears of scrubbing.

On animation finished no longer describes itself as hearing "a sprite of the owner".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:00:18 +02:00
adminandClaude Opus 5 4ed2edd0f0 An animation is a value a graph can pick, and playing a running one makes it win again (#97)
Play, Pause and Stop animation take the animation on a port instead of a
fixed setting, fed by the new Animation literal or a list item tagged
`$animation`. Playing an animation already running keeps its frame but
counts it as started last, so turning back to a walk that never stopped
shows that walk. The actor demo walks through one Play animation over
`ItemAt(walk, facing)` again and stops every animation to stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 20:47:31 +02:00
adminandClaude Opus 5 193378e6a9 An animation is a timeline of rows over one asset, played on an object rather than worn as a picture (#97)
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>
2026-09-16 20:02:25 +02:00
adminandClaude Opus 5 d3563897d8 Two compilers that walk a graph the same way walk it through one emitter (#85)
game-maker / suite (push) Failing after 2m22s
game-maker / gate (push) Successful in 2m53s
Refactoring round two: the particle and shader compilers share emitGraph.ts (cycle detection,
duplicate names, emit order) and keep only their rules. FontLibrary and TextureLibrary sit on
FileLibrary (base URL, loading/failed bookkeeping, listeners); SoundLibrary keeps its own
contract. The three Create*Command share ImportFileCommand, the three renames share
RenameCommand - class names stay. bandBounds() in framing.ts and portNameField() in
portSection.ts replace in-file clones, and three comments that told history now state the rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:28:28 +02:00
adminandClaude Fable 5.1 cc6db5f350 Three stores, three importers and three pickers that differ only in their type are one of each (#85)
game-maker / gate (push) Successful in 1m33s
game-maker / suite (push) Successful in 5m46s
Refactoring round one: HttpFontStore/HttpSoundStore/HttpTextureStore collapse into a generic
HttpFileStore over a FileStore contract; fontImport/soundImport/textureImport share assetImport
with an ImportKind per sort; ImagePicker/FontPicker/SoundPicker sit on FilePicker and describe
only their row. The group/comment drag logic leaves GraphCanvas for annotationDrag.ts (with its
own unit test), declaredRows.ts replaces the duplicated row build in particles.ts and shaders.ts,
and dead code in font/sound/compile/emission/transform/actions is gone. README: asset list,
check count 958, the two-job CI, the Scenes wiki page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:38:54 +02:00
adminandClaude Opus 5 b9f1a9d319 A graph that grew past one screen needs somewhere to say what a part of it is for (#38)
game-maker / suite (push) Successful in 6m13s
The graph could hold any number of nodes and said nothing about which of them
belonged together. Groups and comments are two separate things, as decided in
the issue: a group is a titled box that carries the nodes it encloses, a
comment is free text that carries nothing.

Both live as optional arrays on Graph, not as registry nodes. A comment that
were a real node would run through the interpreter, show up in the drop
palettes and be compiled into a shader — the wrong place for a piece of text.
As arrays on Graph they exist in all four graph kinds at once, and a document
without them means exactly "no groups", so no format bump.

Membership is computed when the box is grabbed, never stored: the center of a
node inside the box travels with it, and a node dragged out is simply out. A
box inside a box travels with the outer one, its own nodes included. A group
is grabbed by its title bar only — its body stays transparent to the pointer,
or a rubber band could never be drawn across the nodes it holds. Deleting a
box deletes the box; the nodes stay where they are, because a box that
silently took ten nodes with it would be the most expensive surprise in the
feature.

The color is a free picker, and the title color follows from it: relative
luminance per WCAG, threshold at 0.179 where the two contrast ratios meet —
not 0.5, which would pick white on mid grey where black reads better.

Resizing snaps the far edge to the grid, not the width, so the corner that is
not being dragged stays put. The minimum size rises in whole steps for that
reason: 80 is no multiple of 24, so a box squeezed to the stop would have sat
off-grid in exactly the mode meant to keep it on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:24:34 +02:00
adminandClaude Opus 5 1135205d69 A machine that arrives as a value can still be commanded, so a name may name its state (#58)
game-maker / suite (push) Successful in 6m12s
Set state and State branch derived their ports from the graph, so a machine
that came out of a variable or a list had no ports at all: nothing to set,
nowhere to branch. It could be passed around but not operated.

stateByName() resolves id first, then name; a name two states carry answers
with nothing, the same rule transitionTo already follows for two transitions
of one name — without something in the graph that decides between them, any
pick would be a guess.

Set state by name takes the machine and a text, and has a second exec output
not found, so a typo is not swallowed in silence. State branch gains a
derived else that fires whenever no state output matches — no state set, a
state of another machine, or a machine that arrives as a value and therefore
has no state outputs at all. A flow that enters State branch now always
leaves it somewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 09:08:57 +02:00
admin 1e9d21cd5d A game that is more than one scene needs a way from one to the next, so the graph can load one (#39)
game-maker / suite (push) Failing after 11m6s
Load scene sets a flag on the World like Stop and Restart do; the swap
happens after the tick, and the last wish of a tick wins. Exactly one
thing crosses over: the SharedState, so global variables and global
machines keep what they know while everything local is built anew.
Loading the scene that is already running rebuilds it, which is what
"reset the level" means when the score must survive.

A run starts either from the start scene (a new project field, set from
the asset panel and marked with an arrow in the list) or from whatever
scene is open. The toggle next to Play says which question is asked and
remembers the answer in localStorage: it is a way of working, not part
of the game.

Sounds no longer end because a scene ends. A voice belongs to the
behavior that started it and stops right after On destroy, so the scene
swap is no longer a special case at all. For the enemy that plays its
explosion and destroys itself in the same breath there is now a
detached input on Play sound.

The scene selector stays usable while a run plays, because a double
click in the asset panel already did the same thing unguarded. It moves
what the editor looks at, not what the run plays.
2026-09-15 13:24:26 +02:00
admin 5671a38044 A game that stays silent is only half a game, so a sound is an asset like any other (#45)
game-maker / suite (push) Successful in 12m1s
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.
2026-09-15 07:54:28 +02:00
adminandClaude Opus 5 7babf88636 A state change is something the graph can react to, so the machine announces it (#53, #54, #55, #57)
game-maker / suite (push) Successful in 10m58s
Four issues that all reach for the same line: how the current state is
read and written. Splitting them into four commits would mean four
states of the tree that do not compile, so they arrive together.

#55 start state — the machine asset carries an optional `start` holding
a State node's id. Right-click a state to set or remove it, a ring in
the node's title says which one it is. A dead id (the state was deleted)
reads as "no start", so nothing has to clean up after a deletion.

#53 entered/exited — `On state entered` and `On state exited`, one exec
output per state. Every change now goes through a single `setState`
that reports it; BehaviorSystem delivers it after the exec flow that
caused it, `exited` of the old before `entered` of the new, round after
round until the queue is empty, with a cap that stops a ping-pong graph
with an endless-loop error. A local machine tells the instance that
switched, a global one tells every instance holding a matching event
node. The start state fires `entered` too: per instance for a local
machine, once per run for a global one.

#57 reading — `Current state` with `name` for showing and `id` for
comparing, `Is in state` with one boolean per state. Neither has a
`run`, so `read` learned `def` and `lookup`, and the catalog test knows
this third kind of node.

#54 seeing it — during a run the state machine tab marks every state
some instance is holding faintly, and the one held by the selected
scene object's instance strongly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 06:18:20 +02:00
rootandClaude Opus 5 8143130ed2 A shared value belongs to no behavior, so it becomes an asset of its own (#91)
game-maker / suite (push) Failing after 8m59s
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>
2026-09-14 18:05:36 +02:00
rootandClaude Opus 5 d1fcfe8b81 A shared value belongs to the run, so a declaration carries its scope (#91)
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>
2026-09-14 16:22:53 +02:00
rootandClaude Opus 5 68c92daaa6 A transition carries a name, so a state needs no visible port (#52)
game-maker / suite (push) Successful in 10m22s
A state keeps its `in`/`out` exec pins in the model and the editor never draws
them. That is the whole trick: connecting, undo, deleting a state and saving all
keep working untouched, because `normalizeGraph` filters nodes and never edges,
and no edge ever checks its port key against a NodeDef. Only two places change --
`NodeView.redraw` stops drawing the row, and `portAt` refuses to hand one out, so
an invisible pin cannot be grabbed either.

Transitions are drawn by right-clicking a state, `Make transition`, then clicking
the target. The armed mode takes the same shape as a live drag, so the existing
preview draws it with no second code path, and it dies with Escape, a click on
empty canvas or a switch to another asset.

Removing the pins removed the only handle a transition had: edges have no
hit-test anywhere, and the pin right-click was the sole route to Disconnect.
The inspector's TRANSITIONS OUT / TRANSITIONS IN lists are therefore not a
convenience, they are the delete path.

`GraphEdge` gained an optional `label`, which `normalizeGraph` has to pass
through explicitly or a saved name is dropped on load. `State transition`
replaces `Next state`: it takes the machine on an input port and the name from a
picker, and resolves against the *current* state. Names are deliberately not
unique across a machine -- two states may both have an `alarm` -- so a node named
`alarm` moves whichever state has one, and an empty name follows the single
unnamed edge.

The geometry moved to a Pixi-free `transitions.ts`: a gap to the box border, and
a fan that puts a pair of opposite edges beside each other instead of on top of
each other. Keeping it as plain maths is what let a screenshot bug become a unit
test -- an earlier pin-to-pin line ran under the target node and took its
arrowhead with it, because `nodeLayer` sits above `edgeLayer`.

One limit stands and is not fixed here: `ConnectCommand` rejects a second edge
between the same two states ("Already connected"), because every transition
hangs off the same two ports. With names, `A --alarm--> B` beside
`A --seen--> B` is a sensible pair, so this is worth its own issue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:05:18 +02:00
root a041322a0e An edge to a vanished port needs a handle, so the port stays as a ghost (#60)
game-maker / suite (push) Successful in 10m7s
Four ways erase a derived port and take no edge with them: deleting a state,
deleting the `State machine` literal, deleting `Behavior start`/`end`, deleting
the asset. The common cause is `RemoveNodeCommand` clearing only its own graph,
so the fix sits in `drawnDef` (graph/nested.ts) and touches no delete site.

A right-click on an edge goes through `view.portAt` only — there is no curve
hit-test. Without a port row such an edge is not merely invisible, it is
unreachable, so the ghost row is the handle Disconnect hangs on.

`resolveDef` stays untouched: it is the truth for interpreter, compiler,
ConnectCommand and dropTarget, so nothing runs or connects through a ghost
without a second guard.
2026-09-14 11:50:36 +02:00
root 0f9e1611d1 A particle is a function of its age, not a thing that is stepped (#73)
game-maker / suite (push) Failing after 4m22s
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.
2026-09-14 05:44:25 +02:00
rootandClaude Fable 5.1 016e83c9b5 Transform: scale, rotation and mirror as properties, world transform as a matrix chain, oriented colliders (#76)
game-maker / suite (push) Successful in 9m5s
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>
2026-09-13 17:25:06 +02:00
rootandClaude Fable 5.1 3da0ef1be2 Shader graph: alpha input on Output, Texture alpha node (#70)
game-maker / suite (push) Successful in 7m55s
Output gains a number input `alpha`; unconnected it reads the texel's
alpha, like `color` reads the texel. The epilogue premultiplies with the
resolved alpha. New `Texture alpha` node exposes texel.a for maths.

PortDef.inline=false keeps the alpha port without an inline field: every
number port is inline by default, and a typed default of 0 would have made
every shaded sprite invisible. The compiler's texel fallback is typed:
colour ports get rgb, number ports get texel.a.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 11:51:59 +02:00