Commit Graph
104 Commits
Author SHA1 Message Date
rootandClaude Opus 5 7c63c884ef Replace the five variable nodes with one Variables node
A single node now lists any number of variables. Every entry is an output
that reads the current value whenever it is pulled; an entry switched to
Set is also an input and is written when the flow arrives. Exec pins only
exist while at least one entry writes, and all inputs are read before any
write, so one node can swap two variables.

Get/Set variable, Get/Set variables and Variable are gone. Old project
files are converted on load: each old node becomes a Variables node with
the same variables, edges and typed values.

A written port without a typed value now shows the zero of its kind on
the node body, matching what the inspector shows and what the run writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:56:24 +02:00
rootandClaude Opus 5 854ae2e756 Add Get/Set properties nodes and a breadcrumb for opened sub-behaviors
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>
2026-09-11 15:07:07 +02:00
rootandClaude Opus 5 e9fa592078 Add the Variable node and show the variable name in node titles
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>
2026-09-11 13:34:09 +02:00
rootandClaude Opus 5 5f8a308d62 Drop the collider and delete-node buttons from the inspector
Adding a collider now lives only in the Scene objects context menu, deleting a node on
the Delete key and the node context menu. The e2e checks that pinned the inspector button
now drive the hierarchy menu instead, including that its entry is disabled on template
content and on a placed container without raising a notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:54:07 +02:00
rootandClaude Opus 5 0dcb3b4432 Render through a desynchronized WebGL context by default
Measured click-to-paint was 72-77 ms, of which the editor itself spends
under 4 ms; the rest is the finished frame waiting in the compositor
queue. A desynchronized context bypasses it (16-18 ms in the same probe).
Pixi has no option for it, so main.ts builds the WebGL2 context itself.
The mode may tear on some drivers; ?vsync falls back to Pixi's own
context.

__gm.lowLatency() reports the attributes the browser actually granted,
and a graph-step check pins that the mode stays on.

Also adds the todo note graph-node-collapse-width.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:54:19 +02:00
rootandClaude Opus 5 13852fe4b2 Route the widget hover cursor through the theme
Button, Checkbox, the color swatch and list rows took a hard-coded
'pointer'; they now read Theme.hoverCursor, which is 'default'.

Also adds the todo note for a breadcrumb when drilling into a
sub-behavior in the graph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:16:20 +02:00
rootandClaude Opus 5 8f92a06c84 Consolidate duplicated editor code, trim comments, refresh README
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>
2026-09-11 11:00:30 +02:00
rootandClaude Opus 5 9212e61c4c Create a sprite asset from a texture's context menu
The sprite takes the whole image at its pixel size and the texture's name,
numbered like any other clash. CreateAssetCommand now accepts starting
properties; they go through the schema in execute, so an unknown key or a
bad value is refused before the project is touched, and one undo removes
the asset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:57:19 +02:00
rootandClaude Opus 5 1e2285c545 Let a graph node collapse to its title bar
A chevron in front of the title toggles it without touching the selection,
and right-click offers Collapse node / Expand node for the whole selection
as one undo step. A collapsed node draws its title bar alone; every link of
one side meets at a single point on it, and hidden links there share one
mark per side, since per-port marks would stack on the same pixels. It has
no ports to grab, so a link dropped on it connects nothing.

Like hiding a link this is drawing only: collapsed is saved on the node and
an expanded node leaves no trace in the project file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:59:19 +02:00
rootandClaude Opus 5 631cf54c72 Give every behavior variable one row shape on asset and instance
buildStructBlock only covered structs, so a placed instance drew numbers,
text, bools and lists through createFieldRow on a 64 px name column while
its struct block sat on the asset's grid below it. variableRows.ts now
builds the row for every variable kind on both sides; the edit option
decides what the owner may change (asset: rename, x, item kind; instance:
revert and push). Label and Button take a fixed width so the mark column
no longer shifts names when a list shows its item-kind picker.

