Co-Authored-By: Claude Fable 5.1 <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, behavior, collider) and typed properties. Assets have no position. |
| 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. |
| 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 (↺). |
| 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. |
| Edit mode | The viewport draws the project model; panels, grid, gizmos and drag-to-move are active. |
| Play mode | A World is instantiated from the active scene (one entity per scene object, resolved properties as mutable state). Systems tick it; nothing writes back. Stop discards the world. |
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. In edit
mode that frame is drawn as an outline. In play mode only that frame is rendered – fitted into
the viewport with black letterboxing – and objects outside it are culled. A scene without a
camera renders nothing in play mode. Play has no built-in behaviour (no input, no movement):
everything that happens is defined by behavior graphs.
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 viewport). 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.ts): eventsOn start,On update,On key down/up/held,On timer,On collision enter/stay/exit· flowBranch,Sequence· dataSelf,Owner,Parent,Object by name,Get/Set variable,Get/Set property,Get position(local and world), literals,Add/Subtract/Multiply/Divide,Min/Max/Abs/Sqrt/Clamp,Length,Distance,Normalize,Axis input(four keys → a normalized direction, so diagonals are not faster),Compare,And/Or/Not,Key is down,Is touching· actionsMove(dx, dy, scale, dt – connect an event's dt for per-second movement),Set position,Destroy,Log,Stop game. A node type is oneregisterNode()call; 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. - 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). - 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 Scene tab; switch back 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. 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.
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. Pairs that already overlapped before the step are left
alone, so an object that starts inside a wall can leave it and stacked objects never jolt apart.
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.
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. - 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, property schema, resolve, commands, store – no Pixi imports
src/core/graph/ graph model, node registry + built-in nodes, interpreter (headless, unit-tested)
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, BehaviorSystem
src/ui/ widget toolkit (generic, knows nothing about assets)
src/viewport/ scene renderer: model in edit mode, world in play mode
src/editor/ App composition, state, panels, inspector field factory
src/editor/graph/ GraphEditor (canvas), NodeView, NodePalette
api/ PHP backend data/projects/ persisted projects
tests/ Vitest unit tests tools/ e2e.mjs (CDP smoke test), seed-demo.mjs
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.
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 build # writes dist/ and regenerates index.html
node tools/seed-demo.mjs <projectId> # reset the demo project to the state the e2e run expects
node tools/e2e.mjs <projectId> # headless end-to-end run with screenshots in /tmp/gm-e2e-*.png
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+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.
Not yet
Textures on sprites, solid collisions (blocking movement, "move and slide"), spawning from graphs, multi-select, reordering the hierarchy, auth, editing while playing, touch-optimised layout, graph comments/groups.