Every push runs the gate, the editor suite and the three games on the Gitea runner, so a local full run only costs four minutes of waiting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Game Maker
A browser-based tool "one step before a game engine": create assets (scenes, cameras, sprites), place them into scenes, tweak their properties in an inspector and press Play – all in one program, all drawn inside a single WebGL canvas.
Live: https://nockal.com/game-maker/ · Widget playground: https://nockal.com/game-maker/?playground
Concepts
| Term | Meaning |
|---|---|
| Asset | Named thing with a type (scene, camera, sprite, text, behavior, collider, container, texture, animation, statemachine) and typed properties. Assets have no position. |
| Texture | An uploaded image. Scenes, textures, animations and state machines are the four types that cannot be placed: a texture has no position, it is worn by a sprite through the ordinary property texture. See Textures below. |
| State machine | A named set of states, edited as nodes in its own tab. A behavior puts it into a state with Set state and branches on it with State branch; each behavior instance keeps its own current state. See State machines below. |
| Text | An asset that draws a line of text (text, fontSize, color, align) – titles, HUD readouts, button labels. Its content is an ordinary property, so a Properties node of type Text with text switched to Set changes it while playing. |
| Enabled | Every placeable asset has the switch enabled (default on). A disabled object is not drawn, does not collide and its behavior does not run – and neither does anything parented below it. Switching one instance off is a normal override, which makes a container a screen: switch it on to show it, off to hide it. From a graph, Properties with enabled switched to Set does it, and Is enabled asks (including the parents). |
| Scene object | An asset placed into a scene: a link to the asset + a position (x, y, z) + sparse overrides of the asset's properties + an optional parent (another object of the scene). Scenes cannot be placed. |
| Container | A reusable group ("prefab"): the asset holds a template of objects (asset link, local position, overrides, parent links) centred on its origin. Placing a container stores the container object and nothing else – its content is resolved from the template on every read, so a template edit reaches every instance at once. Create container turns the current selection into such an asset: the selected objects move into the template and the scene keeps one placed container at their centre (world positions unchanged). The template is edited in the Container tab (see Editing a container) or from an instance through Apply to container. |
| Selection | One or more scene objects. Click selects, Ctrl/Shift-click adds/removes, dragging on empty scene space draws a rubber band (objects whose position lies inside are selected). Dragging a selected object moves the whole selection; Delete removes it; the inspector offers Create container and Remove for several objects. |
| Behavior | Logic as an asset: a node graph plus the variables it declares. Placed like anything else (a diamond symbol in the scene); its parent is the owner the graph acts on. |
| Run in editor | A switch only behaviors have (default off). With it on the behavior keeps running while the project is not playing – and unlike Play it then edits the saved model: what it moves or writes is a real change, autosaved and not on the undo stack. See Running without Play. |
| Collider | A shape (box or circle) for collision detection, placed as a child of the object it belongs to (its body). Drawn as a green outline in edit mode only. |
| Override | A per-object value that replaces the asset's value for one property. Only changed keys are stored; the inspector paints their label gold and offers a reset (↺) and an apply (↑) that writes the value into the asset instead. Both appear only where the value really differs from its base – an override carrying the asset's own value is none. |
| Default | Every property has a schema default. Asset values that differ from it are marked gold in the inspector and can be reset (↺) the same way an override can. The asset's name in the Assets panel carries the same gold while any of them does (assetDeviates, src/core/properties/deviation.ts – the inspector marks the rows of that same schema, so the list cannot claim a deviation the inspector then fails to show). A behavior's variables are not in it: their default is declared on the variable itself, so the asset value cannot differ from it. |
| Instance | An object resolved from a placed container's template. Its values can be edited – position, name, property overrides – and only what actually deviates is stored, as a sparse instanceEdits entry on the container object keyed by the template path. Everything else keeps following the template. Its structure cannot: it can not be removed, re-parented or given a collider, and nothing of the scene can be parented into it. ↺ ▾ resets to the nearest base, which inside a container is the template as the containers in between leave it; ↑ ▾ offers Apply to container (write the value into the template, where every other instance still at the old value picks it up) and/or Apply to asset (which drops the template's override as well). With one base left the menu collapses to a plain ↺ / ↑, with none the buttons disappear. Gold marks the same thing the buttons offer. |
| Unpack | Writes a placed container's content into the scene as ordinary objects (hierarchy context menu, or the inspector's Content section). The ids survive it, so the selection does; from then on the structure is editable and the objects no longer follow the template. |
| Edit mode | The Scene tab draws the project model; panels, grid, gizmos and drag-to-move are active. |
| Play mode | The editor switches to a runtime copy of the project (same ids). A World is instantiated from its active scene (one entity per scene object, resolved properties as mutable state) and shown in the Game tab. Systems tick it and mirror their changes into the copy; every panel stays open and edits the copy, which the world picks up immediately. Stop discards world and copy – the saved model is never touched. |
The camera defines what is rendered. A camera asset has a view size (width × height,
default 640×360) and a zoom; the frame it covers in world units is size / zoom. The Scene
tab draws that frame as an outline. The Game tab renders only that frame – fitted into the
view with black letterboxing – and objects outside it are culled. Of several cameras the first
one in hierarchy order that is switched on (enabled, its parents included) is the active
one, so switching it off hands the view to the next; with none on the tab stays empty and says
No active camera. Without playing the tab previews the scene through that same camera, so it
shows what Play would show, minus the editor symbols. Play has no built-in behaviour (no input,
no movement): everything that happens is defined by behavior graphs.
Toolbar and panels. The toolbar holds the Project menu (new project, switch project,
Delete this project…), the project name (type to rename), the Scene picker for the active
scene, Undo/Redo, Play/Pause, and on the right the notice line, the save status and the frame
rate. The Assets panel's + creates an asset; right-clicking a row offers Set as active
scene (double-click or Enter does the same), Create sprite from texture, Create animation
from texture and Delete…. In
Scene objects it offers Select asset, Add collider, Create container, Unpack
container, Unparent, Expand all / Collapse all and Remove.
The console. Under whichever centre tab is showing sits the Console panel, always
visible and resized by the divider above it – dragged all the way shut it keeps its own header
to be dragged open by. It mirrors everything that reaches the browser console: a Log node's
message, Pixi's warnings, an uncaught error, and the editor's own notices (a failure in red, a
hint in plain text; the Playing · … status line is a mode rather than an event and stays out).
Each line carries the clock time of its call (HH:MM:SS, muted, in its own column, so the
message stays exactly what was logged). The newest 500 lines are kept, Clear empties them,
and new lines follow the tail only while the tail is what you are looking at.
Editing while playing. Play opens the Game tab but leaves the editor fully usable: the
Scene tab shows the live positions (symbols, gizmos and dragging included), the hierarchy and
inspector show and edit the running objects, the graph can be changed, undo/redo work on the
play session's own history. All of it happens in the runtime copy: values a behavior changes
appear as overrides (gold, ↺ resets to the asset value), objects a graph destroys vanish from
the lists, objects a graph spawns (Spawn node) appear in them – selectable, editable, still
only in the runtime copy –, and Esc/Stop brings back exactly what was saved. Autosave pauses
while playing.
Pausing. The toolbar's Pause button (or Ctrl+Enter) holds the running world: it is no
longer ticked, so behaviors, timers and collisions stand still and the world clock stops with
them. It is a sub-state of play mode, not a third mode – the runtime copy, the panels and the
keyboard behave exactly as while playing, and everything the editor can do during play it can
do during a hold. Keys and clicks that arrive while it is held are dropped rather than
queued, so resuming does not fire a frame's worth of input at once. Stop clears the hold, so a
paused game can never outlive its run.
Running without Play. A behavior switched to Run in editor goes on running while the
project is not playing (src/editor/EditorRun.ts, ticked by the Editor next to the play session).
The switch is an ordinary property, so it can be set on the behavior asset and overridden per
object. What it runs is a World over the saved model, and that is the whole difference to
Play: a position or variable it changes is a real edit – it is autosaved, the panels show it, and
undo does not take it back. Which is why the switch is off by default and why it belongs on
behaviors that arrange or build content rather than on ones that simulate.
Input goes to the tab you are looking at. While the Scene tab is on screen the keyboard and
the mouse are the editor's – typing a name or walking the hierarchy with the arrow keys must not
steer a game at the same time. In the Game tab, where no key means anything to the editor, they
reach the run instead: On key …, Key is down and On click work there, and the tab says so in
its top-left corner. Leaving the tab (or focusing a field in it) hands the keyboard back and lets
every held key go, so nothing keeps walking with nobody pressing anything. What the run does not
get:
- structural changes to the content of a placed container – such content is resolved from the
template, not stored (see Containers), and the editor run must not bake it out, since that is
itself an edit. Its behaviors do run, and what they move or set is kept as an
instanceEditsentry on the instance; but aDestroyof resolved content is dropped from the running world without being saved, and comes back the next time the world is rebuilt. - a second life – only the behaviors of the loaded scene run, and only in edit mode; Play
stops the editor run, since that world runs every behavior anyway. An exception in a graph, or a
Stop gamenode, ends the run and puts the reason in the notice line instead of repeating it every frame.
Behaviors (node graphs)
A behavior asset holds a Blueprint-style graph edited in the Behavior tab of the centre
area (the Scene tab shows the editable scene, the Game tab the running game). Ports come in two kinds: exec (white
arrows, control flow) and data (coloured circles; number, boolean, string,
object, list, struct, any). Event nodes start a pulse; the interpreter follows exec edges and pulls
data inputs lazily from connected outputs. Unconnected data inputs use the inline value
shown on the node and editable in the inspector.
- Variables are the behavior's own property schema (
BehaviorAsset.variables): they appear in the inspector, can be overridden per placed instance, and each running instance gets its own mutable copy. One node reads and writes them,Variables: its settings are a list of variables, and each one is an output that can be read at any time – no flow has to reach it – and is read at the moment it is asked, so a read after a write sees the new value. A variable switched to Set in the inspector is an input too, written when the flow arrives; the node has its flow pins only while at least one is. Unconnected and with nothing typed, it writes the zero of its kind (0, false, empty), and the node shows that value beside the name. All of them are written together, their inputs read first, so one node swaps two variables.count = count + 1is oneVariablesnode feeding its own output through anAddback into its input. The ports are picked from the behavior's variables rather than named, they take their type from the declaration, and renaming a variable carries them (and their edges) along.Propertiesis the same node for the object it is handed (fixed inputobject). Its Type setting, above the list, decides what+ Propertyoffers: Sprite, Text, Collider, Camera and Container offer their fields, Behavior the variables of the behavior picked below it (renaming such a variable carries the port along, in any graph), and Any offers the kinds (Number, Text, …) and leaves the name to be typed. A picked property keeps its field and default – an unconnectedcoloris a colour field starting white, shown on the node as#ffffff, and a camera'swidthis its whole-pixel view width. Switching the type keeps the ports; one the new type lacks is marked. The node shows its type as a setting row (Type: Sprite, plus Behavior: Game under that type). Textures and scenes are no type here: they are not objects in a running game. A variable is a number, a text, a yes/no, a colour, a texture, a state machine, a list or a struct; a list declares nothing about its items – each one is a number, a text, a yes/no, a colour, a texture, a state machine or a struct and says so itself, so one list may hold several kinds at once. A list is compared by its items, never by identity – otherwise every reload would look like an override. There is no list of objects: a list is stored in the project file and an object is a live handle that no reload survives. A set of objects is a list of their names, walked withRepeatandObject by name. A list property is shown as a button saying how many items it holds; clicking it opens the List tab, which gives each item a row with the same widget the inspector would use for that kind – a struct item is its fields as rows under each other. The inspector stays visible beside the tab, so what the list belongs to is still on screen. Which list the tab shows is aListSlot(asset property, object override or node setting, plus thefieldof a struct that holds one) on the editor state, because no tab may reach into theEditorto switch itself on. An unconnectedlistinput of a node is the same button and the same tab: its inline value lives in the very same params bag a setting does, and the slot stops resolving the moment an edge feeds the port. What an item holds is its own and is chosen there: the kind mark in front of a row (#,Aa,✓,◧,▦,⊞) is the picker – a click opens the menu, with no chevron, since the mark has to stay as narrow as its column – and+ Item ▾asks for the kind of the one it appends. Because the kind lives in the value, every such change is an ordinary value edit: one command, one undo step, and no neighbouring item touched. A struct item carries its own shape the same way: its fields are renamed, retyped, added (+ Field ▾) and removed on its own rows, which grows that one item and not the list. Three kinds need saying, because JSON cannot tell them from the value beside them: a colour is a number, and a texture or a state machine is an id, which is a text. Such a tagged kind wears its name as the one key of a small object –{"$color": 16711680},{"$texture": "walk-up"}(the picker a sprite'stextureproperty has – a whole picture, one frame of a sheet, or an animation),{"$statemachine": "doors"}. A field name cannot start with$(isValidFieldName), so a tag can never collide with a struct item of one field. Either reads as what it wraps on a data edge (Item at), the graph having neither colours nor pictures of its own, andSet itemleaves the slot the kind it was. A texture is a kind wherever a kind is picked – a variable, a struct field, a list item, an Any port ofProperties, a field ofPack– and theTexturenode hands one into the graph, asNumberandTextdo a number and a text; a state machine is the same kind of kind, with theState machinenode. Every kind is one row of one table (core/properties/kinds.ts): its word and mark, what it travels as on an edge, its empty value, how a value is read as it, whether it is tagged and where it may be picked. The kind lists, the+ …menus, the tags, the port fields and the literal nodes all follow from the row, so a new kind is that row plus, if it names something in the project, its picker. Declared (a variable, a struct field of one, a port) it needs no tag: the declaration says what it is, and the value is the id – the way a colour field is a number. A declared port of kind colour or texture carries the field that edits it and travels as a number or a text, since the graph has neither; a port declared as the number a colour travels as still names the property (isPortOf). A struct variable is the other way round: few fields, so they fit under each other right in the inspector. Its head row renames it and adds fields (+ Field), each field row below carries its own name, its value and a remove – the port editorPackalready has, one level down and with a value column – and a field may itself be a list, which is the one count button that opens the tab one level deeper. Adding, renaming or removing a field reaches the declaration, the behavior's own value and every instance override in one command, so no stored value keeps a shape its declaration no longer promises. A placed instance draws every variable, whatever its kind, with the very same rows (buildVariable), minus the renaming, the kind chooser and the+ Field/×buttons: it may override the values, not declare them. A struct's ↺ / ↑ sit on its head row, because an override covers the whole bundle rather than a single field. All of it sits on one column grid with the rest of the inspector: mark (28 px), gap and name (90 px) are exactly as wide as a plain row's label (124 px), a nested field's name gives up what its indent takes, and names are cut with…rather than pushing their value out of line. - Owner = parent: a placed behavior is usually parented to the object it controls
(inspector → Parent, or drag it onto that object in the hierarchy; dashed line in the
viewport). The
Ownernode returns it;Selfreturns the behavior itself;Parentwalks up from any object. - Auto-repeat on
On key down: two settings on the node, Repeat after (ms) and Repeat interval (ms) – milliseconds, so both stay whole numbers. A delay of 0 leaves the node as it was – one fire per press; with a delay set, a key that stays held fires again after it and then every interval (0 = every frame). That is the sideways slide of a Tetris piece, or a held arrow walking a menu. The browser's own key repeat is deliberately not used: its delay and rate are an operating-system setting, not a game's. Both rows stay off the node body while the delay is 0, so a plain key node keeps its single line. - Node set (
src/core/graph/nodes/, one file per node): eventsOn start,On update,On key down/up/held,On timer,On collision enter/stay/exit,On click(the mouse pressed on the owner – anywhere when it has none),On animation finished,Start· flowBranch,Sequence,Repeat,Run behavior,End· dataSelf,Owner,Parent,Object by name,Name of,Is asset(is this object an instance of asset X – how you tell what you collided with),Variables,Properties,Get position(local and world),Is enabled, literalsNumber/Text/Boolean/Texture,Add/Subtract/Multiply/Divide,Min/Max/Abs/Sqrt/Clamp,Floor/Ceil/Round,Modulo,Random,Time,To text,To number,Join text,Substring,Find text,Equals, listsList(a constant),Fill(one value repeated – how an empty board is made),Count,Item at,Set item(a new list, the one that came in is left alone),Sub list,Join lists,Index of, structsPack/Expand,Length,Distance,Normalize,Axis input(four keys → a normalized direction, so diagonals are not faster),Compare,And/Or/Not,Key is down,Mouse position,Mouse button,Is hovered,Is touching· actionsMove(dx, dy, scale, dt – connect an event's dt for per-second movement),Set position,Spawn(asset setting + x/y/z, optional parent → outputs the new object),Destroy,Log,Stop game,Restart game. A node type is one file exporting aNodeDef(ports, settings,run), listed innodes/index.ts; palette, ports, validation and inspector fields are generated from it. A port may declare adefaultfor its inline value (e.g.Move.scale = 1), shown on the node and in the inspector. - Ports that are not fixed by the type: a
NodeDefmay saydeclaresPorts(the node carries its own list, edited in the inspector) orderivePorts(the node works them out from its settings and from the graph around it).resolveDef(node, graph, lookup)is what applies both, so the canvas and the inspector see a definition that fits this node; a node whose ports are derived reads them back off that definition, which is whyrunis handed it.Sequencekeeps one free output below the last connected one – connecting the spare grows it, unplugging the bottom edge shrinks it again (keysa,b,c, … up to 26).Run behaviortakes its ports from the behavior it names, and a behavior'sStart/Endnode declares them. Pointing a call at a different behavior takes the edges of the ports that go away and gives them back on undo: an edge on a port that no longer exists draws as nothing but survives every save, and would come back to life once the port returns. - Structs: several named values travelling as one, so a bundle needs one edge instead of one
per part.
Packdeclares its fields as inputs,Expandreads them back out as outputs, a behavior port may be declaredstruct– which is how a bundle crosses a call – and a variable may be one, which is how a bundle is stored. APack/Expandcan take its fields from a variable that already has the shape (From variable: a struct variable, or the first struct item a list holds – a list declares nothing, so its value is the only source) instead of having them typed again; that adds the fields it does not have yet as one undo step and leaves the ones it has alone. Writing a struct variable keeps the fields it declares: what arrives fills the ones it names, the rest stay put (mergeStruct), so one node can set part of a bundle without erasing it – "the same piece, one row lower" is aPackof that one field. Depth is bounded by the value, not by convention: a list item is a primitive, a tagged colour, texture or state machine, or a struct of flat ones (listItems.ts), a struct field says what it holds (StructFieldKind, primitives pluslist), and the list a field holds takes flat items only. Forbidding just that one combination is what keeps the shapes finite:list → struct → primitiveandstruct → list → primitiveare the two deepest a stored value goes – exactly whatasStoredValuelets through an untyped slot – so the deep compare and the editor stay finite. The List tab offers only the kinds its slot can hold (ListAccess.kinds): on a list of its own all six, on a list a struct field holds the five flat ones. Offering the struct there would add an item the next write drops – which reads as the menu entry doing nothing. A live entity is an object on a data edge too, so it carries aSymbolbrand (ENTITY_HANDLE) that no struct field name can impersonate; nothing may hand a property value toString()(textOfinstead), or a struct would read as[object Object]. A struct port has no inline value (its parts come from aPack), and compatibility is per kind, not per shape – any struct fits any struct port. - Loops:
Repeatruns itsbodyoutput a number of times, countingindexup from 0, and continues atdone. Data nodes are cached for the length of one event pulse, but a loop round drops that cache, so an expression built onindexreally changes with it. That is what makes a graph able to work through a list: the Tetris example draws its 200 board cells that way,Object by nametaking the name the round put together ("c" + index). Two guards, not one: a pulse may take 100 000 steps (a loop legitimately runs long), but exec edges may only nest 1000 deep – that is what catches a flow feeding back into itself before the call stack does. - A behavior as a node (nesting): a behavior that has a Start and an End node can be
run as a step of another behavior's graph. Every input declared on the start node becomes an
input port of the calling node, every output declared on the end node one of its outputs; the
node is drawn under the behavior's own name, and the palette lists it there too, so a call is
one pick. Declaring a port keeps both sides in step – renaming carries the typed-in value along,
removing takes the edges hanging on it in every graph that calls the behavior. A call is a
call, not an inlining: the nested graph runs in its own pulse cache with a fresh copy of its
variable defaults, so values travel in and out through the ports and nothing else. That makes a
call repeatable and keeps a nested behavior free of hidden state – for state, use a behavior
object in the scene (see Cross-object state below). A behavior that would run itself raises an
error instead of looping; there is one start and one end node per behavior.
Three ways in and out of a call: drag a behavior out of the asset panel onto the graph and it
lands as a call where you let go; double-click a call node to open the graph it runs. The
way down stays above the graph as a breadcrumb (
Game › Move › Other): click a crumb to go back there; the first one returns to where you started and the bar goes away. Opening a behavior any other way – the asset panel, the scene, the picker – ends the trail. And a call that cannot do anything says so on the node – no behavior picked, the behavior deleted, or no start node in it. The last one is the one worth naming: without a start node there is no way in, so the call is skipped and the flow simply carries on at itsexecoutput. A missing end node is not a defect – a call is always synchronous, "done" is the nested flow running out, and the end node only decides what comes back. - Cross-object state: a behavior's variables are the properties of its object, so another
graph reads and writes them with
Object by name→Propertiesof type Behavior. That is how the example game keeps one coin counter for the whole run: picking a coin up is one node,coinsfed throughAddback into itself. - Mouse: the Game tab feeds the pointer to the world in world units.
On clickfires for the behavior whose owner was hit – colliders decide when there are any, otherwise the sprites and texts of the owner and everything below it, so a button made of a box plus a label works as one. A switched-off screen cannot swallow clicks. A behavior without an owner has no shape that could be hit, so it takes every click inside the camera frame instead (theworld x/world youtputs say where) – that is the "click anywhere" listener. - Idiomatic movement (the demo's "Mover", five nodes):
On update → Move(Owner, dx = Axis input.x, dy = Axis input.y, scale = speed, dt = On update.dt),speedbeing aVariablesnode. - Idiomatic platformer (the example game's "Player"):
On updateaddsgravity · dtto avyvariable and moves the owner by(axis.x · speed, vy)with Collide with solids on;blocked ywhile falling means standing, sovygoes back to 0 andonGroundbecomes true;On key down (Space)setsvy = −jumpSpeedwhenonGround. - Editing: right-click the canvas for the searchable palette, drag port → port to connect
(types are checked; a data input holds one link and is replaced), drag nodes, right-click a
node or port to delete/disconnect,
Deleteremoves the selection, wheel zooms. Everything is undoable. - Mouse and selection work as in the scene view. The middle button pans from anywhere,
a node under the pointer included (shift-drag on empty canvas does too); the left button never
pans. Dragging on empty canvas pulls a rubber band instead, which selects every node it
touches – nodes are large rectangles, so touching counts, unlike the scene view's test of an
object's position. Ctrl/Shift-click adds or removes single nodes, dragging any node of a
selection moves all of them, and
Deleteremoves all of them – one undo step either way. - Dragging an edge tells you where it would land before you let go: the port it would connect
to wears a white ring and the line snaps to it. Dropping on a node's body rather than a port
takes that node's first free compatible port – or, when none is free, the last compatible one,
whose link is then replaced. A drop into empty space opens the palette filtered to the node
types this edge could reach at all, and the picked node arrives already wired; node and link go
in as one undo step (
core/graph/dropTarget.tsdecides all of this, the canvas only draws it). The dangling edge stays on screen while that palette is up, so the connection being made is still visible while its node is chosen. - A link can be hidden, so a connection across the whole graph does not have to be drawn as a
line across the whole graph. Right-click a port → Hide link; the curve is replaced by a short
stub at each end, ending in a dot captioned with the node at the other end. The connection is
untouched – hiding is drawing only, and
foldedon the edge is the entire change. The curve comes back, dimmed, while either of its two nodes is selected, and always while a pulse is travelling it, so the live trace never loses its thread. Clicking a dot selects the node at the far end, which both answers "where does this go?" and lends the curve back; right-clicking it offers Show link and Disconnect (editor/graph/folding.tsholds the visibility rule, the canvas only draws it). A port's hidden links share one mark – drawn per link, several of them would stack stub, dot and caption on the very same pixels. The mark then grows a little, is captionedAdd +2, and one click selects every far end at once, which is also what lends all of its curves back; the menu counts what it acts on (Show 3 links), and does it as one undo step. - A node can be collapsed to its title bar: the chevron in front of the title (it leaves the
selection alone, like the hierarchy's), or right-click → Collapse node / Expand node, which
acts on the whole selection as one undo step. Every link of one side then meets at a single
point on the title bar, and its hidden links share one mark per side. It narrows to its title
(never wider than an open node, where a long title is cut). A collapsed node has no
ports to grab, so a link dropped on it goes nowhere – expand it to wire it. Like hiding a link,
this is drawing only:
collapsedon the node is saved with the project. A collapsedVariablesorPropertiesnode, titled just that while open, is titled by what it does, since its ports are out of sight: Set 2 variables when more than one is written, else Get 2 variables when more than one is only read, else Set speed / Get speed, and No variables when it has none. - Header bar (the same one the Texture and Container tabs have): the behavior picker,
Fit (which zooms out far enough to hold every node, measuring each by its own drawn
height –
editor/framing.ts), and the size of the graph as "N nodes · M links". - Snap (header checkbox, off by default): a drag drops each node it carries on the nearest
24 px grid line – the same grid the background draws. It takes effect on the next drag only, so
switching it on never shuffles a graph that is already laid out, and a node nobody drags never
moves. Every node of a selection snaps for itself, so dragging a group is how you line one
up; the offset is a decision about rectangles and lives with the others in
editor/framing.ts. - Selection-following, not selection-bound: selecting a behavior asset, a placed behavior or one of its nodes shows that graph; a selection about anything else (a sprite, nothing at all) leaves the tab on the behavior you were editing, because checking a sprite's position must not empty your work. Only a behavior that no longer exists is dropped, and then the tab falls back to another one – the picker is how you switch on purpose.
- Live view: Play always switches to the Game tab; switch to Behavior while playing and
the exec edges and nodes that just ran light up – the same canvas draws editor and game, so
this costs nothing extra. Each fire of an exec edge also sends a bead along it (0.3 s, at most
one per 0.15 s and edge): a key press is a single bead,
On updatean even chain. Beads run on game time, so a paused game holds them where they are. Every data output shows, in the same colour, the value it last carried (0.017 dt,Pause box object), refreshed ten times a second. With several objects running the same behavior it is the one that produced last; in a loop, the last round. An output nothing has asked for yet shows nothing. The graph stays editable meanwhile: the interpreter reads the runtime copy's graph on every pulse, so an edit takes effect on the next frame and is discarded with the run. A newOn startnode does not fire for a behavior that has already started. - Runtime:
BehaviorSystemcreates oneGraphInterpreterper behavior entity; graphs only touch world entities. A runaway pulse throws (see Loops for the two limits) and stops play with a notice.
Placing an asset: drag it in. Drag a row out of the Assets panel and drop it on the
view – the Scene tab places into the active scene, the Container tab into the open template, so
what you see is what you fill. While the pointer is over a view, an outline shows the footprint
the object will have and a crosshair the origin it snaps to; it turns red when the drop would
be refused, which today means a container that would end up inside itself (placementRejection,
src/core/model/placement.ts – the same function the command runs, so the preview cannot promise
a placement the drop then denies). Picking a container up selects it, but the Container tab does
not follow that while a drag is in flight: the target must not swap out from under the pointer.
Dragging is the only way to place: a double-click on a scene makes it the active one, on anything
else it does nothing.
Editor symbols are always on top. The viewport draws scene content (sprites) in an
objects layer and editor symbols (camera icon, labels) in an overlay layer above it, so a
sprite can never hide a symbol regardless of z or hierarchy order. A viewport label keeps a
constant size on screen however far you zoom, and both kinds of them – the object names of the
Scene tab and the frame names of the Texture tab – read that size from one token
(Theme.viewportLabelSize); the two viewportLabel styles differ in colour only.
Z-order is part of the position. position.z is the draw order of a scene object: higher
z is drawn on top; objects with equal z keep their hierarchy (tree) order. It is edited in the
inspector next to X/Y, undoable like any move, and applies in edit and play mode alike.
Hierarchy (parenting). Every scene object may have a parent in the same scene
(SceneObject.parentId). Positions – including z – are local to the parent; the world
position is the sum along the chain (src/core/model/hierarchy.ts). Children move with their
parent and are removed with it (also when the parent's asset is deleted); undo brings the whole
subtree back. Re-parenting keeps the world position by recomputing the local one, and cycles
are rejected. In the Scene objects panel children are indented below their parent; drag a
row onto another row to parent it, onto the empty space below the rows (or right-click →
Unparent) to move it to the top level; the inspector offers the same as a Parent menu.
Dragging into the gap above or below a row – its outer quarter, marked with a line instead of
an outline – orders the object among that row's siblings rather than putting it inside, and
re-parents it on the way when the gap belongs to another parent. Since equal z falls back to
this order, that is also how two objects at the same depth are put in front of each other. A drag
that lands where it started changes nothing and costs no undo step.
A row's name turns gold when the object deviates from what it follows – its container
template, or its asset – and also when anything below it does, so a folded subtree still shows
that something inside was changed. It is the same gold the inspector paints an overridden
label in.
Rows with children fold: click the chevron (or press ←/→, or double-click the row) to open
and close a subtree, right-click → Expand all / Collapse all for the whole scene. A
scene opens fully collapsed – which rows are open is view state of the panel, never stored in
the project – and selecting an object anywhere (viewport, Add collider, Create container)
unfolds the way down to it and scrolls it into view, so a selection is never hidden. In
play mode entities carry the same parent link: moving a parent entity moves its subtree,
Destroy removes it. Get position exposes both the local and the world coordinates.
Editor offset (scene view only). Every scene object additionally carries an
editorOffset – an x/y shift of where it is drawn, selected and dragged in the Scene tab,
and nothing else. The game, collisions and the graph never see it; it is there to pull apart
objects that sit on top of each other, such as the stacked title/win/lose screens of a game.
It cascades like a position (offsetting a container offsets its content) and is edited in the
inspector below X/Y/Z, undoable, with ↺ back to 0. It belongs to the placed object, so
Create container and placing a container never carry it into the template.
Colliders and collisions
A collider is an asset (shape box/circle, width/height or radius) placed like any
other object – typically as a child of the sprite it belongs to, so its position is the offset
from that body and it moves with it. Right-click an object in Scene objects → Add
collider to get one with one click: a box collider sized like the
sprite, parented at its centre. Colliders can be reused across objects, overridden per instance
and stacked (hitbox + hurtbox on one sprite; colliders of the same body never collide with each
other). They are editor symbols: green outlines in edit mode, invisible in play mode.
Play runs a CollisionSystem (src/runtime/systems/CollisionSystem.ts) before the behaviors:
every frame it tests all collider pairs (world positions applied; touching edges do not count)
and sorts them into enter, stay and exit. Detection never pushes anything apart. A
behavior receives On collision enter/stay/exit for the colliders of its owner (its
parent; itself when top-level): the outputs are other (the other body), other collider
and collider (the own one). Is touching(a, b) answers the same question on demand – an
object stands for its collider children, a collider for itself. The geometry lives in
src/core/properties/collider.ts so editor and runtime agree on it.
Blocking is opt-in per movement: the Move node has a Collide with solids setting
(default off). When enabled it moves an object one axis at a time and, after each axis, pushes
its solid colliders back to just touch any solid collider they ran into – so the other axis
still moves and the object slides along walls. Its blocked x / blocked y outputs tell which
axes hit (always false with the setting off). A collider with
Trigger (no blocking) enabled (isTrigger, drawn amber instead of green) still produces
collision events but never blocks. An overlap that already exists is never resolved (no jolt
when objects start stacked or inside a wall) but also never deepened, so such an object can
always leave. A move longer than the thinnest collider involved is split into sub-steps – a
fast object cannot jump across a thin wall and end up on the other side. Colliders of the
moving object and of anything parented under it travel with it and cannot
block it. Move without the setting and Set position ignore colliders entirely.
Textures
A sprite is a coloured box until it is given a picture. The picture is a texture asset,
and the sprite points at it through the ordinary property texture — which is the whole
design: because it is an ordinary property, instance overrides, Apply to container,
Reset to asset, undo and Properties work on it without a single line written for them.
One coin instance can wear a different picture than its nine siblings, and swapping a texture
at runtime is a graph node that already exists.
Getting an image in. Dropping image files onto the canvas or Ctrl+V pasting one uploads it and makes the asset in one go; several files at once are imported one after another, and a file that fails does not stop the rest.
+ → Texture asks a different question and so opens a different dialog: not the machine's file system, but every picture the server already holds, as a grid of thumbnails. Files are never collected (below), so a picture one project dropped is still there for the next to pick up, and uploading the same bytes twice buys nothing. Picking one and pressing Use image makes a texture asset on that file; Upload… is the way in for a picture 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 rather than making a second one.
Where it goes. The image is uploaded as raw bytes to POST ?r=textures and stored as
data/textures/{sha256}.{ext} — the file is named after its own content. That name buys four
things: the same picture uploaded twice is one file, a stored file can be cached forever
(a name cannot come to mean different bytes), the project document holds a short string
instead of megabytes of base64, and deleting a texture asset is undoable for free because
nothing on disk is removed. The price is that files outlive the projects that referenced them.
Colour tints the picture. color multiplies the texture, so it defaults to white
(= no tint) and a picture shows its own colours. Tinting is worth having: one white coin
picture becomes ten differently coloured coins, per instance, for nothing. Without a texture
color is simply the fill of the box, as before.
filter is the texture's only property: Smooth (default) or Pixel art, which is the
difference between crisp and mush when a small image is scaled up. Switching it needs a
style.update() on the texture source, not just the assignment: Pixi's scaleMode setter
writes the filters but leaves the style's cached resource id in place, and that id is the key
the GPU sampler is looked up under — so without it the picture only changes on a reload.
Frames — cutting a sheet up
The Texture tab shows one texture with its frames on top: named rectangles in image
pixels. Drag on empty picture to cut one, drag it or its eight handles to adjust it, and edit
the exact numbers in the inspector; Delete removes the selected frame. Slice… cuts a
regular grid at once — you give columns and rows (not a cell size: everyone knows how many
tiles their sheet has, and deriving the cell size from the picture cannot disagree with it),
with optional offset and spacing for padded sheets, and the dialog says how many frames of
what size the numbers would produce before it commits.
A sprite then wears a frame instead of the whole picture — but through the same
property. texture holds either a texture id (the whole image) or a frame id, because frame
ids come from the same generator and are unique across the document. That is what keeps one
picker, one property and one set of instance overrides, and what makes every sprite written
before frames existed still mean exactly what it meant. The picker lists each sheet followed
by its frames as "Sheet / Frame", and it is searchable, because one sliced sheet adds an entry
per cell.
Frames are intrinsic data on the texture, like a scene's objects and a behavior's graph — not properties, since nothing about them is overridable per instance. All edits go through commands, so cutting, dragging, slicing and re-slicing are undoable, and one drag is one undo step. Deleting a frame leaves the sprites pointing at it: a dangling reference draws the plain colour and comes back by itself on undo. Deleting the whole texture counts the sprites wearing any of its frames, not just the ones naming the texture directly.
Fitting the sprite to the picture. The Width and Height rows of a sprite carry a ↔ / ↕ button offering two things: Fit to 64 × 48 sets that one side from the other and the picture's proportions — the frame's rectangle when it wears a frame, the whole image otherwise — and Set to 64 × 48 px puts both sides at the picture's own pixel size. Like ↺ and ↑ the button exists only when it would do something, and follows the same rule for how many: no offer means no button, one is a direct click, both make it the menu form ↔ ▾. So a sprite without a picture, or one already in proportion and at pixel size, carries nothing. Either action is an ordinary property write, so on an object it becomes an override, and setting both sides is still one undo step. The proportions are compared with a relative tolerance rather than exactly — of the 40 000 picture sizes up to 200 × 200, 3135 do not survive the round trip through a division, and an exact test would leave the button standing after its own click. The pixel size is compared exactly, because those two numbers are written as they are.
A sprite straight from a texture. Right-click a texture in the asset list → Create sprite
from texture makes a sprite asset that wears the whole picture at its pixel size, named after the
texture (numbered like any name clash, so Coin gives Coin 2) and selected. It is the same as creating a sprite, picking the texture and clicking Set to
W × H px, but in one undo step.
Rendering a frame is a texture matrix on the fill, not a Pixi sub-texture — a Graphics fill
ignores a Texture's frame (measured, see viewport/textureFill.ts). One consequence is
worth having: a sheet stays one GPU upload however many frames of it are on screen.
Nothing ever breaks on a missing picture. While an image loads, and for a reference that dangles because the texture was deleted, the sprite draws its plain colour — exactly what it looked like before textures existed. So there is no placeholder, no flicker and no error path. Deleting a texture warns how many sprites reference it (a texture is never placed, so counting placed instances would report "unused" for a picture a dozen sprites are showing).
Animations
An animation asset is a list of pictures played one after another. Each entry is what a
sprite's texture can hold – a whole texture or one of its frames – so an animation may mix
frames of several sheets, and a picture listed twice is held twice as long; there is no
per-frame duration beyond that. Its two properties are Frames per second (default 10) and
Playback: Loop, Once (stops on the last frame) or Ping-pong (back and forth, without
showing the turning frames twice).
A sprite wears it through texture – the same property, the same picker (animations are
listed first, marked "(animation)") and the same instance overrides as a texture or a frame.
The sprite stays what is drawn, sized, tinted and collided with; the animation is only a picture
that changes over time. Set to W × H px takes the size of the first frame. The fastest way in
is right-clicking a texture → Create animation from texture, which plays its frames in
order (the whole picture when it has none), named after the texture, in one undo step.
The Animation tab plays the animation above a strip of its frames. ▶ / ❚❚ pauses on the
frame on screen, and resumes from the selected one. Clicking a tile pauses on it, dragging a
tile into another gap reorders the frames, Delete removes the selected one, right-click offers
Duplicate frame and Remove frame, and + Frames ▾ inserts after the selection: each
sheet's frames at once, or single pictures. The selected tile belongs to the tab, not to the
editor's selection, so the inspector keeps showing the animation's two properties meanwhile.
The preview fits each frame on its own until the wheel zooms it about the pointer; from then on
every frame is drawn at that one scale, centred, so frames of different sizes show their real
proportion. The middle button (or Shift+drag) pans, Fit returns to fitting, and over the
strip the wheel scrolls the tiles instead.
It runs in play only. The Scene and Container tabs show the first frame: the edit mode has
no clock and redraws on changes only. The AnimationSystem gives every sprite its own clock, kept on the entity and
not in props (the world writes changed props back as overrides). The one rule that makes it
usable from a graph: a different animation starts from its first frame, the same one set again
runs on – so On update → Properties (Sprite, texture on Set) may choose Run or Idle every
frame without freezing the animation on frame one. The system ticks before the behaviors, so
On animation finished fires in the frame a Once animation reaches its end, exactly once,
with the sprite as object. Like collisions it is heard by the behaviors of the sprite's owner
subtree – the sprite itself, or a sprite placed next to the behavior below the same object – and
by a behavior without an owner for every sprite; its Animation setting narrows it to one.
There is no node to restart an animation that is already running; that is left for when a game
needs it.
Deleting a texture counts the animations that play its frames besides the sprites wearing it; deleting an animation counts the sprites wearing it. Either leaves dangling references that draw the plain colour and come back on undo.
State machines
A state machine asset is a named set of states and nothing more, yet: its graph holds one
State node per state (a title and a Name setting, no ports) and no edges. Transitions,
events like "on state entered" and a highlight of the current state while playing are later
steps. The states are edited in the State machine tab, which is the graph canvas the Behavior
tab uses – so placing, dragging, the header's Snap checkbox, renaming in the inspector,
Delete and undo are the graph's.
Selecting the asset opens the tab (a behavior, texture or container asset does not switch tabs –
a machine has nothing to show anywhere else). Right-click on its canvas offers a State and
nothing else, named by its number (State 3 for the third); no behavior palette offers one.
A behavior reads it through two nodes, both with a machine input that is a picker while
unconnected (or takes a State machine node, the literal every kind has):
State branchgrows one exec output per state, named after the state, and continues along the one the machine is in. BeforeSet statehas run the machine is in no state, and the flow ends there – there is no start state. A state the machine no longer has (deleted, or of another machine) ends it too.Set stateis the mirror image: one exec input per state, one exec output. The input the flow arrives through names the new state – soOnStart → Set state (Open)andOn key down → Set state (Closed)are two edges into one node. The interpreter hands every node the exec input a pulse entered through (run(…, entry)); this is the first node to read it.
The ports are keyed by the state's node id and labelled by its name: renaming a state keeps
every edge, deleting one leaves its edges dangling like any port that is gone. Which machine the
ports belong to is read off the graph, not off a running value – the inline pick, or a
State machine literal wired in; a variable or an Item at feeding the input yields no state
ports, exactly as Run behavior knows its behavior only as a setting. Picking another machine
orphans the edges of the old states, the way any setting that decides a node's ports does.
The current state is the behavior instance's own, kept beside its variables
(NodeContext.states, machine id → state id): two crates running the same behavior are in
states of their own, and a behavior run through Run behavior starts in no state, as it starts
with fresh variables. A state machine is a kind like a texture – a variable, a struct field,
a list item (tagged {"$statemachine": id}), a port – and a statemachine asset is never placed.
Limits. nginx caps request bodies at 1 MB, so the editor shrinks images over 2048 px per side and re-encodes to WebP when they are still too big — but an image that already fits is uploaded byte for byte, because re-encoding pixel art would destroy it. Anything still too large is refused with a clear message instead of a broken upload. The server accepts PNG, JPEG, WebP and GIF, deriving the type from the content rather than from anything the client says, and rejects images over 8192 px per side.
Editing a container
The Container tab shows one container's template with the same viewport the Scene tab uses –
ContainerCanvas subclasses SceneViewport and swaps nothing but which asset holds the objects,
so picking, rubber band, dragging, gizmos, labels and parent lines are not written twice. It adds a
cross at the origin, because that is the point the container is held by when placed: where the
content sits relative to it is the thing being authored. A template has no background of its own –
painting one would invent the scene it is not in.
The Scene objects panel lists whatever the visible tab edits, so in the Container tab it lists
the template, with the same drag-to-parent, context menu and folding as a scene; the inspector,
Delete and Ctrl+G work on template objects like on placed ones. None of that is a special case:
a scene asset and a container asset are both ObjectHolders, and every object command takes a
holderId that is either one.
Header bar: the container picker (searchable), Fit (frames the content, origin always included) and the size of the template as "N objects". Placing needs no button of its own: an asset dragged from the panel onto this view goes into the template, and a container dragged onto its own template is refused while it hovers rather than at the drop. Like the Behavior tab it follows the selection without being bound to it – the container asset, one of its placed copies or one of its template objects shows it, anything else leaves the tab on what you were editing, and only a deleted container makes it fall back to another one. Selecting a template object also switches to the tab, the rule a graph node and a texture frame already follow: it exists in no other view. Selecting the asset itself does not.
Placing a container anywhere creates one object. Its content is never copied: resolveObjects
(src/core/model/instance.ts) weaves the template in whenever the holder is read, one level per
nesting step, and the resolved objects get derived ids – <instance id>~<template id>, joined by
/ for a container inside a container. That is what the hierarchy lists, what the viewport draws
and what a command addresses. A container that reaches this one, directly or through another, is
still refused – that cycle would resolve forever.
So editing a template changes every instance, structure included: add, delete or re-parent a
template object and every placed container shows it immediately. What an instance may deviate in
is values, and only per slot: an override key, one position axis, the name. Those live in
instanceEdits on the container object and are dropped again the moment the value matches its
base, which is what keeps "untouched follows, touched keeps" exact without any bookkeeping. That
base is the template with the edits of every container in between woven in (InstanceTarget.base):
inside nested containers the innermost template is not what a value falls back to, so comparing
against it would drop an edit that does deviate – and the value would jump to the value of the
container in between.
The other direction is therefore just as small: Apply to container writes the value into the template and drops the edit, Reset to container drops the edit alone.
Two places do spell the content out. Unpack does it on purpose – the objects become ordinary
ones and stop following. And play mode bakes every placed container into stored objects, in
the throwaway copy of the model, because a running behavior may destroy one of them and a resolved
object has nowhere to remember that. Both keep the derived ids, so the selection and a check script
survive them. A world built without baking (the editor run, WorldOptions.bakeContainers) runs
that content all the same and writes through objectRef into the instance edits; only a removal
has nowhere to go there.
Screens without scene switching
A screen (title, win, lose, a pause overlay) is just a container whose enabled is
switched on and off. It keeps working while it is off in one important way: Object by name
still finds it, so a graph can switch it back on. A behavior inside a disabled screen has no
interpreter yet – it gets one, and its On start, the moment the screen is switched on, which
makes "the screen initialises itself when shown" the default. Its variables survive being
switched off and on again. A switched-off object is hidden in the Scene tab as well, so the
editor shows what the game shows – selected, it keeps a faint outline, so where it sits is still
visible, but it cannot be dragged. It stays listed in the hierarchy panel, which is where it is
selected and switched back on.
Restart game throws the run away and starts the scene again from the saved model – what the
"Play again" buttons of the example game do.
Stack
- PixiJS v8 – rendering and pointer events. No game engine; runtime systems live in
src/runtime/.main.tscreates the WebGL context itself withdesynchronized, which Pixi has no option for: frames skip the compositor queue, so the UI keeps up with the pointer (click to picture measured 72–77 ms before, 16–18 ms after). The price is possible tearing while things move fast;?vsyncfalls back to Pixi's own context. A browser without the mode simply ignores it. - Own canvas widget toolkit (
src/ui/) – panels, buttons, lists, text/number/color fields, popups. The only lasting DOM element is a hidden<input>used as a keyboard bridge (IME, mobile keyboard, clipboard) while a canvas text field is focused; Upload… adds a short-lived file input. Double-clicks are counted inclicks.ts, not read off the event: Pixi only puts a click count on theclickit synthesises and leavesdetailat 0 onpointerdown, so every press handler that trustede.detailnever saw a second click. Presses are counted per key rather than per widget, because a list rebuilds its rows when the first click changes the selection. Menus read as lists: entries are left-aligned and scroll once they pass 320 px instead of running off the screen. A menu that grows with the project – the parent picker lists every object of the scene – is opened with{ searchable: true }, which puts a filter box above the entries (Enter takes the first match). A field that opens a menu is aDropdown: value flush left, chevron drawn flush right rather than written into the label. Every label that can be wider than its control is cut with an ellipsis (truncateinLabel.ts, shared byLabel,ListRowandButton). AButtonkeeps the full text and measures from it – only what is drawn is shortened – and a subclass that puts something else inside the control takes its room offlabelSlot(), so a long value is cut rather than run underneath the chevron. Given awidth, a button is exactly that wide and truncates instead: the Behavior, Texture, Animation and Container pickers keep their size whatever they show; the tab bar uses 90 px as a floor. A button that can draw its label bold – on an accent fill: primary, danger, a toggled tab – is measured bold, or a dialog'sDeletecomes out asDel…. The padding is an inset rather than a claim, so a 24 px icon button spends it on the glyph instead of truncating it away; a button that switches between label forms (↺and↺ ▾) asks forminWidthand grows.__gm.textOverflows()guards the whole class of bug: it reports any visible text drawn wider than the widget owning it,__gm.labelsDropped()guards the other direction – a button too narrow to draw any of its label – and thepickerWidth()of__gm.graph/__gm.texture/__gm.containercatch a picker that resizes itself around its content (e2e step 14). - Vite + TypeScript, Vitest for core, toolkit and editor logic.
- PHP 8 JSON API (
api/) storing one JSON file per project indata/projects/, with atomic writes and an integer revision that rejects stale saves (HTTP 409).
Layout
web/index.html Vite source page (dev) index.html built page (generated, served)
src/core/model/ Project (object holders), Asset + AssetTypes, SceneObject, Properties (value types), hierarchy, walk (generic parent/child walks), instance (container content resolved from its template), usages (where an asset is used), container (the cycle guard), placement (what may be dropped where), texture (frames and the slice grid), animation (which frame at time t), graphLookup (what a graph sees of the project: behaviors to run, state machines to read)
src/core/properties/ PropertySchema, kinds (one row per value kind), listItems (an item's kind, read off its value), resolve (the only override merge), deviation (what is gold), collider/camera/bounds geometry, aspect, enabled, editorRun, assetRef/textureRef (how a reference reads)
src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base, require.ts (validated lookups, and what an instance may not change), naming.ts
src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter, dropTarget, nested (behaviors as nodes),
stateMachine (the State node, a machine's states, the ports they become), variablePorts, propertyPorts,
lookup (what a node may look up: behaviors, state machines), keyRepeat; nodes/{events,flow,data,action,variable}/<Name>.ts
src/core/store/ serialize (untrusted JSON → Project), http (one API call), ProjectStore/HttpProjectStore, TextureStore/HttpTextureStore
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, AnimationSystem, BehaviorSystem
src/ui/ widget toolkit (generic, knows nothing about assets)
src/viewport/ view (transforms), items (what to draw), symbols (how to draw it),
textureFill, TextureLibrary, activeCamera – no editor concepts, so tests reach it freely
src/editor/ App → Editor (layout + ticker), PlaySession (the run), ProjectSession (load/create),
EditorRun (run in editor), state, actions, shortcuts, debugHook, autosave, textureImport,
framing (fit/marquee/snap maths), AssetTabEditor (the shared shell of the asset tabs)
src/editor/viewport/ WorldViewport base + SceneViewport/GameViewport – editing tools, hence not in src/viewport/
src/editor/inspector/ Inspector panel, host (what a section builds into), field factory, previewHandlers (scrub → preview → command), valueSpecs
(where a value lives – shared with the List tab),
one section per selection kind (asset/object/node/frame), variableRows (one row shape for
every variable), portSection, aspectAction, objectQueries/vectorRows/parentRow
src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar
src/editor/graph/ GraphEditor (header + picker), GraphCanvas, EdgeLayer (curves and fold marks), graphMenus,
NodeView, NodePalette + paletteEntries, folding (hidden links, collapsed nodes), Breadcrumb + trail (the path into nested behaviors), beads (the run's pulses)
src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template)
src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + frames), SliceDialog, sheetView (the grid maths),
ImagePicker + imageGrid (the "+ → Texture" dialog), checker (the transparency board)
src/editor/animation/ AnimationEditor (header + picker), AnimationCanvas (preview + frame strip), strip (its geometry)
src/editor/statemachine/ StateMachineEditor (header + picker, on the graph canvas)
src/editor/list/ ListEditor (a row per item), slot (which list is being edited, and how it is written back)
src/editor/console/ log (ring buffer, argument formatting, the console.* capture), ConsolePanel (the tail under the tabs)
api/ PHP backend data/projects/ persisted projects
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/, rig in tools/e2e/lib/), smoke-*, lib/, assets/ (the walk demo's sheet)
The toolbar's right edge shows the frame rate (frames counted over a 0.5 s window, updated
twice a second) in edit and play mode alike; __gm.fps() exposes the same number to tests.
Rules that keep it organised: core/ imports nothing from other layers; ui/ never sees
assets; position exists only on scene objects; overrides are merged in exactly one place
(core/properties/resolve.ts); the inspector is generated from PropertySchema – a new
property is one schema line; a new node is one file plus a row in the table in
tests/core/node-catalog.test.ts, which fails if a node has no case. Comments state contracts
and reasons, not what the code does: a /** */ on every export whose name does not say it all,
// only for a decision or gotcha.
Development
Node 22 is required (/usr/bin/node, in the default PATH).
npm install
npm run dev # http://localhost:5175/game-maker/dist/ – API calls are proxied to production
npm test # unit tests (core, editor, ui, viewport)
npm run typecheck
npm run lint # ESLint (type-aware for src and tests); lint:fix for the fixable half
npm run format # Prettier over the repo, minus what .prettierignore lists
npm run check # typecheck + lint + format:check + tests, the gate CI runs too
./check.sh # the same, ending in `=== CHECK EXIT n ===`: piped into `tail` npm's exit
# code is lost (tail's is reported), so a red run reads as green
npm run build # writes dist/ and regenerates index.html
./build.sh # the same under `set -o pipefail`, for callers that pipe the output
node tools/seed-e2e.mjs && node tools/e2e.mjs # 671 editor checks, screenshots in /tmp/gm-e2e-*.png
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
./e2e.sh start [GM_STEPS] # the same run detached, see below
./e2e.sh status # live progress, or the last run's result
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs # 19 checks: the Tetris example, played end to end
node tools/seed-actor.mjs && node tools/smoke-actor.mjs # 20 checks: the walk demo (the seed uploads its own sheet)
A run needs a working GPU. An idle editor page keeps rendering a picture that never changes, and
in software that is ruinous: measured here on an idle page over 20 s, software WebGL costs 373% of
a core at 8 fps, the same page on the Intel HD 630 via ANGLE/Vulkan costs 69% at 56 fps. The
full suite takes about four minutes that way. tools/lib/browser.mjs therefore always asks for the GPU and has
no software fallback – on a host without one, chromium should fail loudly rather than melt the
machine down. (--use-angle=gl and --use-gl=egl measure cheaper still, but get no WebGL context at
all, so the app never renders.)
Leave the full run to CI. Every push runs the whole proof on the runner (see Continuous
integration), so locally take only what the change needs: the gate, plus the step that covers it –
GM_STEPS keeps that to seconds. The whole suite works here too and costs about four minutes and
roughly 0.8 of the four cores of this box (60% renderer, 18% GPU process, load ~1.0), but four
minutes of waiting buys nothing the push does not give.
./e2e.sh start runs the suite detached, in a transient game-maker-e2e.service, and returns at
once; output goes to logs/e2e-<date>_<time>.log (gitignored) and ends in === E2E EXIT n ===.
Backgrounding it from a shell instead is not enough – four runs in a row died mid-suite with no
closing line, because the whole process group is killed when the calling shell exits (nohup only
covers SIGHUP, and /proc/vmstat confirmed oom_kill 0, so it was never memory). The unit outlives
its caller. It takes the same GM_STEPS argument, refuses to start a second run over a live one, and
systemctl stop game-maker-e2e.service ends it.
./e2e.sh status reports the live run (elapsed, PASS/FAIL so far, the check it is on) or, when
nothing is running, the newest log's result plus its FAIL lines. A log without a closing line is
reported as ABORTED, not as its PASS count: a run that was cut short is not a result.
Prettier owns the formatting (110 columns, single quotes, .prettierrc.json); Markdown is
excluded because it pads table cells to equal width. ESLint therefore only ever means "this is a
mistake", never "this looks different": eslint-config-prettier switches every stylistic rule
off, and what is left is type-aware – unnecessary conditions, floating promises, duplicate
imports, base-to-string. Where a rule fires on something deliberate, the disable comment states
why (the Log node's console.log, a guard against untyped test hooks).
Each script writes to its own project (tools/lib/projects.mjs): the fixture project is
rewritten by every e2e run, the jump-run and tetris projects by their seeders. The
playground project is the author's own and is maintained in the editor – every tool refuses
to touch it.
The e2e run is a list of steps in tools/e2e/ (hierarchy, inspector, play, assets, drawing,
graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect,
container tab, run-in-editor, pause, nested behaviors, key repeat, list tab, struct variable,
variable nodes, property nodes, animations, state machines, console) that build on each other's
scene state and share the CDP driver in tools/lib/harness.mjs – press, undo, play,
object(id), world(), mutate(js) and friends, so a check reads like the interaction it
exercises.
GM_STEPS=20, GM_STEPS=1-12 or GM_STEPS=6,20 runs a subset – seconds instead of minutes
while one step is being written. Because a step lives off what the earlier ones left behind, a
subset that skips a prerequisite fails on purpose; take 1-N when in doubt.
The example game (tools/seed-jumprun.mjs, project Jump and Run) is a complete little
platformer built only from assets, containers and graphs: a title screen with a clickable Start
button, a level of boxes with ten coins, gravity and jumping, a 30-second clock in the HUD, and
a win / lose screen with "Play again". A coin is a container of its own (sprite plus trigger
collider) placed ten times inside the level container – nested containers resolve through both levels,
and picking one up destroys the container, not just the sprite the collider reported. tools/smoke-jumprun.mjs plays it in a headless browser
(start, jump, collect, win, restart, lose) – it is the regression test for everything screens,
text, mouse and platformer physics need.
The second example (tools/seed-tetris.mjs, project Tetris) is the same idea with data
instead of physics. The board is a list of 200 numbers, one per cell – -1 for empty,
otherwise the shape that came to rest there. A cell is Item at(grid, row · 10 + column), a
landing is Set item, and clearing a full line is an empty row joined in front of everything
above and below it. What the player sees is a pool of objects the graphs address by name –
c0…c199 for the well, p0…p3 for the piece that is falling – so nothing is spawned or
destroyed while playing. Every frame tries exactly one move (turning beats moving beats
falling), which is why the game needs only a single "does it fit?" test instead of one per key.
What is not loose numbers is the piece. piece and the candidate next are structs of
shape/rot/x/y, and the shape table is a list of {x, y} cells – so Next move hands back a
whole candidate for Fits to judge and the master to accept in one write, rather than three
parallel variables copied back and forth, and each strand packs only the field it changes
(mergeStruct keeps the rest). Reading a cell is ((shape · 4 + rot) · 4 + corner) into that
list instead of the same index doubled and read twice.
Where a step needs several variables at once, one Variables node stands for the
lot: the call to Next move has one node at each side of it rather than five, and the master
graph is 44 nodes / 64 edges where the same game in single nodes was 69 / 76. Inside Next move each strand writes its whole answer – the candidate, what kind of move it was and the wish
it used up – in one node rather than three to five, which is 30 nodes against 40. A writing
node reads all its inputs before it writes any, so an input fed from a variable the same node
writes sees the value from before.
It is ten behaviors, not one graph. Tetris owns the state and does none of the work: each
step of a frame is a behavior of its own, called as a node – Spawn piece, Next move, Fits,
Draw piece, Lock piece, Clear lines, Show lines, Draw board, with Piece cell (where
one of the four cells of a piece sits) used by two of them. None of them holds state: what they
need arrives through their inputs and every answer leaves through their outputs, which is what
lets Fits answer for turning, moving and falling alike, and Piece cell serve the fit test
and the drawing instead of being the same twenty nodes twice. What is left in
the master graph is the order the steps run in and which variable each answer lands in – it holds
no loop and no list access of its own, which tools/smoke-tetris.mjs checks. That script plays
the game in a headless browser (fall, move, turn, drop, land, clear a line, fill the well) – it
is the regression test for loops, lists and behaviors used as nodes.
The walk demo (tools/seed-actor.mjs, project Actor demo) is the smallest of the three and the
only one that ships its own picture: it uploads tools/assets/Actor1.png and slices the 576×384
sheet into the 96 cells of a 12×8 grid. That is the usual RPG-Maker layout – eight characters, each
three columns (left foot / standing / right foot) by four rows (down, left, right, up) – and the demo
wears the one in the top left corner. Walking is four ping-pong animations of three frames, which
is exactly the original walk cycle; standing is no animation at all but the middle frame of the row.
The graph picks what the sprite wears the way the editor does: two list variables hold the four
walk animations and the four standing frames, every item chosen with the texture picker, and
facing is the row of the sheet, which indexes both (Item at). No id is spelled anywhere in it,
so the whole graph can be rebuilt by hand. Which way it looks is the key pressed last, corrected whenever only one axis is held – otherwise letting go of one key of a
diagonal would leave it walking backwards. It is the worked example for animations, for a seed that
brings its own texture, and for Axis input, whose vector is already normalized so a diagonal is not
faster than a straight line. tools/smoke-actor.mjs walks it in a headless browser; that the
animation really advances it reads off the drawn pixels, standing at the edge of the camera where
he walks on the spot – a clock that ticks without the picture following would pass otherwise.
The workspace folder itself is what nginx serves, so npm run build is the deployment.
Continuous integration
Every push runs the whole proof on the Gitea runner: .gitea/workflows/ci.yml installs, runs
./check.sh, builds, then tools/ci/run-e2e.sh (the editor suite) and tools/ci/run-smokes.sh
(the three games). Both scripts source tools/ci/serve.sh, which puts the checkout behind
http://127.0.0.1:8080/game-maker/ with php -S – the build wires its assets absolutely under
/game-maker/dist/, so the path segment is not optional – and writes the empty project files the
seeders need (PUT does not create a project, POST hands out a random id). Every tool takes its
server from GAME_MAKER_BASE_URL.
The job runs in the image from tools/ci/Dockerfile under the label game-maker-ci: chromium
with the Vulkan drivers, php-cli, and the host's font packages, because the checks measure text
widths to find click points. The render device is not in this repo – /dev/dri/renderD128
and a 1 GB /dev/shm come from container.options in the runner's own config.yaml, which
applies to every job on that runner. Without them chromium falls back to llvmpipe, which gets
WebGL 1.0 only. Measured in the job container: ANGLE (Intel, Vulkan 1.3.230, HD Graphics 630),
WebGL 2.0, and the same 671 checks as here.
Ideas, gaps and bugs
They live in the issue tracker of the Gitea repo: https://git.nockal.com/meleki/game-maker/issues.
The folder todo-notes/ that held them as files until 2026-09-12 is gone; all 50 notes were moved
into issues #1–#50, closed ones included, each carrying the commits it was filed and finished with.
API
All routes go through api/index.php?r=… (the site's nginx does not pass PATH_INFO).
| Method | Route | Body → Result |
|---|---|---|
| GET | projects |
[{id,name,revision,updatedAt}] |
| POST | projects |
{name} → project document (201) |
| GET | projects/{id} |
{id,name,revision,updatedAt,project} |
| PUT | projects/{id} |
{revision, project} → {revision,updatedAt} or 409 {error,revision} when stale |
| DELETE | projects/{id} |
204 |
| GET | textures |
[{file,width,height,bytes,updatedAt}] – what the image picker offers |
| POST | textures |
raw image bytes → {file,width,height,bytes} (201) |
| DELETE | textures/{file} |
204 |
Every project document carries formatVersion (PROJECT_FORMAT_VERSION in core/model/Project.ts,
currently 2). There are no load-time migrations: serialize.ts reads what it recognises and drops
the rest.
Uploads send the bytes as the body rather than multipart/form-data: no boundary parsing in
PHP, and PHP's upload_max_filesize (2 MB) never applies — only post_max_size. The API has its
own caps behind nginx's: 8 MB per request, 4 MB per image (413). The stored
files are served statically from data/textures/, so reading an image never runs PHP.
There is no authentication yet; data/ is world-readable. Both are phase-2 topics.
Keyboard
Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y undo/redo (Cmd works wherever Ctrl does) · Delete or
Backspace remove the selection (objects, nodes, frames, a tile of the Animation tab; an asset asks first) · Ctrl+G create
container from the selection · Ctrl+Enter play, and pause/resume while playing · Esc stop ·
Ctrl+V import an image from
the clipboard as a texture (ignored while a text field has focus) · Tab / Shift+Tab cycle fields · lists: ↑/↓ move,
←/→ fold, Enter activates · number
fields: drag to scrub (Shift ×10, Alt ×0.1, snapping to the grid of that increment, so a
step-1 field lands on whole numbers), ↑/↓ step, shown with three decimals (ui/math.ts,
NUMBER_DECIMALS) so a fractional value never reads as a whole number — except where the
value is integral by nature (frame rectangles, the slice dialog, a camera's view size, font
size, the key-repeat milliseconds, the index and count ports of list and text nodes), which set
NumberField's decimals: 0 and show plain whole numbers · viewport: wheel zooms,
middle-drag or Shift+drag pans, drag on empty space rubber-bands, Ctrl/Shift-click toggles an
object in the selection (hierarchy rows too).
Not yet
Several scenes in one run (a Load scene node – screens are containers
today), sound, auth, touch-optimised layout, graph comments/groups. A frame is always stretched to
the sprite's width × height: no nine-slice. Animations have no state machine, no restart node
and no preview in the Scene tab. Slicing cuts a regular grid only — no detection of where the sprites actually
sit on the sheet. Unreferenced image files are never collected – deliberately, since the image
picker offers them again; nothing deletes a stored image except DELETE ?r=textures/{file}.