The harness forced software WebGL (--disable-gpu --use-gl=swiftshader) on every host. Measured here on an idle editor page over 20 s: that costs 373% of a core at 8 fps, while the Intel HD 630 via ANGLE/Vulkan costs 69% at 56 fps. The full suite now runs in ~124 s. Note the app is not rendering 60 fps as previously assumed - in software it only reaches 8, and a single frame costs more than any throttle interval would save, which is why capping the framerate had measured no effect. --use-angle=gl and --use-gl=egl look cheaper still but get no WebGL context at all, so the app never renders. They are not usable and are named in the comment as such. There is deliberately no software fallback: a host without a usable GPU should fail loudly rather than drag the machine to a standstill. None of the 267 checks reads the rendered image anyway - they read window.__gm.locate() and the debug hook, and the screenshots assert nothing - but Pixi needs WebGL2 to run at all. The browser launch moves out of harness.mjs into tools/lib/browser.mjs, which also supports GM_REMOTE=user@host via an SSH-forwarded CDP port. That path is unfinished: the tunnel degraded until the suite crawled at ~25 s per check, and forcing software WebGL on a desktop with a real card brought it to its knees. README says so plainly rather than advertising it. Also: the jump-run HUD check asserted a fixed "Coins 1/10", which fails on a faster host where the same walk sweeps up two coins. It now asserts the HUD against the count the run actually reached - the real guarantee is that HUD and state agree. Verified: npm run check green (404 tests), 267 e2e ALL PASSED and 24 jump-run checks ALL PASSED against the GPU flags. The fallback removal leaves the flag set on this host identical (same flags, different order), so it was not re-run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
502 lines
39 KiB
Markdown
502 lines
39 KiB
Markdown
# 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`) and typed properties. Assets have **no position**. |
|
||
| **Texture** | An uploaded image. Scenes and textures are the two types that cannot be placed: a texture has no position, it is *worn* by a sprite through the ordinary property `texture`. See *Textures* 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 `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 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`-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. |
|
||
| **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 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 always the **template**; ↑ ▾ 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. Bold 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. 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/`, one file per node): events `On 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) · flow `Branch`, `Sequence` · data `Self`, `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` ·
|
||
actions `Move` (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 a `NodeDef`
|
||
(ports, settings, `run`), listed in `nodes/index.ts`; 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.
|
||
- **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 click` fires 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 (the
|
||
`world x`/`world y` outputs 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 update` adds `gravity · dt` to a
|
||
`vy` variable and moves the owner by `(axis.x · speed, vy)` with *Collide with solids* on;
|
||
`blocked y` while falling means standing, so `vy` goes back to 0 and `onGround` becomes true;
|
||
`On key down (Space)` sets `vy = −jumpSpeed` when `onGround`.
|
||
- **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.
|
||
- **Header bar** (the same one the Texture and Container tabs have): the behavior picker,
|
||
**Add node…** (the palette, dropping the node in the middle of the view – the right-click nobody
|
||
finds), **Fit**, and the size of the graph as "N nodes · M links".
|
||
- **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 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. 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.
|
||
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.
|
||
|
||
## 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 `Set property` 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.** Three ways, all ending in the same import: **+ → Texture** in the
|
||
asset panel, **dropping image files onto the canvas**, or **Ctrl+V** pasting one. Several files
|
||
at once are imported one after another; a file that fails does not stop the rest.
|
||
|
||
**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.
|
||
|
||
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).
|
||
|
||
**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 `ObjectHolder`s, 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: the
|
||
Assets panel already places, and its entry follows the visible tab – **Place in scene** becomes
|
||
**Place in container** while this tab is open, and drops at the middle of *this* view. A right-click
|
||
marks a row without selecting it, so opening that menu on a container does not load it into the tab
|
||
first – otherwise nesting one container in another would place it into itself.
|
||
Like the Graph 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 the
|
||
template, which is what keeps "untouched follows, touched keeps" exact without any bookkeeping.
|
||
|
||
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 move or 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.
|
||
|
||
## 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 a `Dropdown`: 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 (`truncate` in
|
||
`Label.ts`, shared by `Label`, `ListRow` and `Button`). A `Button` keeps 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 off `labelSlot()`, so a long value is cut rather than run
|
||
underneath the chevron. Given a `width`, a button is exactly that wide and truncates instead:
|
||
the tab-bar buttons and the Graph, Texture and Container pickers keep their size whatever they
|
||
show.
|
||
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
|
||
for `minWidth` and 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 the `pickerWidth()` of
|
||
`__gm.graph` / `__gm.texture` / `__gm.container` catch a picker that resizes itself around its
|
||
content (e2e step 14).
|
||
- **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/ Project (object holders), Asset, SceneObject, hierarchy, walk (generic parent/child walks), instance (container content resolved from its template), container (the cycle guard)
|
||
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, and what an instance may not change), 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 (header + picker), GraphCanvas, NodeView, NodePalette
|
||
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)
|
||
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`).
|
||
|
||
```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 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 # 267 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
|
||
|
||
GM_REMOTE=user@host node tools/e2e.mjs # run the browser on another machine
|
||
```
|
||
|
||
**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 ~124 s 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.)
|
||
|
||
**`GM_REMOTE=user@host` moves the browser to another machine** – `tools/lib/browser.mjs` starts it
|
||
over SSH and forwards its CDP port back to `127.0.0.1`, so the harness itself is unchanged. The
|
||
checks load the public base URL, so the remote side needs only a browser and internet: no repo, no
|
||
node. Setup is an SSH key (`GM_REMOTE_KEY`, default `~/.ssh/gm-win.key`). `tools/remote/gm-browser.ps1`
|
||
is copied over on every run and ties the browser to the SSH session – closing stdin kills it, however
|
||
the run ends; without that leash a dropped connection left eight orphaned `chrome.exe` behind.
|
||
|
||
Local CPU during a remote run is genuinely idle, but **the last remote attempt was not usable**: a
|
||
single CDP call cost ~5 ms at first, then the tunnel degraded until the suite crawled at ~25 s per
|
||
check. Since the GPU flags fixed the local cost, remote is a fallback that has not been made to work
|
||
end to end – treat it as unfinished, not as a supported path.
|
||
|
||
**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, textures, frames, overflow, aspect,
|
||
container tab) 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 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 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 |
|
||
| GET | `textures` | `[{file,bytes,updatedAt}]` |
|
||
| POST | `textures` | **raw image bytes** → `{file,width,height,bytes}` (201) |
|
||
| DELETE | `textures/{file}` | 204 |
|
||
|
||
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 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 · `Delete` remove selection · `Ctrl+G` create
|
||
container from the selection · `Ctrl+Enter` play · `Esc` stop · `Ctrl+V` import an image from
|
||
the clipboard as a texture (ignored while a text field has focus) · `Tab` cycles fields · 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), 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`-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 animation (nothing steps through a sheet's frames yet) and no
|
||
nine-slice. Slicing cuts a regular grid only — no detection of where the sprites actually
|
||
sit on the sheet. Unreferenced image files are never collected.
|