The two structFields calls that became dead with 9f48257 are gone. The
e2e steps locate the Enabled toggle through __gm.inspectorField instead
of clicking a fixed offset from its label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 07:27:29 +02:00
root 9f482573e5 Show a placed object's struct the way its asset does
The instance inspector drew its own, flatter version of a struct: one row per
field, no bundle head. It now draws the same block as the asset side, because
there is only one implementation left - both call buildStructBlock. What the
instance does not get is everything that touches the shape: renaming a field,
'+ Field', and the per-field '×'. The shape belongs to the asset; a placed
object overrides values. The revert and override marks sit on the head rather
than on a field, since an override covers the whole bundle at once.

Underneath this, editing a single field of an unoverridden struct used to blank
its siblings: structFieldsOf filled the untouched fields from spec.read, which
is object.overrides[key] and therefore undefined until something is overridden.
structOf then handed back the empty shape. PreviewedValue carries 'current' now
- the resolved value the row actually shows - and the fields come from that.
Without it the old path stays, which is right for the asset side, whose prop
bag is dense.
2026-09-10 23:05:02 +02:00
root 14f542365e Build a Pack or Expand node's ports from a struct variable
The port editor gets 'From variable' next to '+ Field': pick a struct variable
of the behavior and its fields land as ports. It only adds - a field the node
already declares stays as it is, because it may carry an edge - and the entry
is disabled once there is nothing left to add. A list of structs counts too:
it declares the shape of every item, which is exactly the bundle a Pack builds
for it. A color field arrives as a number port; the graph has no color type.

AddPortsCommand is new underneath: adding five ports had to be one undo step,
and it checks every key before it appends a single one, so a collision does
not leave half the list behind. AddPortCommand is a three-line subclass of it,
which leaves its sixteen call sites untouched.
2026-09-10 21:35:30 +02:00
root 2218046738 Scene: ausgeschaltete Objekte behalten ihren Auswahlrahmen
Ein ausgeschaltetes Objekt zeichnet nichts, verschwand bisher aber auch aus
der Auswahl - im Panel angeklickt blieb der Viewport leer, es gab kein
Anzeichen, wo es liegt. Der Rahmen kommt jetzt, gedimmt auf 45 %, damit er
sich nicht wie eine fehlgeschlagene Zeichnung liest. Ziehen bleibt aus: der
Node ist weiterhin nicht interaktiv.

Dazu __gm.gizmos() als Testhaken - Gizmos waren vorher gar nicht prüfbar. Es
meldet das alpha, das wirklich in den Strich geht, kein daneben abgeleitetes
Flag.
2026-09-10 17:36:27 +02:00
root 4b47741fac Graph: optionales Grid-Snapping beim Ziehen von Nodes
Ein Toggle in der Graph-Kopfzeile lässt gezogene Nodes auf dem 24er-Raster
einrasten – derselben Größe, die den Hintergrund zeichnet. Einschalten bewegt
nichts; die Regel greift erst beim nächsten Zug. Bei Mehrfachauswahl richtet
sich jeder Node für sich aus, statt einem gemeinsamen Versatz zu folgen, der
die alten krummen Abstände mitschleppen würde.

Die Entscheidung liegt als reine Funktion in framing.ts, wo die übrige
Graph-Geometrie schon wohnt, und ist dort wie im Browser gepinnt.
2026-09-10 17:14:37 +02:00
rootandClaude Opus 5 10d030376a Finish the struct and list leftovers: inline lists, list fields, item kinds
An unconnected list input is a button into the List tab instead of a number
field; a struct field can hold a list, so ListSlot carries an optional field
name and the three owner cases only say what sits at a key; and a list
variable picks its item kind in the inspector (marker doubles as the chooser),
so a list of structs no longer has to be born from a project file.

The declaration keeps the storable shapes finite: a struct field may be a list,
but that list holds primitives only, so list->struct->primitive and
struct->list->primitive are the two deepest forms.

check 674, e2e 483, jump-run 24, Tetris 19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:36:15 +02:00
rootandClaude Opus 5 fe3c63c48c Collapse Tetris' variable clusters, and give the long runs a home
Next move held four clusters of Set variable in a row, fourteen single nodes
in all; clear-lines read three variables one after the other. They are now
one plural node each, which takes Next move 40 -> 30 nodes and clear-lines
25 -> 23. Every remaining single node was checked and really is single: ok/no
are the same variable in opposite branches, take/keep the same one across a
write, and the three key setters hang on three different events.

