Menu entries are left-aligned and live in a ScrollContainer capped at 320 px, so a
long list (the parent picker holds every object of the scene) scrolls instead of
running off the screen. `Menu(items, { searchable: true })` puts a filter box above
the scroll area; Enter takes the first match. Filtering forces a relayout because
two queries with the same hit count leave the height unchanged, which would keep
the stale row positions.
Fields that open a menu are a new `Dropdown` widget: the value sits flush left and
the chevron is drawn flush right instead of being written into the label. A menu
opened below a field is never narrower than that field.
New debug hooks `overlayTexts()` / `overlayRect()` make the popup contents
assertable; e2e step 11 covers the cap, the scrolling and the search box.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
24 KiB
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) and typed properties. Assets have no position. |
| 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 Set text (or Set property) 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. Set enabled / Is enabled do it from a graph. |
| 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 creates a container object plus fresh copies of the template below it – ordinary objects, each remembering its template id. Create container turns the current selection into such an asset and replaces the selected objects by one placed container at their centre (world positions unchanged). The template is edited from its instances (see Instance). |
| Selection | One or more scene objects. Click selects, Ctrl-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 from scene 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. |
| 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 marks them bold 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 bold in the inspector and can be reset (↺) the same way an override can. |
| Instance | An object that is part of a placed container. It has two bases: the template it was instantiated from and the asset behind it. A row lists only the bases its value really differs from – ↺ ▾ offers Reset to container (template value) and/or Reset to asset (drop the override – the template is only read when the container is placed, never at draw time), ↑ ▾ Apply to container (template; copies still at the old value follow, copies changed on their own keep theirs) and/or Apply to asset. With one base left the menu collapses to a plain ↺ / ↑, with none the buttons disappear. Bold marks the same thing the buttons offer. Position rows compare against the template only. Right after Create container an instance matches its template (the overrides were copied into it) but may still override the asset – then only the asset entry shows. |
| 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. A scene without a camera
renders nothing in the Game tab. Play has no built-in behaviour (no input, no movement):
everything that happens is defined by behavior graphs.
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 (bold, ↺ 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.
Behaviors (node graphs)
A behavior asset holds a Blueprint-style graph edited in the Graph 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, 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.Get variable/Set variableread and write them. - 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. - 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) · flowBranch,Sequence· 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),Get/Set variable,Get/Set property,Get position(local and world),Is enabled, literals,Add/Subtract/Multiply/Divide,Min/Max/Abs/Sqrt/Clamp,Floor/Ceil/Round,Modulo,Random,Time,To text,Join text,Equals,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,Set enabled,Set text,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. - Cross-object state: a behavior's variables are the properties of its object, so another
graph reads and writes them with
Object by name→Get property/Set property. That is how the example game keeps one coin counter for the whole run. - 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 = Get variable speed, dt = On update.dt). - 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 selected node, wheel zooms, dragging the empty canvas pans. Everything is undoable. - Selection-bound: the Graph tab shows the behavior the current selection refers to (asset, placed object or node); with nothing or a non-behavior selected it is empty.
- Live view: Play always switches to the Game tab; switch to Graph 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.
- Runtime:
BehaviorSystemcreates oneGraphInterpreterper behavior entity; graphs only touch world entities. A runaway loop throws after 1000 steps and stops play with a notice.
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.
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.
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 (or press
+ Add collider in its inspector) 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.
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; 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/. - Own canvas widget toolkit (
src/ui/) – panels, buttons, lists, text/number/color fields, popups. The only DOM element is a hidden<input>used as a keyboard bridge (IME, mobile keyboard, clipboard) while a canvas text field is focused. 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. - Vite + TypeScript, Vitest for the pure core.
- 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, SceneObject, hierarchy, walk (generic parent/child walks), container templates
src/core/properties/ PropertySchema, resolve (the only override merge), collider/camera/text geometry, enabled
src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base, require.ts (validated lookups), naming.ts
src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter; nodes/<category>/<Type>.ts
src/core/store/ serialize (untrusted JSON → Project), HttpProjectStore
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, BehaviorSystem
src/ui/ widget toolkit (generic, knows nothing about assets)
src/viewport/ WorldViewport base + SceneViewport/GameViewport, view (transforms),
items (what to draw), symbols (how to draw it)
src/editor/ App → Editor (layout + ticker), PlaySession (the run), ProjectSession (load/create),
state, actions, shortcuts, debugHook, autosave
src/editor/inspector/ Inspector panel, field factory, previewHandlers (scrub → preview → command),
one section per selection kind (asset/object/node)
src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar
src/editor/graph/ GraphEditor (canvas), NodeView, NodePalette
api/ PHP backend data/projects/ persisted projects
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/), smoke-jumprun, lib/
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 (on this machine: PATH=/opt/node22/bin:$PATH).
npm install
npm run dev # http://localhost:5175/game-maker/dist/ – API calls are proxied to production
npm test # unit tests (core)
npm run typecheck
npm run lint # ESLint, type-aware; --fix for the fixable half
npm run format # Prettier over src, tests and tools
npm run check # typecheck + lint + format:check + tests, what CI would run
npm run build # writes dist/ and regenerates index.html
node tools/seed-e2e.mjs && node tools/e2e.mjs # 140 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # the example game, played end to end
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 and the jump-run project by its seeder. 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) 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.
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 two-minute 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 expand on placement,
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 workspace folder itself is what nginx serves, so npm run build is the deployment.
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 {revision} when stale |
| DELETE | projects/{id} |
204 |
There is no authentication yet; data/ is world-readable. Both are phase-2 topics.
Keyboard
Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y undo/redo · Delete remove selection · Ctrl+G create
container from the selection · Ctrl+Enter play · Esc stop · Tab cycles fields · number
fields: drag to scrub (Shift ×10, Alt ×0.1), ↑/↓ step · viewport: wheel zooms,
middle-drag or Shift+drag pans, drag on empty space rubber-bands, Ctrl-click toggles an
object in the selection (hierarchy rows too).
Not yet
Textures on sprites, editing a container's template directly (today changes reach it only
via Apply to container from an instance), reordering the hierarchy, several scenes in one
run (a Load scene node – screens are containers today), sound, auth, touch-optimised layout,
graph comments/groups.