All routes go through api/index.php?r=… (the site's nginx does not pass PATH_INFO).
| Method | Route | Body → Result |
|---|---|---|
| GET | auth/session |
{user, role} — the name and role (admin or user) of whoever is signed in, both null when nobody is. Always 200 |
| POST | auth/login |
{username,password} → {user, role} and the session cookie; 401 {error} for a wrong pair, 429 while the address is locked out (see below) |
| POST | auth/logout |
204, and the cookie is cleared |
| POST | auth/password |
{currentPassword,newPassword} → 204. Changes the password of whoever is signed in — the only auth route that needs a session. 401 for a wrong current password, 422 when the new one is shorter than 8 characters, 429 while the address is locked out. Every other session of the account ends; the one asking keeps its own |
| 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 {error,revision} when stale |
| DELETE | projects/{id} |
204, and the snapshots, the release and the demo copy of the project go with it. 403 {error} for an administrator who is not the owner |
| GET | projects/{id}/snapshots |
[{id,name,revision,createdAt}], newest first — see Concepts |
| POST | projects/{id}/snapshots |
{name?} → {id,name,revision,createdAt} (201). Copies the project as stored; a blank name becomes the UTC date and time. 422 over 120 characters |
| DELETE | projects/{id}/snapshots/{sid} |
204 |
| POST | projects/{id}/snapshots/{sid}/restore |
{revision} → {revision,updatedAt,before}. Keeps the stored project as the snapshot before (Before restoring <name>), then saves the snapshot as the next revision. 409 {error,revision} when revision is stale |
| GET | projects/{id}/release |
{token,revision,publishedAt}, or null while the project is not released — see Concepts |
| POST | projects/{id}/release |
→ {token,revision,publishedAt}. Copies the project as stored; publishing again replaces the copy and keeps the token |
| DELETE | projects/{id}/release |
204, the link is dead; 404 when the project was not released |
| GET | releases/{token} |
{name,publishedAt,project} — the released copy, without a session: the one route besides auth/* open to everybody. project.id is the token, not the project's id. 404 for an unknown or taken-down token |
| GET | projects/{id}/demo |
{token,revision,publishedAt}, or null while the project has no demo copy (#428). Administrators only |
| POST | projects/{id}/demo |
→ {token,revision,publishedAt}. Copies the project as stored, like a release; doing it again replaces the copy and keeps the token. Administrators only |
| DELETE | projects/{id}/demo |
204, guests no longer see the project; 404 when it had no demo copy. Administrators only |
| GET | demos |
[{token,name,publishedAt}], by name: the demo copies a guest picks from, without a session |
| GET | demos/{token} |
{name,publishedAt,project}, without a session, like releases/{token}. 404 for an unknown or removed copy |
| GET | textures |
[{file,name,width,height,bytes,updatedAt,owner,deletable,demo}] — what the image picker offers: the caller's own files and nobody's, an administrator's every file, and everything offered as demo material (see below). Without a session: the demo material alone, owner null |
| POST | textures&name=hero.png |
raw image bytes → {file,name,width,height,bytes} (201), stored in the caller's folder; 422 for bytes that are no readable image |
| DELETE | textures/{file} |
204. {file} is the stored name, folder and all. 403 unless it lies in the caller's folder or the caller is an administrator, 404 when there is no such file (asked only after the 403, so a stranger learns nothing of another folder), 409 while a project, snapshot, release or demo copy of any account uses it, and for anybody but an administrator while it is offered as demo material |
| GET | fonts |
[{file,name,bytes,updatedAt,owner,deletable}] — what the font picker offers |
| POST | fonts&name=Serif.ttf |
raw font bytes → {file,name,bytes} (201); 422 for bytes that are no font |
| DELETE | fonts/{file} |
204, 403, 409 like an image |
| GET | sounds |
[{file,name,bytes,updatedAt,owner,deletable}] — what the sound picker offers |
| POST | sounds&name=jump.wav |
raw sound bytes → {file,name,bytes} (201); 422 for bytes that are no sound |
| DELETE | sounds/{file} |
204, 403, 409 like an image |
| GET | videos |
[{file,name,status,…,original,bytes,updatedAt,owner,deletable}] — what the video picker offers. status is queued, converting (with progress, 0 to 1), failed (with error), unconverted (kept without converting it) or ready (with width, height, duration, fps, audio and poster, the .jpg beside the MP4). original is the upload as it came, {folder}/{sha256}.source.{ext} beside the MP4, null for a video uploaded before uploads were kept |
| POST | videos&name=holiday.mov[&convert=0] |
raw video bytes → the same record (201). The name {folder}/{sha256}.mp4 is the hash of the upload and known at once; the upload is kept as it came, and the worker converts it later (see Videos). With convert=0 it is only kept, unconverted. The same bytes again answer with what is there, a failed one is tried again. 422 for bytes that cannot be a video (the start of an ISO/MP4, Matroska, AVI or MPEG-TS file), 413 above 2 GB. Unlike every other upload it is streamed to disk, so the 256 MB ceiling of the other routes does not apply |
| POST | videos/{file}/convert |
→ the record: an unconverted upload is queued, a failed one is tried again, anything else answers as it is. 403 for somebody else's, 404 for no such video |
| DELETE | videos/{file} |
204, 403, 409 like an image; also for one that waits, failed or is only kept. The upload as it came goes with it |
| PATCH | textures/{file}, fonts/{file}, sounds/{file}, videos/{file} |
{demo} → 204: offers the file to everybody as demo material, or takes that back (#428). 404 for no such file, 422 without a boolean demo. Administrators only |
| POST | pixels |
raw cel bytes → {file,bytes} (201) — the pixels of one layer on one frame of pixel art; 422 for anything that is not a cel. No listing and no delete: a cel is found by the name in the project |
| GET | plugins |
[{file,bytes,updatedAt}] — every .js in data/plugins/, sorted by file name. Also without a session: a guest's demo may switch one on |
| GET | plugins/{file} |
the module source as text/javascript (nosniff, no-cache); 404 for anything but a bare file name there — see Plugins |
| GET | agent-tokens |
[{id,name,createdAt,expiresAt,usedAt}], oldest first: the caller's agent tokens (#429), never a token itself. usedAt is null until an agent used it |
| POST | agent-tokens |
{name, days?} → {id,name,createdAt,expiresAt,usedAt,token} (201). The one answer the token fwa_… is ever in. days is 1 to 365, 90 without. 422 for a name over 60 characters or a lifetime out of range, 409 for a 21st token |
| DELETE | agent-tokens/{id} |
204, the token stops working at the agent's next request; 404 for none of the caller's |
| POST | agent/ticket |
→ {ticket, expiresIn}: a ticket for the caller's tab to get onto the MCP relay with, good for 60 seconds and once |
| POST | agent/verify |
{token, address} → {user, role}; 401 for an unknown, expired or revoked token, 429 while address is locked out. The relay's own: without its key the route answers 404 |
| POST | agent/redeem |
{ticket} → {user, role}; 401 for an unknown, used or expired ticket. The relay's own, like agent/verify |
| GET | users |
[{username,role,createdAt,projects:[{id,name,revision,updatedAt}]}] — every account with its projects, never a password. Administrators only |
| POST | users |
{username,password,role} → {username,role,createdAt} (201). 409 for a taken name, 422 for a bad name, a password under 8 characters or an unknown role. Administrators only |
| PATCH | users/{name} |
{username?,role?,password?} → {username,role,createdAt}. Only what is sent changes, and everything is checked before anything is written. A new name moves the account's projects and signs it out everywhere; a new password ends its sessions (the caller's own stays). 404 unknown account, 409 taken name, 422 for the caller's own name or role and for demoting the last administrator. Administrators only |
| DELETE | users/{name} |
204, and the account is signed out everywhere. 409 while it owns projects, 422 for the caller's own account and the last administrator. Administrators only |
Everything but auth/* needs a session and answers 401 {"error":"Sign in first"} without
one. The exceptions only read: a released game and the demo copies (releases/…, demos,
demos/…), the plugins, and the four file lists, which then hold the demo material alone. Projects belong to an account: projects lists only the caller's own, and somebody else's
project answers 404, not 403 — that an id exists is already more than a stranger should learn.
An administrator (#180, #179) reads and saves every project with the same routes, and a save
keeps the project's owner; their projects list stays their own. Deleting a project stays with
its owner (#272): an administrator gets 403 {"error":"Only its owner may delete a project"},
also for a file without an owner. Snapshots and the release of the project are theirs to manage
all the same. The users routes answer anybody but an administrator 403
{"error":"Administrators only"}. Cels and plugins are shared between accounts; uploading a cel
still takes a session. A guest has no agent tokens and gets no ticket. See Accounts.
What the server refuses (#427)
- Wrong passwords: five failed sign-ins (or wrong current passwords) from one address within
15 minutes, and that address gets 429
{"error":"Too many failed sign-ins. Try again in N minutes"}withRetry-Afteruntil 15 minutes after the last of them. The right password waits as well, and a successful sign-in does not reset the count, or an account of one's own would be the way to do that. The address is the one nginx takes from the last entry ofX-Forwarded-For, which the Brandmauer appends; anything the client wrote in front of it is ignored. The failures lie indata/sign-ins/(api/lib/LoginThrottle.php). - Space: an account takes up at most 1 GB with its uploads, its projects and their
snapshots (
api/lib/Quota.php). An upload, a save, a new project or snapshot or a restore that would make it larger is refused with 413{"error":"Not enough space: this account uses … of its 1024 MB"}. A write that makes nothing larger always goes through, so a full account can still save a smaller project or delete files. Administrators have no limit. Cels belong to nobody and are left out. - Other sites: any request but GET is refused with 403 when the browser says it comes from
another site (
Sec-Fetch-Site: cross-siteorsame-site). The session cookie isSameSite=Lax, and to that every subdomain of the site is the same site. Tools send no such header. - The cookie carries
Securewhenever the request reached the Brandmauer over HTTPS, along withHttpOnlyandSameSite=Lax. It has noPath, so the browser keeps it for the folder of the API as it sees it:/feuerwerk/apionnockal.com,/apionfeuerwerk.nockal.com. A cookie from before, set for all of/feuerwerk/, is ended with every sign-in and sign-out.
tests/php/rights.test.php runs the API under php -S on a scratch directory and asks every route
as somebody signed out, as the owner, as another account and as an administrator. It also reads the
route list at the top of api/index.php and fails for a route the matrix does not ask about, so a
new route has to be put there with what it expects.
Agent tokens and the MCP relay (#429)
An agent such as Claude Code works in the editor through the MCP relay, which passes its tool calls to the user's open tab (see MCP). The API decides who is who for it; the relay keeps no accounts.
- An agent token (
fwa_and 64 hex digits) is kept asdata/agent-tokens/{sha256}.json(api/lib/AgentTokens.php, 0640), like a session: the file cannot be replayed as the token. It opens the relay and nothing else, no route of the API takes it. It ends at its expiry, when its owner revokes it, and with every login of the account when the password changes (an administrator's reset included) or the account is renamed or deleted. - A ticket is how a tab gets onto the relay. The session cookie belongs to the folder of the
API and never reaches the relay's path, so the tab asks
agent/ticketwith it and hands the ticket over on its WebSocket. It lies indata/agent-tickets/for a minute and is taken once. agent/verifyandagent/redeemare the relay's. It sends the key inX-Relay-Keythat lies indata/mcp-relay.keyfor both sides; without it the two routes are not there. A wrong token counts as a wrong password to the brake above, against the agent's address, which the relay passes along.
Demo copies and demo material (#428)
A guest tries the editor without an account (see Accounts), and the server writes nothing for them: there is no guest session, no guest folder and no quota.
- A demo copy is a frozen copy of a project in
data/demos/, stored exactly like a release (ReleaseStoreon a directory of its own, 0640). An administrator makes, updates and removes it from the project's main menu. The project's own id stays hidden: the copy goes out under its token. - Demo material is a list in
data/demo-files.json(api/lib/DemoFiles.php, 0640) of uploaded files an administrator offers to everybody. The files stay in their owner's folder. Every list carries them withdemo: true, for accounts as well as guests. While a file is offered, only an administrator deletes it, since any account may have put it into a project by then. - A file a demo copy names counts as used (
FileUsage), like one a release names.
Uploaded files belong to an account (#344)
Every account uploads images, fonts, sounds and videos into a folder of its own, and the folder is part
of the name the project stores: {folder}/{sha256}.{ext}, for example
data/sounds/a590cab0f8b7/f1f2…f558.wav. The folder is a random 12-character name kept as
folder in users.json, given with the first upload. It is not the account name, so renaming an
account moves no file and rewrites no project, and the URL of a released game's files says nothing
about whose they are. Two accounts uploading the same bytes have a copy each.
A name without a folder is a file that belongs to nobody — what was uploaded before #344 and is used by no project.
- Listing: an account gets its own files and nobody's; an administrator every file.
owneris the account a file belongs to,nullfor nobody and""for an account that was deleted.deletablesays whether the caller may try. - Deleting: a file in the caller's folder, or any file for an administrator — and only while no
project, snapshot or release of any account names it (
api/lib/FileUsage.php, any string in the document equal to the stored name counts). The 409 says who uses it: the caller's own projects by name, somebody else's only as "a project of another account" (an administrator sees the name and the owner). - A deleted account's folder stays, and only administrators see it.
- The files themselves are still served without a session — a released game loads them that way. The owner decides what the picker offers and who deletes, not who may load a file whose URL they know.
The files that existed when this came in were handed out once by tools/migrate-file-owners.php
(2026-10-07): a file a project uses moved into the folder of the project's owner (a copy each when
two accounts used it), and the projects, snapshots and releases were rewritten to the new names.
A project's revision went up with it, so an editor that had it open was told it changed elsewhere
instead of saving the old names back.
Which editor plugins a project switches on is the project's own field plugins (#119); the server
has no switch of its own and hands out every plugin file it has.
The named collision layers are the project's field collisionLayers (#152), a list of up to 16
names of which the first one is layer 0. A project file without it has the single layer Default,
which is what every collider is on, so the field needs no format bump either. The render layers
(#302) are the field renderLayers, read by the same rules; an object names one in renderLayer
(-1 for Inherit), a camera the ones it shows in the mask sees and whether it is an overlay in
overlay.
Every project document carries formatVersion (PROJECT_FORMAT_VERSION in core/model/Project.ts,
currently 2). There are no load-time migrations: serialize.ts reads what it recognises and drops
the rest.
Uploads send the bytes as the body rather than multipart/form-data: no boundary parsing in
PHP, and PHP's upload_max_filesize never applies — only post_max_size. nginx and the
Brandmauer let 2 GB through, and the API has its own caps behind them (413): 256 MB per request,
4 MB per image or font, 256 MB per sound, 2 GB per video, which alone is streamed to disk. The
quota above comes on top. The stored files are served statically from
data/textures/, data/fonts/, data/sounds/ (each in its owner's folder) and data/pixels/, so reading one never runs PHP. An image is
downscaled and re-encoded in the browser when it would not fit; a font or a sound cannot be, so
an oversized one is refused rather than shrunk.
The format of a font or a sound is read from the file's own signature, never from the
Content-Type of the request: browsers name audio inconsistently (audio/mp4, audio/x-m4a,
nothing at all for the same .m4a). MP3 is the one with no single signature — either an ID3
tag or a frame header of eleven set bits, which is what SoundStore::mp3Extension demands.
A cel (#130) is GMPX, a version byte (1), a reserved byte, width and height as little-endian
16-bit numbers, then one palette index per pixel, row by row; 0 is transparent, a side is 1 to 512
pixels. It is stored as data/pixels/{sha256}.px. The editor works the same name out in the browser
before it uploads, so a stroke can name its cel in the project at once; the name the server answers
with is the check that both hashed the same bytes. A pixel art asset in the project document holds
width, height, layers ({id,name,opacity,hidden?}) and frames ({id,cels}, cels maps a
layer id to a cel file), a palette asset colors (0xRRGGBB, index 1 first).
name is the original file name, kept beside the stored file as {file}.json and returned
with it. The stored file is always {sha256}.{ext}, so the name is a caption and never a path:
the server strips directories, control characters and anything not valid UTF-8, and caps it at
120 characters. Uploading the same bytes again under a different name renames what the
pickers show; an upload that carries no name leaves the known one alone rather than erasing it.
A file uploaded before names were kept simply has "", and reads as it always did.
What the web server hands out as files is only images, fonts, sounds, videos (with their
posters and the uploads as they came) and cels, matched by folder, hash and ending; their name
files, the video details and the jobs in data/videos/queue/ are not. Everything else under
data/ answers 404, see Development. On top of that, projects,
snapshots, releases, accounts, sessions and failed sign-ins are written readable for PHP alone
(0640, AtomicFile::HIDDEN), so a mistake in the nginx list alone would still not hand them out.
Every file the API writes goes through AtomicFile: a temp file beside it, its mode, then a
rename, so no reader sees half a file or a file with the wrong mode. Saves of one project take turns
on a lock file beside it ({id}.json.lock), which stays when the save is done.
Using the editor
- Concepts
- Accounts
- Agents (MCP)
- Scenes
- Scene editing
- Behaviors
- Structs
- Colliders
- Physics
- Textures
- Vector art
- Pixel art
- Fonts
- Videos
- Sounds
- Instruments
- Patterns
- Songs
- Animations
- Curves
- Tiles
- Tilemaps
- State machines
- Shaders
- Particles
- Containers
- Compositions
- Keyboard
Working on the code