Two contracts carry those merges, both pinned as tests. Set variables writes
per port with writeTyped exactly as the singular node does, so a Pack{rot}
still merges instead of overwriting - without it, turning would have erased
the other three fields. And the interpreter collects every input before the
node runs, so no input may come from a variable the same node writes; a data
node is evaluated once per pulse and keeps its answer. That second rule is
invisible in the game, because OnUpdate re-reads each frame and heals it, so
it lives in a unit test rather than in the smoke.

/opt/node22 and /opt/npm are gone from the tree. build.sh ran into the empty
path once they were moved away; the other seven mentions were documentation.

e2e.sh and check.sh replace the bare npm calls. A long run does not survive
the end of its shell - four full e2e runs died mid-suite with oom_kill 0 -
so e2e.sh start hands the run to a transient game-maker-e2e.service, which
does. e2e.sh status reports the running job or the last result, and calls a
log without a closing line ABORTED rather than reporting the passes it got
to. Both scripts end on an explicit exit line, because set -o pipefail inside
a script does not protect the caller: measured, ./check.sh | tail still
reports 0. Logs carry a timestamp and land in logs/, which is ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:00:28 +02:00
rootandClaude Opus 5 f60358c951 Let a list hold structs, and read or write variables in bundles
A list item may now be a struct. The depth is bounded in the declaration
rather than by a comment: FieldKind (number|text|boolean|color) says what a
struct field holds, ListItemKind adds struct on top and says what a list
holds, so list -> struct -> primitive is the deepest storable shape and
sameValue stays finite. A struct item renders inline in the List tab, which
is why it needs no path in the slot address.

Four paths silently assumed a list item was a primitive: sameValue compared
items with ===, textOf would have printed [object Object], asListItem turned
a struct into text on Set item and Fill, and Index of used indexOf, which is
reference equality and could never have found a struct.

Tetris now runs on bundles. shapes is 112 {x, y} structs instead of 224 loose
numbers, piece and the candidate are structs, so Next move hands back a whole
candidate and each strand packs only the field it changes, since mergeStruct
leaves the rest standing. Declared behavior ports drop 49 -> 35, the master's
variables 18 -> 13, its graph 84/94 -> 69/76 nodes/edges.

Get variables and Set variables declare their ports from a list of variable
names, the way Pack and Expand already do, so one node can read or write
several at once. Renaming a variable rewires them, because there the port key
is the variable name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 14:01:56 +02:00
rootandClaude Opus 5 9d62dfea7b Pull the three open items out of the finished port note
The dynamic-port note went to done/ while its tail still listed three things nobody
built: a list of structs, a struct field of kind list, and the unconnected list input.
Measured while extracting: inlineInputDef sends list, struct AND object into its number
branch, so the last item is wider than the note claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 12:40:21 +02:00
rootandClaude Opus 5 0449218cb6 Let a variable declare struct fields
The value type was already there: StructValue, textOf, sameValue,
writeTyped/mergeStruct and the struct port type came with the first
part, and Get/Set variable carry any-ports, so a bundle already flowed
through the graph unchanged. What was missing was the declaration.

PropertyDef gains kind 'struct' with a list of fields, VARIABLE_KINDS a
fifth kind, variableDef a fifth parameter. A declared field is a
primitive - a list field would need a nested editor and therefore the
path in the slot address, the same step "a list of structs" is waiting
for. coerceValue now returns PropertyValue rather than FieldValue, the
first value type that is not a field; none of its five callers break.

In the inspector a struct variable is a block, not a tab. The list tab
exists because hundreds of rows fit in no inspector row; a struct is the
opposite case - few fields that fit under each other. It is the port
editor Pack already has, one level down and with a value column: type,
key and default value in one place. A placed object sees the same fields
as plain value rows: overriding yes, declaring no.

Adding or removing a field touches the declaration, the default, the
behavior's value and every instance override in one command, so a stored
value can never keep a shape its declaration no longer promises. The row
handlers read the bundle at the moment of the change rather than closing
over a snapshot - a field writes the whole bundle, so with a snapshot
the second edited row revives the first one's old value. defSignature
carries the fields, otherwise a new row appears only at the next
rebuild.

Fixes a fixture fault this uncovered: step 10 pushed its text object
into the scene without instanceEdits, which overrideSites walks with
Object.entries. AddStructFieldCommand is the first caller to reach it
from that state; the step was green alone and failed three checks in the
full suite.

