A scene with 200 spawned gold pickups, each with a Pickup behavior of
97 nodes, dropped the game to about 14 fps. The graphs themselves ran
only a dozen nodes per pickup and frame. The cost was the lookup: for
every data input the interpreter searched all edges and all nodes of
the graph linearly and computed the port shape of the node again.
- New module core/graph/graphIndex.ts builds tables once per graph:
nodes by id, edges by node and port, node types. Port shapes are
cached as well.
- BehaviorSystem shares one cache between all its instances and
clears it in syncFromModel. A graph changed during a run applies
from the next frame on, as before.
- An interpreter without a cache, as in most tests, searches as
before.
Measured on the scene Performance test (headless per frame, browser
fps on a throwaway copy of the project):
- Headless: about 21 ms down to about 3.6 ms per frame.
- Browser, about 505 entities: 13.6 fps up to 23 fps.
- Browser, about 320 entities: 32.3 fps up to 48 fps.
- After the fix behaviors take about 15 % of the browser profile.
The rest is the editor mirroring the game into the model and
rebuilding its panels, which is a separate change.
Tests:
- ./check.sh: 192 files, 2786 tests, CHECK EXIT 0.
- Full e2e run (local): ALL PASSED, 1551 checks.
- tests/core/graph-index.test.ts: 2 tests. The tables return the
same as the search, in the same edge order. A graph changed during
a run applies from the next frame.
- Counter-check: without clearing the cache in syncFromModel the
second test fails with ['before'].
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Which of two events ran first depended on hidden lists: the order of
the behavior instances and the order in which nodes were created. A
graph had no way to say that one On update must run before another,
and an On update of one behavior could run before a key event of
another.
- A frame now runs event kind by event kind over all behaviors: On
disable and On enable, key down, key up, key held, timer, collision
enter, stay and exit, animation finished, click, mouse wheel and at
last On update.
- All 17 event nodes have a field Order, a whole number, default 0,
negative allowed. Within one kind a lower Order runs earlier, no
matter which behavior holds the node.
- On a tie the current hierarchy decides from top to bottom, then the
order in which the nodes were created. The old rule that a behavior
enabled later goes to the end is gone.
- The node shows Order: 5 only when the value is not 0, so existing
graphs look the same.
- The rule lives as pure functions in core/graph/eventOrder.ts. The
interpreter can now fire a single event node.
Changes in existing projects:
- An object destroyed in a frame gets no further event in that frame.
On disable and On destroy still come at the end of the frame.
- New instances get all their On start first, then all On enable.
Teardown runs all On disable first, then all On destroy.
- State events follow right after the event kind that caused them.
- Four unit tests had fixed the old order and are adjusted:
two in behavior-events, one in physics-system, one in key-repeat.
Tests:
- ./check.sh: 191 files, 2784 tests, CHECK EXIT 0.
- GM_STEPS=1-6: ALL PASSED, 280 checks.
- Full e2e run (local): ALL PASSED, 1551 checks, measured.
- tests/core/event-order.test.ts: 11 tests for the rule, the field on
all 17 nodes, order across behaviors, key before update, two nodes
in one graph, On start, hierarchy after late enable and reorder at
runtime, state events, save and load.
- e2e 6z-viii, 2 checks with real mouse and keyboard: Order -1 set in
the inspector shows on the node, and a run logs B, A although B is
lower in the hierarchy.
- Counter-checks: 5 in unit tests and 1 in the browser, all fail as
expected. The originals were restored and checked with cmp.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The object queries only ever returned one object: Object by name,
Child by name and Find all stop at the first hit. Getting every coin,
every child or every item that passes a test needed a For each with
its own bookkeeping. These nodes return the whole set as a list of
Object, so For each and Find can use item straight away.
- Objects by name returns every scene object with that name in scene
order.
- Children by name returns every match below object, nearer levels
first, the same order as the singular node.
- Children returns the direct children, with recursive all of them
level by level.
- Objects of asset returns every instance of an object asset or
container by the same rule as Is asset, spawned ones included.
- All four take a snapshot. Destroyed objects are never in it,
disabled ones are, as with the singular nodes.
- Find has a Mode setting: First, All, Any and Count. First is the
default and hides the row, so existing graphs look the same.
- Match follows the mode of its paired Find: done with items and
indices, done with any, or done with count. An unpaired Match shows
First, since the pairing only comes from the wiring.
- First and Any stop at the first hit, All and Count walk the list.
- Changing the mode drops edges at outputs the Match no longer has,
in one undo step. SetNodeParamCommand now cleans the whole graph,
but only edges that were valid before the change.
- The palette entry "filter" adds a Find set to All with its Match.
Found on the way:
- After adding the filter entry, "find" + Enter picked Filter. The
entry has node type Find, counted as an exact hit, and the tie went
to the alphabet. The search now ranks by the entry id. e2e 6z-v
caught it.
Tests:
- ./check.sh: 188 files, 2746 tests, CHECK EXIT 0.
- find-modes.test.ts with 11 tests, 11 catalog cases for the four
nodes, the asset input guard now expects 3.
- e2e 6z-vii with 3 checks: filter from the palette, Count picked in
the inspector drops the items edge, a run logs 2, 2, 2, 3, 4, "0, 2".
- GM_STEPS=1-6: all passed, 278 checks.
- Counter-checks fail as expected: every mode stopping early (2),
Match ignoring the mode (6), cleanup on the own node only (1), no
recursion (2), no inOrder (2), old ranking (1). In the browser a
Match ignoring the mode fails both editor checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reading one field of a struct needed its own Expand node, even where
the graph already knows the struct type. A split output shows the
fields directly on the node, so a Find … Match test becomes a single
Equals and the wires keep running left to right.
- Right-clicking an output pin offers "Split into <Type> fields" when
the type is known and "Split as …" for every other struct asset.
The output is replaced by one pin per field with the field's kind.
- The type is known for typed struct variables, typed Pack/Expand,
fields of another split and item of For each, Find, Match and Item
at over a typed list (portSplits.ts).
- Right-clicking a field pin offers "Recombine". Splitting drops the
edges of the whole output, recombining drops the field edges. One
undo step brings them back.
- The split is stored as GraphNode.splits and saved with the graph.
Copy and paste carry it along.
- Field pins follow the struct asset like a typed Expand. A new field
gets a pin, a removed field loses pin and edge, a renamed field keeps
its edge. Deleting the asset keeps the last fields. The delete dialog
counts splits, so it now says "node(s)" instead of "Pack/Expand
node(s)".
- Renaming a variable carries its split and the field edges along.
- At run time the node still produces the whole value. A field pin
reads its field like Expand, a missing field reads as empty.
- Limits: outputs only, one level, behavior graphs only.
Tests:
- ./check.sh: 185 files, 2706 tests, CHECK EXIT 0
- e2e GM_STEPS=1-6,54,55: ALL PASSED, 303 checks
- New port-splits.test.ts with 12 tests. e2e 6z-vi with 3 checks
using a real mouse: split Find.item, wire item.Name into the test
and Match.item.Amount into the log (logs 7), recombine.
- Counter-checks: without field reads in the interpreter, without
rename in the split, without moving edges on rename and without
cleanup on field removal one unit test fails each. In the browser,
without field reads nothing is logged and without type inference
the split entry is missing.
- Check total in README: 1520.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Searching a list with For each needs the test result wired back into
break on the left of the loop node. The wires then run across the node
and the graph reads badly. Find … Match keeps the whole search flowing
left to right, while the test in between stays a free graph.
- Find takes exec and list and offers item and index. It sets them to
each element in turn and has no exec output of its own.
- Match reads its bool input match once per element. The first true
value continues at found with item and index of the hit. No hit or an
empty list continues at not found with an empty item and index -1.
- The pair is derived from the wiring and stored nowhere. A Match
belongs to the Find whose item or index its test is built from. Copy,
paste and undo therefore need no extra code (findBlock.ts).
- An unpaired Find or Match, two Matches on one Find and a Match fed
by two Finds show a warning on the node. At run time the flow ends at
an unpaired Find and the console says so.
- Adding Find from the palette places its Match 480 px to the right in
the same undo step.
- The graph draws a quiet frame behind Find, Match and the nodes of the
test. Literals feeding the test from outside stay outside the frame.
- The test consists of data nodes only, like the break of For each.
Tests:
- ./check.sh: 184 files, 2694 tests, CHECK EXIT 0
- e2e GM_STEPS=1-6: ALL PASSED, 272 checks
- New find-match.test.ts with 12 tests, 2 catalog cases, e2e 6z-v
with 3 checks using a real mouse.
- Counter-checks: ignoring the test fails 4 unit tests, no ambiguity
check fails 2, framing everything fails 1. In the browser, no frame
and no Match from the palette each fail their check.
- Check total in README: 1517.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finding the first item that matches took Count, Repeat, Item at and a
Branch, and the loop could not stop once it had found something. A graph
has no lambdas, so the predicate is an input that is read every round.
- For each runs body once per element, with item and index set, and
goes on at done.
- Its break input is read anew before every round, after item is set.
Once it is true the loop ends there and that round's body does not
run.
- done sees the matching item and index with stopped true. Without a
match it sees no item, index -1 and stopped false, the OrDefault.
- RepeatResult has optional items, stopWhen, stopped and exhausted.
GraphInterpreter.loop reads them, Repeat runs as before.
Tests:
- ./check.sh: 180 files, 2623 tests, CHECK EXIT 0
- e2e GM_STEPS=1-6: ALL PASSED, 269 checks
- 5 interpreter tests in graph.test.ts, 2 catalog cases
- e2e 6z-iv: the palette creates the node, and a search for
Name == "Gold" in Axe, Gold, Bow, Gold logs "1true"
- Counter-checks: without the break check 2 unit tests fail, and with
break not read over its edge 6z-iv fails
- This commit alone: tsc and the three touched unit files pass
(349 tests)
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>
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>
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>
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>
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>
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>
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>
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>
The graph was only locked by five edit-mode guards in GraphCanvas. The
running game reads the graph by reference each frame, so a new node, edge,
changed value, disconnect or undo takes effect from the next frame. Stop
discards these edits like everything else changed in play. A new On start
node does not fire for a behavior that has already started.
During play every data output shows the value it last produced, in the
highlight colour and refreshed ten times a second. Values are kept per
graph, because node ids are only unique within one; with several
instances the most recent value wins. The Variables node reads the live
variable bag, so only its own outputs are kept, as a snapshot.
Each time an exec edge fires, a bead runs along it in 0.3 s, at most one
every 0.15 s per edge, following the same curve the edge is drawn with.
Beads run on game time and freeze while paused; the game logic is
unchanged.
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/Set variable nodes now title themselves "Get <name>" / "Set <name>", and
the new Variable node just "<name>"; the title is the same folded or open,
and a collapsed node shrinks to it. The redundant "Variable: <name>" row is
gone unless the variable was deleted ("<name>?" is the only hint then).
The Variable node sets its value when an exec flow arrives and exposes the
value at any time. Its output is read when a downstream node pulls it, not
frozen at the write, so count = count + 1 is one Variable node plus an Add.
Get variable keeps reading once per pulse: switching it would change the
behaviour of existing games.
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>
The Outputs setting is gone. A Sequence now keeps one free output below the
last connected one: nothing plugged in is one output, connecting the spare
grows a new one, unplugging the bottom edge shrinks it back.
It counts the last *connected* port rather than the number of edges, so
unplugging a middle one leaves a gap instead of silently dropping an edge.
Removing the setting also removes a bug it caused. Typing in the Outputs field
did nothing and the arrow keys did nothing, while dragging the value worked:
onInput fires per keystroke, the preview writes into node.params, that emits
'project', and Inspector.refreshValues() sees the node's signature change --
the port list is part of it -- so it rebuilds and destroys the row being typed
in. A drag survives because it keeps receiving globalpointermove on the old
widget. refreshValues() has no focus guard where refreshLive() does; the trap
is still there for the next typed field that changes a node's shape.
Ports that depend on the edges around a node mean derivePorts needs the graph,
not just the params, so resolveDef and portSignature take it too. And a node
whose ports are derived cannot know how many it has from its type, so run is
handed the definition resolved for it.
Ports that vary per node already existed, but only for the three nested-behavior
types, and the rule for them sat as a switch on type names in nested.ts – the
one place in the project that knew node types by name. It dissolves into two
optional contracts on NodeDef, so a type carries its own rule:
declaresPorts the node holds an editable list, shown as a section in the
inspector – behavior start/end, and now Pack/Expand
derivePorts the node computes them from its settings – Sequence from a
count, Run behavior from the behavior it names
Sequence gains an Outputs setting (2 to 26). Its keys stay a, b, c and count on
with d, e, so the edges of the sequences already out there survive.
A param can now take a port away, and the edge on it would stay: it draws as
nothing yet survives every save, and comes back to life if the port returns.
SetNodeParamCommand takes those edges with it, which also closes the same leak
that switching a Run behavior node's target has always had.
The port editor addresses its node by id rather than by type, so two Pack nodes
can live in one graph, and it calls the rows Fields when they are not a
behavior's signature.
A behavior that has a Start and an End node can now be dropped into any
other graph as a node of its own. What is declared on the start node
becomes the inputs of that node, what hangs on the end node its outputs;
the port list is derived from the sub graph, not copied, so declaring a
port in one graph grows the node in the other without touching it. The
node carries the behavior's name and the palette lists behaviors by
name, so a call is one grab instead of a node plus a dropdown.
An embedded behavior has no object and therefore no place for
variables: every call starts on the asset defaults and communicates
only through its ports. That is the conservative direction - state can
still be added later, and shared state already has a home in a behavior
object in the scene. Node ids are unique per graph only, so each call
gets its own pulse cache; without that, two graphs would collide.
Renaming a port used to silently drop the value typed on the calling
node. It is carried along now, project wide, like the edges.
A Tetris example project needs to draw 200 board cells and scan rows
for a full line. Neither is expressible without iteration, so the node
system gets a Repeat node plus the missing text side: Substring, Find
text and To number. The interpreter drops its data cache once per
round -- otherwise every round would recompute with the first round's
values.
Object by name takes its name as an input now, not just as a setting;
addressing "c" + index is impossible otherwise. An unconnected input
reads the stored setting, so saved projects are untouched.
The board itself is a 200-character text, "." for empty. That makes
both operations of Tetris a single expression instead of a loop, and
each frame attempts exactly one move -- rotate beats sideways beats
fall -- so the fit check exists once in the whole graph.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Remove comments that only restate the code, keeping the section banners
and the notes that carry a reason, a measured fact or an external
contract.
ButtonOptions.width claimed to be a fixed width but only set minWidth,
so the Graph and Texture pickers grew with the name they showed. It is
now what it promises, with a separate minWidth for the buttons that have
two forms. Narrow buttons shrink their padding, otherwise a 24px icon
button had 8px left for its glyph and truncate dropped it entirely.
__gm.labelsDropped() guards that class: the overflow guard only saw text
that was too wide, never a label truncated to nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tooling:
- Prettier as the single formatting authority (110 columns, single quotes,
measured against the existing style); Markdown excluded because table-cell
padding turns the README glossary into 1000-character lines.
- Type-aware ESLint ending in eslint-config-prettier, so a rule always means
"this is a bug", never "this looks different".
- .editorconfig and an `npm run check` that runs typecheck, lint, format and
tests in one go.
Findings fixed on the first run: three more duplicate imports, four
String(unknown) calls (parameter types narrowed instead of casts), two dead
initialisers, two redundant casts, a console.log for a real error. Two
warnings were false here and are disabled with a reason in place.
Tests 132 -> 269:
- node-catalog.test.ts covers every node with a run(), with a guard that fails
when a new node has no row.
- behavior-events.test.ts covers the ten event nodes through a running World,
checking timing rather than existence.
- Input's state machine (press/release/releaseAll) is reachable without DOM
events now; the DOM handlers are thin adapters.
Compare fell back to == without a configured operator although the schema
default is <; aligned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>