A behavior run by another one had a single flow port at its Start
and at its End, so it could not branch for its caller. And it could
not be used as an event: a call needs a pulse at its flow input,
and the events of a called graph never fire. An input like
"Confirm" (Space or Enter) had to be built again in every graph.
- A declared port of a Start or an End node can be of the kind
Flow. It sits above the values and shares their names, exec stays
reserved. Only these two nodes declare one, a struct does not.
- Run behavior shows a flow port of the Start as a further flow
input. A call arriving through it leaves the Start through the
output of that name and not through exec.
- Run behavior shows a flow port of the End as a further flow
output. The call goes on through the one the End was reached by
last, and then through exec. exec fires always, as before.
- On behavior is a new event node without a flow input. The
behavior it names runs at the node for as long as the behavior
around it runs, with all of its own events and one set of
variables per node.
- Its outputs are those of the End. It fires once the inner pulse
is through, by the output the End was reached by last, exec only
for End.exec. A pulse that never reaches the End is not seen.
- The value ports of the Start are its inputs, read anew for each
inner pulse. The Start itself never fires there.
- It nests to any depth. Under a Run behavior it stays silent. A
behavior hearing itself, however far round, is not started and
the node says "A behavior cannot listen to itself".
- The palette offers every other behavior with an End once more as
"On <name>". A node naming one without End says so.
- An On node removed while the game runs ends its behavior with On
disable and On destroy. The sounds of the entity end only with
the behavior placed on it.
- Renaming and deleting a declared port follows the edges at On
behavior nodes as it does at Run behavior nodes.
- The flow outputs of the Start of a behavior placed on an object
never fire, like its exec.
- The engine prompt of the benchmark names flow ports and On
behavior.
Tests:
- Gate on melektaus-atx right before this commit: 215 files, 3319
tests (3278 + 41), exit 0.
- e2e 19 on melektaus-atx: 39 checks (+4), all pass. e2e
1-6,17-24,26,36,51: 628 checks, all pass.
- 19 unit tests in flow-ports.test.ts, 19 in on-behavior.test.ts
on a real world, the rest in the node catalog.
- Four new e2e checks in module 19: a flow port declared by mouse
at the End, On Confirm in the graph, the call leaving through the
further output while playing, Enter and Space on a real keyboard.
- 19 unit counter-probes all fail their tests. One did not at first
(a removed node kept its instance), a test was added. Two browser
counter-probes fail their checks.
- One screenshot looked at: On Confirm with exec and pressed, the
second flow pin at the calling node.
- The e2e sum of 1680 is counted (1676 + 4), the full suite and
the smokes did not run.
- Not seen in the browser: flow inputs at the Start, nesting, the
ring, values through On behavior, the palette entry. No test
holds that the sounds of the entity go on when a heard behavior
ends.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A behavior often needs working state such as a timer or a counter.
Until now every variable showed up on each placed object and in the
Properties menu, so the inspector filled up with values nobody should
touch. A private variable works like any other inside its graph but
is not offered anywhere outside.
- Each behavior variable gets a lock switch next to Keep. It starts
off, so existing projects stay as they are (`privateVariables`).
- A placed object shows no private variable, neither in the
inspector nor while the game runs. If all are private, it says so.
- Switching a variable to private drops its overrides on every
placed object in one undo step. Switching back does not restore
them.
- A running game does not write a private value back as an
override, and a project change during the run does not reset it.
- `+ Property` no longer offers private variables. A port that
names one by hand still reads and writes it without a warning.
- The default value of a private variable is read-only in the asset
inspector. Its shape (name, fields, type, delete) stays editable.
- Keep is now a pin icon instead of a word. With the lock added, the
header of a list variable had no room left and the lock covered
`+ Item`.
Not done: the animation track picker still offers private variables.
Tests: ./check.sh -> 186 files, 2711 tests, CHECK EXIT 0.
e2e 1,19,20,21,56 -> ALL PASSED, 119 checks. New module 56 has
7 checks with a real mouse. Counter-checks fail as expected: unit
without the write-back filter, the sync guard, dropping overrides or
loading the flag. Browser with all variables on the object, an
unfiltered menu, or no value lock.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep is a choice the author makes per variable. Switching it on for
every new variable meant most behaviors carried state across runs that
nobody asked for, and the toggle had to be turned off by hand.
- AddVariableCommand no longer adds a new variable to keptVariables.
Keep is only switched on by hand.
- Existing projects are unchanged. Their keptVariables stay as saved.
- The comment in Asset.ts describes the new default.
- The counter fixture in keep-state.test.ts now keeps count
explicitly instead of relying on the old default.
Tests:
- ./check.sh: 2706 tests, CHECK EXIT 0
- e2e 1, 6, 19: ALL PASSED, 172 checks
- 19c-ii expects [] after adding, then ["var1"], [] and ["var1"]
after undo, all clicked with the real mouse
- Before the fixtures were adjusted, 8 unit tests failed on the new
default
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>
The suite spent 88% of its step time asleep: 564 s of steps, 496 s of
them in sleep(n) - fixed spans estimated by hand and then copied from
step to step. That is why it was slow, and why it flickered.
The signal is afterFrames(count, timeout) in ui/render.ts, exposed as
__gm.afterFrames(). It wakes the loop itself, because an editor that
draws nothing would never redeem a promise of a frame, and it gives up
after a timeout instead of hanging the run. settle() in the harness
waits two drawn frames (~32 ms) instead of 150-300 ms and falls back to
sleep(150) when the hook is missing (an old build): silently not
waiting would make every later check a coin toss.
Every action goes through it now - click, doubleClick, wheel, drag,
dragAsset, press, typeText, select, mutate, undo, redo - and with that
about 550 sleep() calls behind those actions became redundant, helpers
that are nothing but a mutate in a coat included. What a frame cannot
answer got its own condition instead: waitUntil(expr), play()/stop()
waiting on __gm.state.mode rather than on 300 ms, saved() for the
autosave (the two hand-written polling loops sampled at 300 ms for
something that arrives after a second), and showTab() as a real tool -
47 places called evaluate("__gm.showTab(...)") raw, and 24 of them
waited for nothing at all and read the old panel.
564 s -> 243 s of step time, the waiting share 88% -> 76%. Three kinds
of wait were kept, and each one bit when it was taken away: loading
bytes (textures, fonts), game time that a repaint cannot stand in for
(time scale, shader and particle clocks, the live values a running
graph carries), and the inertia the scroll step measures on purpose.
Two things the new speed uncovered, both real: 25-animation read the
frame menu unfiltered and had frozen an incomplete list (the CI failure
on c990dbc), and 33-render-on-change measured idleness while the
autosave of the step before was still painting - it waits for
saveStatus to leave dirty/saving now, so it measures that nothing
happens any more instead of that it waited long enough.
A log layer (src/editor/console/log.ts) keeps a 500-line ring buffer and
wraps console.log/info/debug/warn/error, still calling the original. Notices
and behaviour runtime errors go straight to the layer instead of through
console, so editor tests stay quiet and errors can render red. world.log is
untouched: it is per-run structured state that runtime tests and e2e read
directly, not a text stream.
The panel sits under the visible tab, resizable with Splitter('y'), with a
CONSOLE header and a clear button. Each line carries its wall-clock time in
its own dimmed column, so locate() still finds the message text.
The taller centre area drops the graph fit zoom to 0.60, which broke six e2e
checks that assumed one screen pixel is one graph unit and that a fixed
offset from the canvas centre is empty. They now ask __gm.emptySpot(),
__gm.graph.emptySpot() and __gm.graph.zoom() instead of guessing.
locate/locateAll added the unscaled Pixi text width to the global position,
so click points in the scaled graph canvas landed too far right; they use
getBounds() now.
check.sh: 831 tests. Full e2e: ALL PASSED - 671 checks. Smokes 24/19/20.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Get properties / Set properties work like their variable counterparts:
one port per chosen property, an unconnected input writes its inline
value. A port chosen from the built-in list keeps that property's field
and default (height starts at 100, color is a colour field). "object" is
reserved for the fixed input, checked by the command and on load.
The property picker names the owning type ("Sprite width"), and text
fields can now carry a suggestion list; Get/Set property use it.
Double-clicking a call node shows the path of opened behaviors under the
graph header. Clicking an entry jumps back and trims the path; choosing
a behavior any other way hides it. Reopening a behavior already on the
path steps back to it instead of repeating it. The bar shows before the
graph switches, otherwise it clips the graph that was just fitted.
The "Add node..." header button is gone; nodes come from the context menu.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus;
the port and marker menus were the same list and are now one function.
Value specs of the inspector and the list tab share valueSpecs.ts, and
the remaining copies (compound commands, type glyphs, typed asset
lookup, name rule, play/editor world setup, marquee) are merged.
Four copies had already drifted apart; merging them fixes:
- "Disconnect N links" in the port menu is one undo step, not N
- "+ Add collider" is hidden on a placed container instance, as in
the hierarchy menu, instead of always failing
- a bool property coerced from the text "false" becomes false
- a port command on an unknown node type reports instead of crashing
Comment-only edits leave the bundle byte-identical. README corrected in
35 places (step limit, jump-run clock, suite duration, 409 body, ...).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Steps 18 and 20 both hand-rolled the same object-plus-behavior scaffold, down
to lane offsets that nothing kept distinct. Step 19 claimed in a comment that it
cleaned up after itself but never checked it – the only one of the four that
did not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A behavior node that cannot run looked exactly like a healthy one: no
start node, no behavior picked, a deleted behavior or a self-call were
all silently skipped. The call still had to be forgiving at runtime -- a
dangling reference must not abort a game -- so the fix is diagnosis, not
an exception: describeProblem() names the one reason, the node draws it
in red under its title, and the inspector repeats it where you pick the
behavior. A missing end node is deliberately not a problem: a call is
always synchronous, and the end node only decides which values come back.
Two ways in: a behavior dragged from the asset panel lands in the graph
under the pointer, and double-clicking a call node opens the graph it
runs. Both reuse machinery that already existed -- the asset panel
already starts the drag, and list views already report double clicks.
The e2e module needed its assets at the head of the asset list: after 18
modules the list is long enough that a newly appended row scrolls out of
view, and the press then lands on the panel behind it, starting no drag
at all. That looked exactly like a rejected drop and cost two full runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.