Moves the dynamic-ports note to done - all three of its parts are built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 12:29:01 +02:00
rootandClaude Opus 5 4d07946c74 Lay out a scroll container's content when the content changes
A short list opened after a long one drew all its rows stacked on one
point. The fault was not in the list tab: rebuild() handed the row stack
to setContent() while it was still empty, so the content was sized to
max(rows, viewport) = the viewport. The rows arrived after that, and for
a short list the same formula yields the same height, so resize() saw no
change and skipped layout(). A long list changes the height and never
showed it.

relayoutContent() now runs whenever the content changed, rather than
leaning on resize()'s change detection - that detection is the wrong
instrument for "the children moved".

The list tab was the only affected caller: the panels use ListView,
which positions its rows itself, and the inspector fills its body
before setContent.

Also drops the comma line under the list. Every section that can carry a
list supplies openList, so the comma fallback in fieldFactory was
unreachable; listToText/listFromText go with it. The list-value test now
covers coerceListItem through coerceValue instead, so the boolean
spelling stays guarded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:58:40 +02:00
rootandClaude Opus 5 e52e95c0e9 Give a list its own tab instead of a comma line
A list property in the inspector was a single text field holding a comma
separated line -- unreadable past a handful of items, and it could not
carry anything but text. It becomes a button ("3 items") that opens a
sixth centre tab, where every item is a row built by the very widget the
inspector would build for that item kind.

The renderer was free: a PropertyDef of kind 'list' already carries
`items`, so item i becomes a synthetic def of that kind -- number, text,
boolean and colour rows appear without a new branch.

The way into the tab goes through the state (EditorState.listSlot,
openList(), event 'list'), like every other tab switch here; a tab may
not call showTab itself. ListSlot lives next to Selection rather than in
the tab folder, or state.ts would have to import upwards.

A row's handlers must not close over a snapshot of the list: they outlive
a commit, since the tab only rebuilds when the length changes, and the
second edited row would otherwise revive the value the first replaced.

showTab switched five `visible` lines by hand, where a sixth tab is
silently forgotten; it is a Record<CentreTab, Widget> now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:30:57 +02:00
rootandClaude Opus 5 5a440ba433 Hold back an inspector rebuild while a field in it has focus
A rebuild destroys the widget being typed in, and a typed value can change the
very structure that decides whether a rebuild is needed -- a field that says
how many ports a node has. That made such a field unusable: every keystroke
previewed, the preview emitted 'project', refreshValues() saw the signature
change and threw the row away. Dragging the value worked only because a drag
keeps receiving globalpointermove on the destroyed widget.

So refreshValues() now postpones a due rebuild while the focus sits inside the
inspector, and runs it as soon as the field is left. Values are safe in the
meantime: TextField.setValue ignores an edit in progress.

Two handles for that in the toolkit. FocusManager.isWithin(node) also replaces
the parent chain refreshLive() walked by hand, and changeHandlers lets a panel
notice that the focus has gone -- there was no signal for that before.

Nothing in the editor can trigger this today, since the Sequence setting that
did is gone. It is the ground for the List tab, where a row that adds or
deletes an item changes the structure while you type. The e2e check therefore
builds the case out of real parts: type into a field, set the object's parent
from outside -- the parent is part of the structure signature -- and confirm
the input survives and the rebuild follows on Escape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:11:05 +02:00
root 3c76106fd2 Let a Sequence grow an output as you connect the last one
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.
2026-09-10 08:32:14 +02:00
root b6a609ae24 Let a node work out its own ports
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.
2026-09-10 08:14:18 +02:00
root 2cf788eedd Let a value be a bundle of named fields
PropertyValue was number | string | boolean | list. A struct joins it, which
costs two things the typechecker found rather than I did.

An object on a data edge used to mean a live entity, so asEntity would have
taken a struct for one and asString would have read .name off it. EntityHandle
now carries a symbol brand – a symbol so no field name can impersonate it, and
it never serializes, which suits a thing that exists only while the game runs.
It is built in exactly one place.

String(value) would have written a struct as [object Object] at 39 call sites.
They go through textOf(), which is identical for primitives.

