Set the order in which event nodes run across behaviors (#264)
game-maker / gate (push) Successful in 3m42s
game-maker / suite (push) Failing after 11m7s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-29 09:21:31 +02:00
1 parent 0c41cb97b1
commit 5fbc6eb088
29 files changed
+704 -194

No files matched your search

+19 -18
View File
@@ -108,32 +108,33 @@ export class GraphInterpreter {
let fired = 0;
for (const node of this.eventNodes(type)) {
if (filter && !filter(node)) continue;
this.frame = emptyFrame(this.graph);
this.callStack = [];
this.callSites = [];
this.steps = 0;
this.frame.outputCache.set(node.id, { ...outputs });
this.trace?.onNode(node.id);
this.record(node.id, outputs);
this.follow(node, port, ctx, 0);
this.pulse(node, ctx, outputs, port);
fired++;
}
return fired;
}
/** Runs the flow of one event node, whichever it is; `fire` is this for every node of a type. */
pulse(node: GraphNode, ctx: NodeContext, outputs: Record<string, Value> = {}, port = 'exec'): void {
this.frame = emptyFrame(this.graph);
this.callStack = [];
this.callSites = [];
this.steps = 0;
this.frame.outputCache.set(node.id, { ...outputs });
this.trace?.onNode(node.id);
this.record(node.id, outputs);
this.follow(node, port, ctx, 0);
}
/**
* `On state entered` / `On state exited` for one move: only the nodes watching that machine, and
* out of the one output the state stands for. A node whose machine input carries a running value
* rather than a pick has no state outputs at all, so it is never among them.
* The `On state entered` / `On state exited` nodes watching one machine. A node whose machine
* input carries a running value rather than a pick has no state outputs at all, so it is never
* among them.
*/
fireStateEvent(type: string, ctx: NodeContext, machineId: string, stateId: string): number {
if (!machineId || !stateId) return 0;
return this.fire(
type,
ctx,
{},
stateEventNodes(type: string, machineId: string): GraphNode[] {
if (!machineId) return [];
return this.eventNodes(type).filter(
(node) => machineIdOf({ node, graph: this.graph, lookup: this.lookup }) === machineId,
stateId,
);
}
+55
View File
@@ -0,0 +1,55 @@
import type { PropertyBag } from '../model/Properties';
import type { PropertyDef } from '../properties/PropertySchema';
import type { GraphNode } from './Graph';
import { asNumber } from './values';
/*
* When event nodes run within a frame (#264). The frame goes event kind by event kind – every key
* event of every behavior, then every timer, …, every On update last – and within one kind by the
* nodes' `order`: lower first. Equal orders keep the hierarchy, then the order the nodes were made.
*/
/** The setting every event node carries; a node saved without one is `0`. */
export const EVENT_ORDER = 'order';
export const orderParam: PropertyDef = {
key: EVENT_ORDER,
label: 'Order',
kind: 'number',
default: 0,
step: 1,
decimals: 0,
};
export function eventOrder(node: GraphNode): number {
const order = Math.round(asNumber(node.params[EVENT_ORDER] ?? 0));
return Number.isFinite(order) ? order : 0;
}
/** The order row says nothing at `0`, so the node body leaves it off like any setting at rest. */
export function hiddenOrder(params: PropertyBag): string[] {
return Math.round(asNumber(params[EVENT_ORDER] ?? 0)) === 0 ? [EVENT_ORDER] : [];
}
/**
* The pulses of one event kind in the order they run. They arrive in hierarchy order, and within one
* object in node order, so a stable sort by `order` is all the rule there is.
*/
export function inEventOrder<T extends { node: GraphNode }>(pulses: readonly T[]): T[] {
return pulses
.map((pulse) => ({ pulse, order: eventOrder(pulse.node) }))
.sort((a, b) => a.order - b.order)
.map(({ pulse }) => pulse);
}
/**
* Where each object stands top to bottom: by the hierarchy's order for those it lists, the rest after
* them in the order they come.
*/
export function hierarchyRanks<T extends { objectId: string }>(
items: readonly T[],
treeIds: readonly string[],
): Map<T, number> {
const place = new Map(treeIds.map((id, index) => [id, index]));
return new Map(items.map((item, index) => [item, place.get(item.objectId) ?? treeIds.length + index]));
}
+1
View File
@@ -2,6 +2,7 @@
import './nodes';
export * from './dropTarget';
export * from './eventOrder';
export * from './Graph';
export * from './lookup';
export * from './findBlock';
@@ -1,5 +1,6 @@
import { textOf } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, obj } from '../shared';
export const OnAnimationFinished: NodeDef = {
@@ -10,6 +11,12 @@ export const OnAnimationFinished: NodeDef = {
'Runs once when an animation played once on the owner, or on an object below it, reaches its end. Leave Animation at None to hear every animation.',
inputs: [],
outputs: [exec(), obj('object')],
params: [{ key: 'animation', label: 'Animation', kind: 'asset', default: '', assetTypes: ['animation'] }],
hiddenParams: (params) => (textOf(params.animation ?? '') ? [] : ['animation']),
params: [
{ key: 'animation', label: 'Animation', kind: 'asset', default: '', assetTypes: ['animation'] },
orderParam,
],
hiddenParams: (params) => [
...(textOf(params.animation ?? '') ? [] : ['animation']),
...hiddenOrder(params),
],
};
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, num } from '../shared';
export const OnClick: NodeDef = {
@@ -10,5 +11,6 @@ export const OnClick: NodeDef = {
'Without an owner it runs on a click anywhere in the game view.',
inputs: [],
outputs: [exec(), num('x', 'world x'), num('y', 'world y')],
params: [],
params: [orderParam],
hiddenParams: hiddenOrder,
};
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { collisionOutputs, contactOutputs } from '../shared';
export const OnCollisionEnter: NodeDef = {
@@ -11,5 +12,6 @@ export const OnCollisionEnter: NodeDef = {
'point x/y where (all 0 for a trigger).',
inputs: [],
outputs: [...collisionOutputs(), ...contactOutputs()],
params: [],
params: [orderParam],
hiddenParams: hiddenOrder,
};
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { collisionOutputs } from '../shared';
export const OnCollisionExit: NodeDef = {
@@ -8,5 +9,6 @@ export const OnCollisionExit: NodeDef = {
description: 'Runs once when a collider of the owner stops overlapping another collider.',
inputs: [],
outputs: collisionOutputs(),
params: [],
params: [orderParam],
hiddenParams: hiddenOrder,
};
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { collisionOutputs, contactOutputs } from '../shared';
export const OnCollisionStay: NodeDef = {
@@ -20,5 +21,7 @@ export const OnCollisionStay: NodeDef = {
min: 0,
step: 0.1,
},
orderParam,
],
hiddenParams: hiddenOrder,
};
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec } from '../shared';
export const OnDestroy: NodeDef = {
@@ -8,5 +9,6 @@ export const OnDestroy: NodeDef = {
description: 'Runs once when the object is destroyed – also when the game stops or restarts.',
inputs: [],
outputs: [exec()],
params: [],
params: [orderParam],
hiddenParams: hiddenOrder,
};
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec } from '../shared';
export const OnDisable: NodeDef = {
@@ -8,5 +9,6 @@ export const OnDisable: NodeDef = {
description: 'Runs when the behavior is switched off (itself or a parent), and before On destroy.',
inputs: [],
outputs: [exec()],
params: [],
params: [orderParam],
hiddenParams: hiddenOrder,
};
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec } from '../shared';
export const OnEnable: NodeDef = {
@@ -8,5 +9,6 @@ export const OnEnable: NodeDef = {
description: 'Runs right after On start, and again each time the behavior is switched back on.',
inputs: [],
outputs: [exec()],
params: [],
params: [orderParam],
hiddenParams: hiddenOrder,
};
+6 -1
View File
@@ -1,5 +1,6 @@
import type { NodeDef } from '../../NodeRegistry';
import { asNumber } from '../../values';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, keyParam } from '../shared';
export const OnKeyDown: NodeDef = {
@@ -31,7 +32,11 @@ export const OnKeyDown: NodeDef = {
step: 10,
decimals: 0,
},
orderParam,
],
// Both rows go together: with no delay there is no repeat, so the interval is not in play either.
hiddenParams: (params) => (asNumber(params.repeatAfter ?? 0) > 0 ? [] : ['repeatAfter', 'repeatInterval']),
hiddenParams: (params) => [
...(asNumber(params.repeatAfter ?? 0) > 0 ? [] : ['repeatAfter', 'repeatInterval']),
...hiddenOrder(params),
],
};
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, keyParam, num } from '../shared';
export const OnKeyHeld: NodeDef = {
@@ -8,5 +9,6 @@ export const OnKeyHeld: NodeDef = {
description: 'Runs every frame while the key is held.',
inputs: [],
outputs: [exec(), num('dt')],
params: [keyParam],
params: [keyParam, orderParam],
hiddenParams: hiddenOrder,
};
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, keyParam } from '../shared';
export const OnKeyUp: NodeDef = {
@@ -8,5 +9,6 @@ export const OnKeyUp: NodeDef = {
description: 'Runs once when the key is released.',
inputs: [],
outputs: [exec()],
params: [keyParam],
params: [keyParam, orderParam],
hiddenParams: hiddenOrder,
};
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, num } from '../shared';
export const OnMouseWheel: NodeDef = {
@@ -11,5 +12,6 @@ export const OnMouseWheel: NodeDef = {
'positive downwards; a trackpad gives fractions.',
inputs: [],
outputs: [exec(), num('delta', 'notches, positive downwards'), num('x', 'world x'), num('y', 'world y')],
params: [],
params: [orderParam],
hiddenParams: hiddenOrder,
};
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec } from '../shared';
export const OnStart: NodeDef = {
@@ -8,5 +9,6 @@ export const OnStart: NodeDef = {
description: 'Runs once when the game starts.',
inputs: [],
outputs: [exec()],
params: [],
params: [orderParam],
hiddenParams: hiddenOrder,
};
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { machineOf, machinePort, statePorts } from '../../stateMachine';
/**
@@ -16,6 +17,7 @@ export const OnStateEntered: NodeDef = {
'count; a transition drawn from a state back into itself does.',
inputs: [machinePort()],
outputs: [],
params: [],
params: [orderParam],
hiddenParams: hiddenOrder,
derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }),
};
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { machineOf, machinePort, statePorts } from '../../stateMachine';
/** The other half of the move: the pulse leaves through the state that was left, before `entered`. */
@@ -11,6 +12,7 @@ export const OnStateExited: NodeDef = {
'state at all has nothing to leave, so nothing runs.',
inputs: [machinePort()],
outputs: [],
params: [],
params: [orderParam],
hiddenParams: hiddenOrder,
derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }),
};
+6 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, num } from '../shared';
export const OnTimer: NodeDef = {
@@ -9,5 +10,9 @@ export const OnTimer: NodeDef = {
'Runs every N seconds. It can only run on a frame, so late says how far this frame is past the due time.',
inputs: [],
outputs: [exec(), num('late')],
params: [{ key: 'interval', label: 'Interval (s)', kind: 'number', default: 1, min: 0.01, step: 0.1 }],
params: [
{ key: 'interval', label: 'Interval (s)', kind: 'number', default: 1, min: 0.01, step: 0.1 },
orderParam,
],
hiddenParams: hiddenOrder,
};
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, num } from '../shared';
export const OnUpdate: NodeDef = {
@@ -8,5 +9,6 @@ export const OnUpdate: NodeDef = {
description: 'Runs every frame.',
inputs: [],
outputs: [exec(), num('dt')],
params: [],
params: [orderParam],
hiddenParams: hiddenOrder,
};