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>
Input remembered keys under KeyboardEvent.key. Pressing a, adding
Shift and letting go sent "A" on release, which matched nothing, so
"a" stayed down until the next release without Shift.
- New rule keyName in src/core/keys.ts lowercases a single character.
So a and A are the same key, also under Caps Lock.
- Input remembers per physical key (e.code) under which name it went
down and releases exactly that name. So 1 released as ! is freed
too, which lowercasing alone does not cover.
- On key down, up and held, Key is down and Axis input compare with
the same rule. A node that already stores A reacts to a.
- Recording a key in the inspector stores Shift+A as a.
- Limit: holding Shift before pressing 1 still sends !, and a node on
1 does not react. Nothing gets stuck by it.
Tests:
- 3 new unit tests in tests/core/behavior-events.test.ts: the flow
from the issue, 1 released as !, and a node on A hearing a.
- Without lowercasing exactly the issue test and the A test fail.
- Without the code memory exactly the 1/! test fails.
- ./check.sh: 159 files, 2340 tests, CHECK EXIT 0 (before 2337).
- No e2e change, the check sum stays at 1377.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Order per instance: On start -> On enable -> On update ... -> On disable
-> On destroy. On enable fires right after On start and whenever the
behavior comes back on (itself and its parents); On disable in the frame
it goes off, the instance stays frozen. On destroy fires when the entity
leaves the world (Destroy node, child of a destroyed object) and when the
world is torn down on stop/restart, preceded by On disable if still on.
A new On start node added while playing still does not fire for a
running instance (kept as is, documented in the wiki).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A held key fired once and then nothing, so sliding a Tetris piece sideways or
walking a menu meant building a timer next to every key node. Two settings on
the node do it now: Repeat after and Repeat interval, both in milliseconds so
the usual values stay whole numbers (300, 50) instead of 0.3 and 0.05. A delay
of 0 keeps the node exactly as it was, an interval of 0 means every frame.
The browser's own auto-repeat is deliberately not used - Input.press has always
dropped it - because its delay and rate are an operating-system setting of the
player's, not a game's. The repeat is built from the measured hold time instead,
per node, so two nodes on one key keep their own clocks. The rule itself is a
pure function in keyRepeat.ts and is checked without a world.
Both rows stay off the node body while the delay is 0: two lines of "0" on every
key node in every graph would be noise, and the screenshot said so. NodeDef gains
hiddenParams for that; the inspector still shows them.
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>