normalizeGraph dropped everything but primitives from node params, so the List
node lost its value on every save ([3,1,4] round-tripped to undefined).
Variables were spared: they have a schema, params have none.
2026-09-10 08:14:18 +02:00
rootandClaude Opus 5 3b3f374e10 Split the object inspector, and sweep out the section banners
objectSection.ts had grown to 559 lines around three separable parts: the
queries, the vector rows and the parent row. Four object commands repeated the
same capture/restore preamble – applyCommands stays out, its five carry extra
undo state whose restore order matters.

58 dashed banner comments are gone. The nine labels in nodes/index.ts stay:
they are the only structure a flat 90-entry array has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 07:14:45 +02:00
rootandClaude Opus 5 11981b40d0 Give the three asset tabs one base, and move the viewports into the editor
src/viewport/ held three classes only src/editor/ ever used, and framing.ts sat
under graph/ although the scene view wants the same bounds sum. The graph, the
texture and the container tab repeated the same header, subscription and
disposal code with two silent divergences: the graph tab said "nothing shown"
with null instead of '', and the texture tab refell back to the first asset on
a selection change but not on a project event.

These arrive together because GraphCanvas ties them: it reads framing from its
new home, hands its fold marks to folding.ts, and its '' change is what
GraphEditor must agree with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 07:14:38 +02:00
rootandClaude Opus 5 a2f2f8465c Take the cross-cutting queries out of the model's base module
findPropertyUsages walks override sites, so Project.ts imported instance.ts
while instance.ts imported Project.ts – the repo's only value-level import
cycle. The queries now sit in usages.ts and Project.ts is a base type module
again. requireScene has had no caller since holderId replaced sceneId.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 07:14:28 +02:00
rootandClaude Opus 5 b13b130da7 Share the one HTTP envelope and the one byte formatter
Both stores carried their own ~28-line request<T>, and formatBytes existed
twice – the second copy had lost the "B" branch, so a small sprite would have
read as 0 KB had its caller ever passed one under a kilobyte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 07:14:23 +02:00
rootandClaude Opus 5 787a19d525 Give the e2e steps one place to build their fixtures
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>
2026-09-10 07:14:17 +02:00
root 6ac6b69388 Run just the e2e steps you name
GM_STEPS=20, 1-12 or 6,20 takes a subset of the suite, which is seconds
instead of two minutes while one step is being written. Steps live off
what the earlier ones leave behind, so a subset that skips a prerequisite
fails on purpose. The filter had been written as a throwaway script three
times; it belongs in the runner.

The README stops calling a remote browser the way this suite is meant to
be run: with the GPU in use a local run costs ~124 s and about 0.8 of the
four cores here, so GM_REMOTE is a convenience, not a requirement.
2026-09-10 06:06:29 +02:00
root 338ff4389f Type a repeat delay as whole milliseconds
The two On key down fields read in milliseconds now, and say so in their
labels. A number field shows three decimals unless a property asks for
fewer, so both params set decimals: 0 – the field reads 300, not 300.000,
and rounds to whole milliseconds while typing or scrubbing. The clock
behind it still counts seconds; the conversion sits at the one place that
reads the properties.
2026-09-10 05:59:39 +02:00
rootandClaude Opus 5 3ab8d88ad6 Let On key down repeat while the key stays held
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>
2026-09-09 22:44:00 +02:00
rootandClaude Opus 5 4b0919a224 Let a link be hidden, and shown again by selecting either end
A link can be folded away from the canvas: instead of a curve running clear
across the graph, each end grows a short stub with a mark naming the node at
the far side. The curve comes back while either of its two nodes is selected,
so following a connection is a click rather than a search, and a link carrying
a live pulse is always drawn so the trace keeps its thread.

Marks are collected per port, not per link, because several hidden links on one
port used to stack stubs, dots and labels on identical pixels. A bundle reads
"Move +1" and its dot selects every node the port feeds, as one undo step.

The whole thing is one optional flag on the edge; the interpreter never sees it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 22:19:59 +02:00
rootandClaude Opus 5 558365d33f Rebuild Tetris as ten behaviors that pass values around
The board is a list of 200 numbers now instead of 200 characters, so a cell is
Item at and a landing is Set item instead of seven nodes of string surgery.

