# 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: · Widget 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 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 variable` read 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 `Owner` node returns it; `Self` returns the behavior itself; `Parent` walks up from any object. - **Node set** (`src/core/graph/nodes.ts`): events `On start`, `On update`, `On key down/up/held`, `On timer`, `On collision enter/stay/exit` · flow `Branch`, `Sequence` · data `Self`, `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` · actions `Move` (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`. A node type is one `registerNode()` call; palette, ports, validation and inspector fields are generated from it. A port may declare a `default` for 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, `Delete` removes 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**: `BehaviorSystem` creates one `GraphInterpreter` per 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 `` 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 in `data/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: Scene tab (model, editable) and Game tab (world through its camera) 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`). ```bash 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 # reset the demo project to the state the e2e run expects node tools/e2e.mjs # 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.