Sounds follow the road fonts already paved: the file goes to the server
content-addressed under its sha256, the project keeps a named asset that
points at it, and nothing is ever re-encoded. The format is read from the
file's own signature, not from what the browser claims - the same .m4a
arrives as audio/mp4, audio/x-m4a or as nothing at all.
Playing is two nodes. "Play sound" takes the asset and may raise or lower
its volume; every call is its own voice, so two jumps in a row are two
jumps heard. Its loop input is a choice of three - from the asset, on,
off - because a checkbox has to spend one of its two positions on "not
set" and would lose a case: an asset that loops could then never be
played once.
Below the graph core stays browser-free: NodeContext hands out an asset
id, BehaviorSystem resolves it against the project and folds the asset's
own volume and loop into the call, and only viewport/SoundLibrary knows
about Web Audio. A run without a sink plays nothing instead of throwing.
The CI browser is muted, so the ear is no witness: the debug hook counts
started and stopped voices, and step 34 reads that counter.
An upload sends its file name next to the raw bytes, and the server keeps
it in a sidecar next to the content-addressed file. Both pickers caption
every tile and row with it, and an asset made there is named after the
file instead of "Texture" / "Font". Last upload of the same content wins.
The API's own set_error_handler ignores error_reporting(), so @-silenced
warnings still throw: a missing sidecar took the whole listing down with a
500. Existence is checked instead of suppressed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A font asset holds a file and nothing else, the twin of a texture: the server keeps it
content-addressed under data/fonts/{sha256}.{ext}, with the format read from the file's own
four-byte signature (finfo reports fonts inconsistently across libmagic versions). A text
wears one through its ordinary Font property, so overrides, reset, undo and the Properties
node come for free; no font means the editor's own typeface.
The picker lists everything the server holds and renders each row in the face it offers.
The family name is gm-font-<hash16>, never the name inside the file, so two "Regular" cannot
collide. WorldViewport.syncText is the single place that draws it, which covers scene, game
preview and play at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The job image (tools/ci/Dockerfile, label game-maker-ci) carries chromium with the Vulkan
drivers, php-cli and the host's fonts; the render device itself comes from the runner's
container.options, so a job container gets the Intel GPU instead of llvmpipe.
tools/ci/serve.sh puts the checkout behind /game-maker/ with php -S and writes the project
shells the seeders need; every tool now takes its server from GAME_MAKER_BASE_URL rather than
from a project id repeated in shell scripts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tools/seed-actor.mjs builds the project Actor demo: it uploads
tools/assets/Actor1.png, slices the 576x384 sheet into the 96 cells of a
12x8 grid (48x48 each, no remainder) and wears the character in the top
left corner. Walking is four ping-pong animations of three frames, which
is the original RPG-Maker walk cycle; standing is the middle frame of the
row, not an animation.
The graph pastes walk- or idle- in front of the direction faced and wears
the result, so eight cases need no branch tree - the List node only holds
numbers. Which way it looks is the key pressed last, corrected whenever
only one axis is still held, or letting go of one key of a diagonal would
walk backwards. Axis input already normalizes its vector.
tools/lib/projects.mjs gains uploadTexture, since data/textures/ is
git-ignored and a seed that ships its picture has to send it. The harness
gains w/a/s/d and keyDown/keyUp halves, because holdKey cannot express two
presses that overlap - which is what a diagonal is.
tools/smoke-actor.mjs plays it in 19 checks and reads the drawn pixels at
the camera edge, where the bounds hold him walking on the spot: three
distinct frames walking, one standing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Tetris example project needs to draw 200 board cells and scan rows
for a full line. Neither is expressible without iteration, so the node
system gets a Repeat node plus the missing text side: Substring, Find
text and To number. The interpreter drops its data cache once per
round -- otherwise every round would recompute with the first round's
values.
Object by name takes its name as an input now, not just as a setting;
addressing "c" + index is impossible otherwise. An unconnected input
reads the stored setting, so saved projects are untouched.
The board itself is a 200-character text, "." for empty. That makes
both operations of Tetris a single expression instead of a loop, and
each frame attempts exactly one move -- rotate beats sideways beats
fall -- so the fit check exists once in the whole graph.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A run whose chromium died stopped mid-step and exited with code 0, silently
reporting success for checks it never ran: CDP.send() left its promise pending
when the socket closed, and an unsettled promise does not keep node alive.
Waiting on the server had the mirror problem – a bare fetch keeps its socket
open, so a stalled response hung the run forever.
Every wait can now time out, the socket rejects what it can no longer answer,
and reaching process exit without a summary reports RUN DIED with exit 1.
Chromium's stderr and exit signal are kept, so "killed by SIGKILL" is visible
rather than guessed at.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tooling:
- Prettier as the single formatting authority (110 columns, single quotes,
measured against the existing style); Markdown excluded because table-cell
padding turns the README glossary into 1000-character lines.
- Type-aware ESLint ending in eslint-config-prettier, so a rule always means
"this is a bug", never "this looks different".
- .editorconfig and an `npm run check` that runs typecheck, lint, format and
tests in one go.
Findings fixed on the first run: three more duplicate imports, four
String(unknown) calls (parameter types narrowed instead of casts), two dead
initialisers, two redundant casts, a console.log for a real error. Two
warnings were false here and are disabled with a reason in place.
Tests 132 -> 269:
- node-catalog.test.ts covers every node with a run(), with a guard that fails
when a new node has no row.
- behavior-events.test.ts covers the ten event nodes through a running World,
checking timing rather than existence.
- Input's state machine (press/release/releaseAll) is reachable without DOM
events now; the DOM handlers are thin adapters.
Compare fell back to == without a configured operator although the schema
default is <; aligned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>