The bigger change is that the one 219-node graph is gone. Each step of a frame is
a behavior of its own - Spawn piece, Next move, Fits, Draw piece, Lock piece,
Clear lines, Show lines, Draw board, plus Piece cell for where one of the four
cells of a piece sits. None of them holds state: what they need arrives through
their inputs, every answer leaves through their outputs. That is what lets Fits
answer for turning, moving and falling alike, and Piece cell serve the fit test,
the drawing and the landing instead of being the same twenty nodes three times.

Tetris itself is down to 84 nodes and only says in which order the steps run and
which variable each answer lands in. The smoke test holds it to that: it fails if
a loop or a list access reappears in the master graph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:27:01 +02:00
rootandClaude Opus 5 a9c3111377 Let a variable hold a list
A variable could only be a number, a text or a yes/no, so every set of values had
to be smuggled through a string - which is where Tetris got its unreadable
"03066326048763714" colours from. A list is now a value of its own, with an item
kind (number, text, yes/no, colour) that decides how the inspector reads the one
comma-separated line it is typed as, and eight nodes to work with: List, Fill,
Count, Item at, Set item, Sub list, Join lists, Index of.

Two traps came with it. An array is typeof 'object', so every place that told a
value from an entity had to learn the difference, or a list would have been
passed around as a game object. And a value was compared by identity in three
places - with lists that marks everything as an overridden property after each
reload, so those comparisons now compare contents.

There is no list of objects: a list is written to the project file and an object
is a live handle that no reload survives. A set of objects stays a list of names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:26:31 +02:00
rootandClaude Opus 5 c6f907b4e0 Make a call node say why it does nothing, and easier to place
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>
2026-09-09 20:40:56 +02:00
root 1d8e6ebea0 Note down two follow-up wishes 2026-09-09 20:01:52 +02:00
root 6702ff8abe Let a behavior be a node in another graph
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.
2026-09-09 20:01:49 +02:00
rootandClaude Opus 5 64e034ffb9 Note down two more wishes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:10:44 +02:00
rootandClaude Opus 5 4f78c7518c Give the graph a loop, and build Tetris with it
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>
2026-09-09 19:10:41 +02:00
root 9c4148dc1b Hold a running game, and let the editor run reach into containers
Pause is a sub-state of play, not a third mode: the toolbar button and
Ctrl+Enter stop ticking the world, the clock included. A held frame still
drops the input edges, so resuming does not fire a pause worth of keys in
one go.

The editor run missed the case it is most wanted for. hasEditorRun only
looked at the stored scene objects, so a behavior inside a placed
container never started one; the world now builds from resolveObjects and
writes back through objectRef, keeping the move as an instance edit
instead of baking the instance out.

Keys and clicks go to the run while the Game tab is on screen, where no
key means anything to the editor, and are handed back on leaving it -
held keys released, so nothing keeps walking unpressed.
2026-09-09 18:16:28 +02:00
root 8f31afac37 Let a behavior run while the editor is open
A behavior object carries a "Run in editor" switch next to Enabled. When
it is on, the behavior ticks outside of Play and works on the saved model
directly - Unity semantics: what it moves is a real change, autosaved and
not undoable.

Input stays with the editor, so On key never fires while typing. The
editor run never bakes placed containers into the model, since that would
silently drop their instanceEdits.

syncToModel reports whether it wrote anything, and the dirty flag is only
raised on change - otherwise a behavior moving something every frame
would trigger ~60 saves a second.
2026-09-09 17:13:07 +02:00
root c74037b6e1 Show the camera's picture in the game tab without playing
The game tab held a "Press Play" hint where the picture belongs. It now
previews the scene through the active camera, and says "No active camera"
when none is switched on.

Camera choice and its world position moved into activeCamera.ts so both
are testable without Pixi. Entity.x/.y are parent-relative; the viewport
used them as the camera's spot, which framed a parented camera at the
wrong place.
2026-09-09 16:27:04 +02:00
rootandClaude Opus 5 ed455f1fd6 Note down three wishes for the game view
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:13:34 +02:00
rootandClaude Opus 5 37fff5dabf Play through the first camera that is switched on
The game view took whatever camera came first in the scene, ignoring the
switch, so `enabled = false` on a camera did nothing and a second camera
never got its turn. `World.activeCamera()` now decides: of the cameras
switched on – the whole parent chain counted, as for any object – the
first in hierarchy order, and none on renders nothing at all. It is asked
per frame, so a graph switching cameras hands the view over immediately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:13:30 +02:00
root 75be133374 Make the smooth-filter check unable to cover for the hard one
The pixel-art check falls over sporadically, and its follow-up hid it. That one only
asked whether some pixel across the border is blended – but in the failing case the
`nearest` reading is already blurred, so the `linear` one is too, and the check passed
while saying nothing about whether the switch had any effect at all.

It now also demands that the picture actually changed against the `nearest` reading:
the summed colour distance over the five sample points must exceed 150. Measured on the
readings recorded in the note, a working switch drifts 425 and a hung one 0 – and 30
even if every channel were off by two, so the threshold is not brittle against noise.
Exact equality was the first draft and would have gone green on a single wobbling bit.

Both checks now carry `__gm.textureFilter(file)` in their message, so a failure says by
itself whether the model never switched, the switch missed the GPU sampler, or the
pixels were measured wrong.

The cause of the sporadic failure is still unknown; four hypotheses are measured and
ruled out in the note, which is filed with that stated plainly.
2026-09-09 15:55:28 +02:00
root 88ec367ab0 Offer the pictures the server already holds when adding a texture
"+ → Texture" opened the machine's file dialog, so a picture that was uploaded once
but lost its asset – an undone import, a deleted asset – was unreachable from the
editor while still sitting on the server. Nothing collects those files, by design.

It now opens a grid of every picture the server holds, with thumbnails: pick one and
"Use image" makes a texture asset on that file, "Upload…" is the way in for bytes the
server does not have yet and lands in the same import as a drop. A tile captioned with
an asset name is one this project already wears – picking it selects that asset instead
of making a second one, since two assets on one file would fight over `filter`.

- `GET ?r=textures` now reports `width`/`height`; a texture asset holds the pixel size,
  and reading it off the GPU texture would tie the asset's correctness to load timing.
- `TextureLibrary.load(file)` loads without touching `scaleMode` – a thumbnail must not
  restage the filter of a picture the scene is drawing with.
- The grid geometry lives in `texture/imageGrid.ts` as pure functions; inside the widget
  no test could reach it.
- The part after an upload is now one function shared by the drop import and the picker,
  so "one file = one asset" is stated in a single place.

12 new E2E checks; the strongest one undoes an import, leaving the file on the server
with no asset naming it, and picks exactly that file back out of the dialog.
2026-09-09 15:55:15 +02:00
root 2f4dc843d7 Ask the driver what filter a texture actually got
Chasing the flaky pixel-art check needs to tell two things apart that
look identical in a screenshot: a scale mode the model never changed,
and one that changed but never reached the sampler. Only the driver can
answer the second, so `__gm.textureFilter(file)` returns the model's
scaleMode next to `gl.getTexParameter` on the uploaded texture.

It binds through `renderer.texture.bindSource` rather than a raw
`bindTexture`, so measuring does not corrupt Pixi's idea of what is
bound and become a source of flakiness itself. `TextureLibrary.peek`
looks a picture up without starting a load or touching its scale mode,
which `get` would do.

Sixty measurements later the filter path is cleared: model and driver
agreed every time, across repeated switches, after the full module
run-up, and through a forced context loss. The instrument stays; the
note in todo-notes/flaky-pixel-art-filter.md stays open.
2026-09-09 15:21:51 +02:00
root e9634f0f0d Paint an asset's name gold while any of its values deviates
The inspector already marked an asset value that differs from its schema
default, but the Assets panel gave no hint, so a changed asset looked
like an untouched one until you opened it.

The rule sits next to the existing objectDeviates as a pure function:
assetDeviates asks whether any marked property differs, markedAssetSchema
says which rows the inspector marks at all. buildAssetSection now builds
its rows from that same list, replacing the behavior/rest split it used
to carry, so the list cannot claim a deviation the inspector then fails
to show.

A behavior's variables are deliberately not in it. Their default is
declared on the variable itself, which means the asset value *is* that
default; counting them would paint the fixture's Mover gold while the
inspector beside it marks nothing.
2026-09-09 15:21:39 +02:00