Platform
How a game goes from a creator's draft to a room people play in (Technical specs §7 and §9). Four processes: the platform API (apps/api), the generator worker (apps/worker, Blender in the background; optional, the API can run it itself), the game servers (apps/server, each running rooms) and the client (desktop app or browser). Creators drive the API through the MCP server in Claude Code (Creating games); any HTTP client does too.
Running it locally
npm run dev:platform # game server on 7777 (rooms on), API on 8080, web client on 5173, a creator runnerNothing else is needed: the API keeps its drafts and artifacts under .foi-data/ with development accounts and no moderation, and the game server and the API share a development room secret, and when Blender is installed the API generates spec drafts' levels itself. The web client reaches the API on its own origin (/api, proxied by Vite); the legend's Games tab lists the published games and their rooms, and links to the game pages (http://localhost:5173/?browse). The production set-up, on Nakama, Postgres and MinIO behind one origin, is the Compose stack: see Deploying.
From a draft to a room
- Sign in.
POST /auth/login {email, password}(a creator) orPOST /auth/guest {deviceId}(a player), or through Steam (below); the answer'stokengoes inAuthorization: Bearer <token>. Claude Code signs a creator in with OAuth instead (below), whose access tokens are sessions too. Any page may call the API (CORS, every origin: the sessions are bearer tokens, never cookies), andGET /infosays what the platform is before a client plays on it: its release, its protocol version and the WebSocket address of its cities (game), which the desktop app's Play online reads. - Start a draft on a bundled level,
POST /drafts {title, level}, or on a level generated from a level spec,POST /drafts {title, spec}(see below).GET /levelslists the bundled levels;GET /levels/:namegives a level's roads, spawns and the places and zones of its bundled game, to start from. - Write the game:
PUT /drafts/:id/game {game}with a game document (Missions;GET /schemashas its JSON Schema). Its id and level stay the draft's. A document that does not match the schema is refused with its issues. - Build:
POST /drafts/:id/buildqueues a build (the build queue: it waits its turn, and outlives a restart of the API);GET /builds/:idsays how it went, and its place in the queue (position, 1 the next to start) while it waits. A build generates a spec draft's level, checks the game against its level and the vehicle classes (validateGame) and, without errors, stores the artifact (the level, the game and the manifest of what built it: below) under the hash of its JSON, immutable (GET /artifacts/:hashis cached for a year). The build keeps the ids of the places the game can use (places), and so doesGET /drafts/:id(the last build's). - Playtest:
POST /drafts/:id/playtestopens a private room on the last good build and answers with a URL that joins it; with{mission}, a playtest run of that one mission in a room of its own (below), its minimum of players relaxed so its creator can try it alone. - Publish:
POST /drafts/:id/publish {visibility: "link" | "public"}makes the last good build a new version of the game. A public game's texts are moderated first; a game shared by link is not. A mission that asks for a Respect tier above 2 is published with a warning (warnings): until it qualifies it asks for tier 2 at most (Respect). - Play:
POST /games/:id/roomsopens a room of a game's latest version;GET /games/:id/roomslists its open rooms andPOST /rooms/:id/joingives a URL that joins one. The client joins by loading itself against that URL (?server=…). A mission is played in a run of its own (POST /games/:id/missions/:mission/runs, below). - Rate, report, remix: players find the game on its page (below), rate it, report it, and creators remix it into a draft of their own.
- Play together: friends, parties that land in a room together, the chat and the griefing controls are the Social layer (
/social/…); lobbies and Quick play gather a multiplayer mission's players, and every settled run pays its players Respect (below).
The build queue and the daily limits
Builds are jobs of a durable queue (apps/api/src/queue.ts), kept in the store beside everything else (the jobs table in Postgres, the JSON file locally): POST /drafts/:id/build saves the build and its job, a snapshot of the game and the level spec as they are (a draft keeps no old revisions; editing it while the build waits changes nothing of the build), before it answers 202.
- Who runs them. Each API process runs
FOI_BUILD_CONCURRENCYbuilds at once (1 by default; 0 runs none, holding every build in the queue for another process or a restart). A process claims the next job in line atomically (Postgres'sFOR UPDATE SKIP LOCKED: two processes never take one job) and holds it with a lease (60 s), renewed every 20 s while the build runs. - The order. The highest priority first, then the owner who had the fewest builds started that day (UTC), then the oldest: one creator's ten builds take turns with another's one.
GET /builds/:idand Your games say each waiting build's place (position), the line played out as the claims will take it. - A restart. A build waiting when the API stops is built when it comes back (every process looks for work every 2 s and at start). A build running when its process went away is queued again once its lease runs out, and fails after its third try (
its process stopped before it finished, 3 times); a build is the same artifact however often it runs. - Creations. The same queue runs the creations of the platform's own model (
createjobs, one running per owner, claimed by the creator runners: below).
Every account has daily limits: builds and rooms a day (FOI_QUOTA_BUILDS, FOI_QUOTA_ROOMS: 100 and 50), and creations a day (FOI_QUOTA_CREATIONS, 5: spent when a brief is queued, below). They are counted in the store per account, quota and day (UTC), so a restart forgets nothing, and spent in one step (Postgres: insert … on conflict do update … where used < limit), so two requests at once never both take the last one; past a limit the API answers 429. GET /me gives the account's limits, what it used today (usage) and what is left (quotas).
Admins (the accounts FOI_ADMINS names, a list kept apart from the moderators', as account ids) set an account's limits one by one: higher or lower than the defaults (0 blocks), until a date, and whether a test account is exempt from the day's budget of the platform's model. An override's limits replace the defaults while it holds; one whose date has passed leaves the defaults. GET /me answers admin: true for them; anyone else gets 403 from these routes, nobody signed in 401:
| Route | What |
|---|---|
GET /admin/accounts?q= | The accounts the API has seen (drafts' owners, quotas' spenders, accounts with an override) whose id or name holds the words, 20 at most, each with its limits now (limits), the defaults, today's usage and its override (expired once its date passed); an id typed in whole the API has not seen comes first. |
GET /admin/accounts/:id/limits | One account, the same way. |
PUT /admin/accounts/:id/limits | Sets its override, replacing the one it had: {builds?, rooms?, creations?, budgetExempt?, until?, note?}, the limits whole numbers from 0, until a date (through that day, UTC) or a time to come, the note 200 characters at most; at least one limit or the exemption. |
DELETE /admin/accounts/:id/limits {note?} | Takes the override away: the defaults again. |
GET /admin/limits | Every override, the latest set first. |
GET /admin/audit?limit= | The changes of limits, newest first (100, at most 500). |
Every change goes into the audit log beside the moderators' decisions (action: "limits": who, when, the account, its override before and after, the note), which the moderators read too (GET /moderation/audit). The admin page (?admin, apps/client/src/ui/AdminPage.tsx; linked from the game pages' account bar and the main menu's Admin for an admin only) does all of it: an account found by its id or name, its limits beside the defaults and today's use, the override's form with Save and Reset, every override and the changes. From a terminal: npm run admin:limits (Operations).
Creations: the platform's own model
Besides Claude Code, an owner can have the platform make a game from a brief written in the game (Creating games, Technical specs §9.8). Off by default (FOI_CREATOR=off: /jobs answers 503, GET /me says creation: {available: false, reason: "off"}, nothing else changes); fake runs the scripted model of apps/creator (no cost: the tests, a stack tried out), openrouter a model on OpenRouter and claude Claude on Anthropic's API, each with the platform's own key, never a player's.
- A creation is a job of the same durable queue as the builds (
kind: "create",apps/api/src/creations.ts):POST /jobs {brief, where?, size?, missions?, draft?}queues one (a brief of 600 characters at most; on Riverbend, a new town or a region; small, medium or large; 1 to 3 missions;draftrevises one of the owner's drafts),GET /jobsandGET /jobs/:idsay where each stands (its place in line,position, and why it waits:waiting: "budget"or"yours", the owner's other creation running; itsstepwhile it runs; its result: the draft so far, its missions, its last build with its preview and artifact, a region's or a city's level in text),POST /jobs/:id/cancelcancels it. An owner has one creation running at a time (the store's claim, a unique index in Postgres). - Who creates. The owner's routes (
POST /jobs,GET /jobs,GET /jobs/:id, cancel) ask thecreatorlevel with an entitlement at most an hour old (need(…, 'creator', {freshMs: 3_600_000}), Steam): withFOI_ENTITLEMENT=openany account but a guest; withsteaman owner of a copy of their own for good, not banned (a lent copy is refused,code: "shared").GET /mesays it twice, alike:access.create, andcreation.available(false withreason: "account"and the refusal'scode, oroff, orbudget). A creation's session is its owner's account, so its own routes pass the same level. - The allowance.
FOI_QUOTA_CREATIONSa day (5; an admin's override first), the day in UTC. A creation is spent when the brief is queued; given back when it is cancelled before it starts, or when it fails through no fault of the brief (its runner went away and its lease ran out, the model's provider refused to bill); kept when a running creation is cancelled.GET /megivescreations: {limit, used, left, resetsAt}. Revise is a new creation on the same draft, and costs one. - The budget (
apps/api/src/budget.ts). A hard cap a month (FOI_CREATOR_MONTHLY_USD, 25 dollars), spread over the days left: each day's budget is what was left of the cap at the day's start over the days left, today included, so it stays the same all day. A creation reserves its cap ($0.10) when a runner claims it, and settles at what it spent when it ends (the ledger:creator_ledgerin Postgres, a line a creation). While today's spend and reservations reach the day's budget, briefs are refused (429,reason: "budget", "creation resumes in N h": the next day, or the next month once the cap is spent) and no creation is spent; a queued creation starts only while the budget covers one more cap, else it says it waits for budget. An account an admin marked budget-exempt skips both, its caps per creation still holding. - The runners (
apps/creator) claim creations over private routes behindFOI_CREATOR_SECRET, straight to the API on its own network (never through the proxy, which refuses/api/creator/…):POST /creator/claim(a long poll of 20 s),POST /creator/jobs/:id/heartbeat {attempt, step, spent}(renews the 30 s lease; answers whether to stop) andPOST /creator/jobs/:id/finish {attempt, state, cost, summary?, error?, system?, transcript?}. A lease that runs out fails the creation and gives it back (the API sweeps every 5 s). Builds are claimed by the API's build executor alone and creations by the runners alone, and a creation's builds go ahead of the line (priority 1, the creation their parent, six at most, not the owner's builds a day), so a creation never waits behind itself. - The creation's own session, deny by default. A claimed creation gets a session of its owner's for 20 minutes, its grant scoped
job:<id>(grant-job-<id>). It reachesGET /me, its one draft (POST /draftsmakes it, under the id its job keeps;GET,PUT …/spec,PUT …/game,POST …/buildtake that draft only), that draft's builds, the catalogue, the levels and the schemas, and is refused (403) everything else before any route runs: publishing, playtests, rooms, remixes, ratings, reports, the social routes, the Steam sign-in and link routes,/play,/jobs,/admin, another draft. A brief that talks the model into anything else gets nowhere. Cancel revokes the grant: the runner's next call is refused (401) and its next heartbeat says stop. - What the owner hears. A creation's end reaches its owner's feed (
kind: "creation"; not their own cancel), which the game shows as a toast that opens the result. - Moderated twice (Moderation). A brief is checked after the budget and before its creation is spent: refused,
POST /jobsanswers 422 (reason: "moderation", the reasons) and nothing is spent or queued. What the model wrote (the draft's texts, its level spec's, its last words) is checked when the creation ends, finished, cancelled or its runner gone; until then, with moderation on, its draft is shown to nobody but its own session (not inGET /drafts, 409 atGET /drafts/:id, no result inGET /jobs/:id). Refused, the creation fails with why and nothing of it is shown: a draft it made is withheld (404), one it revised is put back as it was a revision on, its builds are withheld, and its creation stays spent. A draft the platform's model wrote (byModel) is moderated when it is published by link too. - Transcripts. The runner's conversation comes with its finish (
transcript: {model, messages}) and is kept 30 days for the moderators and the admins (GET /moderation/creations/:id/transcript,creator_transcriptsin Postgres), then forgotten (every hour,purgeTranscriptsEvery). - On OpenRouter (
FOI_CREATOR=openrouter,apps/creator/src/openrouter.ts): the runner reads its models' prices from OpenRouter's models endpoint at start (the dearest ofFOI_CREATOR_MODELSand their fallbacks) and bounds every call before it is made by what is left of the creation's $0.10, cutting its answer shorter or stopping before a call that could pass it; each answer's cost reaches the ledger through the heartbeats and the finish. A provider out of credit (402) or down fails the creation as no fault of the brief: it is given back. The key's own monthly limit, a little under the cap, is the backstop (deploy/openrouter-key.mjs); the model is chosen by the evals (npm run creator:eval, apps/creator). - On Claude (
FOI_CREATOR=claude,apps/creator/src/claude.ts): the platform'sANTHROPIC_API_KEY(the moderation's too, withFOI_MODERATION=chain), the modelsFOI_CREATOR_MODELSnames (Anthropic's ids,claude-haiku-5-5by default, the others its fallbacks). The runner checks at start that Anthropic takes the key and has the models, and works each call's cost out from the tokens Anthropic counts at the model's list price (the cache's writes and reads included; a model the list does not know needsFOI_CREATOR_PRICE); it bounds every call the same way, at the dearest rate a token could cost. The prefix (the tools and the creator card) and the conversation so far are cached, so a turn pays a tenth or less for what it read before. Anthropic does not keep the API's texts to train its models. A workspace of the platform's own in the Claude Console, with a spend limit a little over the cap, is the backstop (Operations). - Metrics (
GET /metrics):foi_creator_cost_usd_totalby what for (creations, the moderation classifier),foi_creator_jobs_totalby how they went,foi_creator_refused_totalby why,foi_creator_open_jobs, and the budget (foi_creator_budget_usd: the month's cap and spend, today's budget, spend and reservations), which the stack's alert at 80 % of the month and its Creation dashboard read (deploy/monitoring).
Admins see the platform's creations and the budget: GET /admin/creations (every creation waiting or running, whoever's, with its owner and what it spent, then the last twenty over; the budget: the month to date, today's budget, spent and reserved, open, resumesAt) and POST /admin/jobs/:id/cancel (anyone's; its owner hears of it). The admin page shows both, with Cancel.
OAuth: signing Claude Code in
The API is also the OAuth 2.1 authorization server of the MCP server (apps/api/src/oauth.ts; Technical specs §9.6), so a creator adds the MCP server to Claude Code by its address alone and signs in on the API's page (Creating games). Its issuer is the platform's public address, FOI_PUBLIC_URL (the API's FOI_API_URL without one), and its routes are at that address's root, which the stack's proxy sends to the API unprefixed:
| Route | What |
|---|---|
GET /.well-known/oauth-authorization-server | The metadata (RFC 8414): the issuer, the endpoints below, code with PKCE S256 only, public clients (none), the issuer in the answer (RFC 9207). |
POST /oauth/register | Dynamic client registration (RFC 7591): a public client (no secret, whatever it asks for), its redirect URIs https or http on a loopback address (localhost, 127.0.0.1, [::1]: Claude Code's own callback), 20 an hour per address. |
GET /oauth/authorize | The sign-in page: the client's name, the creator's e-mail and password, signed in as POST /auth/login does (the dev accounts, or Nakama's; ten tries a quarter of an hour per e-mail). A broken link (an unknown client, a redirect URI it never registered) stops on the page. |
POST /oauth/authorize | The page's forms: the sign-in, then the consent ("Claude Code wants to make games as you", Allow or Deny). Allowing sends the browser back with a code (ten minutes, single use) and the state; denying, or a request without PKCE S256 (plain is refused), with an error. |
POST /oauth/token | authorization_code with its code_verifier (and the redirect_uri the request named), or refresh_token: an access token (an hour) and a new refresh token (30 days; the one given is spent). A code or a refresh token used twice revokes everything it gave. |
POST /oauth/revoke | Revocation (RFC 7009): an access or a refresh token ends its whole grant at once; an unknown token, or another client's, is answered 200 all the same. |
An access token is the API's own session for the account (with Nakama, signed with the shared session key in Nakama's claims), so every route takes it as it takes a session from POST /auth/login; it names its grant, which the API looks up on every request, so a revoked grant's tokens stop at once. The store keeps the clients, the codes, the grants and the refresh tokens, the codes and tokens by the SHA-256 of their values (memory, the JSON file, or Postgres's oauth_* tables), and forgets the expired ones every hour. The MCP server is the protected resource (apps/mcp/src/http.ts): its metadata (/.well-known/oauth-protected-resource/mcp) names this issuer, and its 401s say where that metadata is.
Published games: pages, ratings, reports, remixes
Every published game has a page in the web client, on the same origin as the game and the API (Technical specs §8.3): /?browse is the page of games (a search box; sorted by new, top rated or featured; a card per game with its preview, its title, its creator, its rating and its blurb) and /?game=<id> a game's page (its preview, blurb and missions, the game it remixes; Play, and Join on its open rooms; stars to rate it; a reason to report it; Remix). The legend's Games tab links to it. The pages are the same client: an address with browse or game shows them instead of starting the game (apps/client/src/ui/BrowsePage.tsx), and joining a room reloads the page against it, as from the Games tab.
| Route | Who | What |
|---|---|---|
GET /games?q=words&sort=new|top|featured | anyone | The public games (and the caller's own): every word found in the title, blurb, creator or level; new newest first, top by their stars (the mean pulled toward three by two imaginary votes, so one five does not beat many fours; unrated games last), featured the moderators' picks first. Each game carries rating {average, count}, featured, preview (whether it has one) and remixOf. |
GET /games/:id | anyone | A game's page: the game, its missions ({id, title, blurb, play: {mode, players}}), own (the caller made it) and mine (the caller's stars, or null); a curated game (a level that ships, by its name) is by Five On It, curated, unrated. |
GET /games/:id/versions | anyone | Its versions, oldest first. |
GET /games/:id/preview.png | anyone | The latest version's preview: image/png, cache-control: public, max-age=300 and an ETag (a request with If-None-Match gets 304). |
GET /previews/<hash>.png | anyone | A preview by its hash, cached for good (immutable): the one a build names. |
PUT /games/:id/rating {stars} | signed in | 1 to 5 stars, one rating per account and game (rating again replaces it), guests too; a creator rating their own game gets 403. Answers the game's rating and the caller's mine. |
POST /games/:id/reports {reason, text?} | signed in | A report for the moderators: reason is offensive, real-people, sexual, broken, spam or other, text up to 500 characters. |
POST /games/:id/remix | creator | A new draft of the caller's with the latest version's game document and, for a generated level, its level spec (renamed after the new draft); remixOf {game, version, title, by} credits the original on the draft and on every version it publishes ("Remix of title by creator" on its page). Any public game can be remixed, and one's own. |
Runs
A run (apps/api/src/runs.ts, Missions) is one play of one mission in a room of its own, for a roster of seats: the caller alone for a solo mission, the caller's party for a co-op one (its leader asks). The game server it is opened on gets the run with the room (RoomOpenRequest.run: the run, the mission, the seats and teams, where the result goes and the token it goes with; a curated game's on the level itself, bundled), takes the roster alone, and reports how the run ended with that token, which the API keeps as its SHA-256 only. Every seat's join token names the run, the seat and the team; the party's other members get theirs in their feed (room).
| Route | Who | What |
|---|---|---|
POST /games/:id/missions/:mission/runs | signed in | Opens a run of a published or a curated game's mission: {run, room, joinUrl} (201); 409 with why when the caller's party is outside the mission's players, the caller does not lead it, or one of them has a seat in an open run (run names it); it spends one of the day's rooms, given back on a 503. |
GET /runs/:id | its players, the moderators, admins | The run: its game, mission, room, state (open, complete, failed, abandoned, lost), seats (each one's credit, downs and early leaving once the result is in) and result (outcome, seconds). |
POST /runs/:id/rejoin | its players | A fresh join URL to the caller's seat while the run is open (409 once it is over). |
POST /runs/:id/result | its game server, the run's token | How it ended (@foi/protocol's RunResult): taken once (200), the same body again 200, a different one 409, seats that are not the roster 422 (the seats filled under way matched by account), a wrong token 401. |
An account has a seat in one open run at most, so an owner has one open run at most (they sit in every run they open): a second is refused until the first ends (its game server ends a seat's player's part a minute after they leave). The game servers cap their own rooms (their players, FOI_CAPACITY) and their runs apart (FOI_MAX_RUNS, 2 by default: every run's room is a whole level's world, Performance); the fleet passes a server at its cap of runs by, and when none has room the run is refused with the city busy, to try again in a minute (an ordinary room is placed as ever). A party's run opened by that route puts the party on a versus mission's teams in turn (the leader on the first); a lobby's run seats each member on the team they picked (below), through the same roster's checks (RunRoutes.openRoster: the mission's players and each team's, a free-for-all's seat i on p<i+1>). A run's room goes only to a game server whose release is known (the one it registered with, else its /health's) and of the API's release line or newer: an older one knows no runs.
The sweep (every minute) marks an open run lost, counting for nothing, only when its room is out of sight: never while its game server lists it, whatever the time; once its server answered without it on two sweeps in a row; and while its server does not answer (restarting, a blip), only once past the run's own time: its mission's attempts (each the time limits of its objectives added up, attemptSeconds, or an hour when one has no clock, times its retries and one) and twenty minutes. The game server tries its report again for up to five minutes, its room held open meanwhile. Runs and their rosters are kept in every store (runs and run_players in Postgres).
Seats filled under way. A live run whose mission takes players under way (joinInProgress: untilCheckpoint until its first checkpoint, or always: Missions) says its open seats in its room's status (RoomStatus.open: each team's seats it may fill and the players it holds). Quick play fills one (below) through RunRoutes.fill, one at a time per run: the mission's Respect gate and the lobby's kicks asked again (403), the seat kept in the run's roster in the store first (Store.addRunPlayer, refused once the run is over or for an account on it already: a unique run and account in Postgres), then asked of the run's game server (POST /rooms/:id/seats with the room secret and a RunSeatSpec, the player's Respect tier on it: 201 {seat}, its index at the end of the roster, 409 with why, 400 or 404; 5 s at most, SEAT_TIMEOUT_MS), and the player's join URL to it. A definite refusal (the server's 4xx, or no server) takes the store's row back (Store.removeRunPlayer, 409); no answer keeps it (503), the seat counting as the player's only while the room's status lists it (RoomStatus.late), so a seat the server never made frees them at the next status. Refused (409) to an account seated in another open run or on this one's roster already (a player who left a run is never seated in it again). The run's result reports every seat, the late ones and those they took the place of, each with its span (joinedS, leftS): its seats from the start must be the roster as opened, in its order; those filled under way are matched by account (reconcileResult), one the store never kept ignored and logged, never paid, one the store kept and the result lacks counted as never come in.
Lobbies and Quick play
A lobby (apps/api/src/lobbies.ts, Social) gathers the players of one multiplayer mission of a game (a published one, or a curated one), public or private (by its link), led by its host, in the API's process (in memory, as the parties are; its runs and their results are the store's). It gathers, checks who is ready once every team has its minimum, counts down 10 s with the run's room opened at the start (so its world warms meanwhile), sends every seated member their join URL in their feed (run) when the countdown ends, waits for the run's result (or the sweep finding it lost), shows the results and holds a 20 s vote on the next mission, then gathers again on the mission picked, on any level. Every change reaches the members through their feed (lobby). Quick play (apps/api/src/matchmaking.ts) places a player by a playlist, and the missions' index (apps/api/src/missions-index.ts) lists every listed game's missions with their public lobbies.
| Route | Who | What |
|---|---|---|
POST /lobbies {game, mission, visibility, playlist?} | signed in | Opens a lobby for a co-op or versus mission, the caller its host and their party in it when they lead one: the lobby (@foi/protocol's LobbyView, 201). A solo mission is refused (409: its Play opens its run at once); a game shared by link always opens a private lobby. Opened from a playlist (playlist, the Play together screen's), it remembers it: refused (409) when the playlist has not the mission. |
GET /lobbies?game=&mission=&mode= | signed in | The public lobbies (LobbySummary): the mission, its game and level, the players and the most, the state, each team's count; those gathering first, then the fullest. |
GET /lobbies/mine | signed in | The caller's lobby (lobby, or null) and the lobbies friends invited them to (invites). |
GET /lobbies/:id | signed in | The lobby as the caller sees it: the mission and its rules (the Respect tier it asks for now among them), the game's creator and rating, the teams' members (party, host, ready, Respect tier), canStart, the countdown (waiting while the city is busy), the run, the last results (each seat's part, the winner), the vote and the caller's own. |
POST /lobbies/:id/join, /leave | signed in | Join (with one's party when leading it, on a team with room for all of it: refused while it counts down or votes, to a kicked account, when full); leave (the lead passes on, an empty lobby closes). |
POST /lobbies/:id/ready {ready}, /team {team} | its members | Ready or not; move to another team with one's party, when it has room for them all. |
POST /lobbies/:id/start, /kick {account} | its host | Start the countdown early once every team has its minimum (409 before); remove a member, who may not come back. |
POST /lobbies/:id/vote {offer} | its members | A vote on the next mission: same, game (the game's next multiplayer mission), level (a playlist's mission on another level taking the group) or replay (the same mission and teams, counting down at once). |
POST /lobbies/:id/invite {account} | its members | Invites a friend (403 for anyone else): their feed (lobby, why: invite) and their invitations. |
POST /lobbies/quick {playlist} | signed in | Quick play by coop, versus, races or any: the caller (with their party, its leader asking) placed ready in the fullest public lobby of the playlist with room for them on one team; else, alone (a party is never split over a run's seats), seated in a public lobby's run under way of the playlist with seats open (the answer's join: the run, its room and the caller's join URL); else in a new public lobby on the playlist's next mission, which remembers the playlist. |
GET /playlists | signed in | Each playlist (PlaylistView): its missions in its rotation across levels (24 at most) and the public lobbies of its missions (12 at most, those gathering first). |
GET /missions?mode=&players=&level= | signed in | The listed games' missions (MissionEntry): the curated games' and the public published games', each with its mode, players and teams and its public lobbies. |
- One open run per account. Opening, joining or Quick play is refused (409, naming the run) while the caller sits in an open run; the members of the party they lead who do stay there, invited instead; a member who opened another run meanwhile is left out of the lobby's next one, told why (
notice: inRun), and the countdown stops when that leaves a team under its minimum. A seat is held until its run ends, or until the run's room says it is gone for good (RoomStatus.gone: a minute after its player left, or never come before the run went live) or the room is no longer on its server. - The city busy. When no game server takes the run (503, or no answer in 60 s), the countdown says so (
notice: busy,countdown.waiting), asks again every 5 s and gives up after two minutes (notice: gaveUp, back to gathering): the lobby is never lost. A playlist's lobby that moved on to a mission whose run cannot open (given up, refused, or the mission gone) goes back to the mission it came from and its results, with its notice and a vote again whose offers leave out the mission that failed. One opening at a time per lobby; a run opened for a countdown that is over by then is abandoned (abandonedwith no result, counting for nothing, its room closed withDELETE /rooms/:id). Before every run the game is read again: taken down or gone, the lobby closes, its members told why (the feed'slobbyevent,why: closed, with itstext). - The playlists (
apps/api/src/playlists.ts, its numbersPLAYLIST_LIMITS) are the curated games' multiplayer missions (Riverbend's co-op heist and its robbers and chasers; withFOI_DEBUG=1every bundled level's game is curated, so the test levels' too, as on a game server with debug commands) and the published games' multiplayer missions whose version qualified for Respect (standing.ts'qualifies: 20 distinct qualifying players and 5 ratings averaging 3; asked of the store at most every 30 s, eight missions at a time,Respect.missionQualified, what it keeps let go after ten minutes; none with Respect off). A playlist's missions are kept 5 s, so the Jobs app, Quick play and the votes reading them often cost one making. Co-op is the co-op missions, Races the versus missions with a race between the players, Versus the other versus missions, Any all of them. Each is a rotation across levels: the levels in name order, one mission of each before the next of any. A lobby of a playlist (Quick play's, or one opened from the Play together screen withplaylist) has its vote's offers from it (the game's next of the playlist, the playlist's next on another level) and, when nobody votes, moves on along it instead of playing the same mission again. - Members gone. A member neither the lobby nor the feed's presence heard from for a minute is dropped from a gathering lobby, has no say in the vote and is dropped when the lobby moves on, told so (
why: removed); the lobby's page says it is there every 15 s. - Caps. 500 lobbies open at once (
LOBBY_LIMITS, 503 beyond); a member's invitations one per friend and lobby while it holds (ten minutes), ten a minute; the changes of a lobby pushed together every 250 ms at most, each push carrying the lobby as the member sees it (view); the listed games' missions made again every 10 s at most (CATALOG_TTL_MS), a version whose document does not read left out and logged.GET /lobbiesandGET /missionsneed a session (a guest has one); a reader not in a lobby sees names and counts, never accounts nor its run. Quick play passes by a lobby that kicked one of the group or whose mission does not admit one, and tries the next when one refuses. - Respect (below): a lobby's create, join and run's start are refused (403) to a player under its mission's Respect tier, saying so; each member's tier shows; a versus lobby's teams are balanced by its members' hidden skill; Quick play keeps the low-conduct pool apart while 16 players or more are in it, and prefers the versus lobbies nearest the group's skill.
Respect
Respect, conduct and the hidden skill (Social says how they move; the rules apps/api/src/standing.ts, the Glicko-2 skill.ts, the service respect.ts, its routes respect-routes.ts) are paid by the API for every settled run, before its lobby shows the results: each account's seat once (its ledger line run:<run>:<account>), never a guest's, nothing for a playtest, an abandoned or a lost run. The game server's result (@foi/protocol's RunResult) carries what conduct reads besides each seat's part: sabotage, kickedBy and teamHitPeak (Missions).
| Route | Who | What |
|---|---|---|
GET /me/standing | signed in | The caller's Respect, tier, the next tier and what it needs, the season's Respect, the conduct's band and the last reasons it moved (StandingView; never the conduct's number); a guest's is empty (guest: true). |
GET /me/standing/export | signed in | Everything kept of the caller's standing: the standing (the conduct's number too), every ledger line, the endorsements given and got, the ratings of missions. |
GET /players/:id/standing | anyone | A player's name, Respect and tier (PublicStanding); a guest has none (404). |
GET /leaderboard?season=&limit= | anyone | The accounts by the Respect earned in a season (a UTC quarter, 2026Q4; the current by default; all: all time), the most first (50 by default, 100 at most): rank, name, tier, Respect, the reader's own marked (LeaderboardView), never accounts. |
GET /runs/:id/standing | its players | What the run paid the caller (RunStandingView): its Respect step by step, the endorsements got and given, the players they may still endorse, their rating of its mission. |
POST /runs/:id/endorse {account} | its players | Endorses a teammate outside one's party (in versus a rival too), both there: {respect, counted}; 403 to a guest, for oneself or one's party, 409 twice or past two a run. |
POST /runs/:id/rating {stars} | its players | Rates the run's mission's version, 1 to 5 (again replaces it); 403 to a guest, 409 for a run that counts for nothing. |
GET /admin/accounts/:id/standing | admins, moderators | An account's Respect, tier, conduct (the number) and band, its season's Respect and its last 50 ledger lines (never the skill). |
POST /admin/accounts/:id/conduct {delta | to, note} | admins | Adjusts an account's conduct by delta or to a value, with a note: the audit log (conduct, its change and what it came to). |
DELETE /admin/accounts/:id/standing {note} | admins | Erases an account's standing (its deletion on request): the standing, the ledger, the endorsements both ways, its ratings of missions; the audit log (erase). |
POST /moderation/reports/:id/uphold {note?} | moderators | Upholds an open report of a player: actioned, the player's conduct lowered by 1,000 (once a report); the audit log (uphold). |
PUT /debug/standing/:id {respect?, conduct?, skill?} | an admin, or a request from the same machine; FOI_DEBUG=1 only | Sets an account's standing for the tests: its Respect raised to so much (never lowered), its conduct, its skill (a rating). |
Where Respect is not shown (FOI_RESPECT=shadow or off) the players' routes answer 404 and the lobbies show no tiers, gate nobody, pool nobody and make their teams by their numbers alone; shadow still computes and keeps everything (the admins' and moderators' routes answer), to tune the numbers on real runs, and off computes nothing. The lobbies' gates, tiers and skill are answered at once from what the service keeps of the accounts it was asked to look at before a lobby is opened or joined, a run started or Quick play placed, and whether a creator's mission's version qualified (kept 30 s). The store keeps it all (in Postgres, migration 10): player_standing (an account's Respect, conduct, skill, its season and the season's Respect), standing_ledger (every change, keyed by its id; a run's by the run and the account), endorsements, mission_stats and mission_stat_runs (a mission version's settled runs and completions, a run counted once), mission_players (its distinct players) and mission_ratings. A creator publishing a mission that asks for a tier above 2 is answered with a warning (warnings): it asks for tier 2 at most until it qualifies.
Previews
A build that passes stores its level's preview: the level seen from straight above, as the minimap shows it, a PNG 384 pixels across. For a generated level the generator renders it (assets/blender/levels/preview.py, a second headless Blender run after the level: every mesh placed as the level data says, the ground and the roads' ribbons as the client draws them, an orthographic camera over the whole ground, Cycles on the CPU, so the worker's container needs no GPU); for a bundled level the build takes the committed picture apps/worker/previews/<level>.png (npm run level:previews renders them again; run it after changing or adding a level). Previews are kept by the hash of their bytes beside the artifacts (previews/<hash>.png in the bucket). A preview is a nicety: a level without one, or a render that fails, leaves the build and its game without a picture and never fails the build. The build's preview is the hash; draft_build links it, draft_publish links the game's.
Moderation: reports, takedowns, the audit log
Public games' texts (and their level specs' district titles, which the minimap shows) are moderated when they are published (FOI_MODERATION, above), and so is the chat of their rooms, line by line (POST /moderation/chat, which the game servers ask with the room secret: Social); FOI_BLOCKED_WORDS adds words to the word list. After that, players report games, and players (POST /players/:id/reports, from a room's list of players), and moderators decide: the accounts FOI_MODERATORS names (account ids, comma-separated: user-<email> with development accounts, the user id with Nakama; GET /me answers moderator: true for them). Every decision goes into the audit log, which nobody edits.
| Route | What |
|---|---|
GET /moderation/reports?state=open|actioned|dismissed|all | The reports, newest first (the open ones by default), each with its game's title; a report of a player names the player (player {account, name}) and the room. |
POST /moderation/games/:id/takedown {note?} | Takes the game down: off the list, its page, versions and preview answer 410 to anyone but its creator and the moderators, it opens no room, and its open rooms take no new players (POST /rooms/:id/join answers 403). Its open reports are actioned. |
POST /moderation/games/:id/restore {note?} | Puts it back. |
PUT /moderation/games/:id/featured {featured, note?} | Features it (first under sort=featured, marked on its card), or stops. |
POST /moderation/reports/:id/dismiss {note?} | Closes an open report with no action. |
POST /moderation/reports/:id/uphold {note?} | Upholds an open report of a player: actioned, their conduct lowered by 1,000 (Respect). |
GET /moderation/audit?limit= | The decisions, newest first (100, at most 500): when, the moderator, the action, the game, the reports it decided, the note. |
There is no moderators' page yet: they use the API (curl with their session, or Claude with a script).
Generated levels: spec drafts and the worker
A level spec is a level in a few lines of JSON, the schema @foi/catalogue holds (GET /schemas gives it as levelSpec): a template (grid-town, a town on a grid of kerbed blocks), a seed, the grid's size, its avenues, its districts with their mix of buildings, its parks, its street furniture and how lived-in it is (dressing). It is what the Blender generator builds (assets/blender/levels/generate.py, see Levels). Two more templates: a region (land from tiles, Tiles) and a city (a city drawn freely, as Riverbend is: roads through points that end on each other, turned grids, rivers with their bridges, hills, roundabouts, districts in Riverbend's styles; The city spec). A region and a city are solved the moment the spec is set: PUT /drafts/:id/spec answers with the spec's issues at their paths, or with its map in text.
POST /drafts {title, spec}starts a draft on one;PUT /drafts/:id/spec {spec}replaces it (a draft on a bundled level then plays on the generated one). A spec that does not match the schema is refused with400and its issues (spec.districts[0].blocks). The API names the level after the draft: the spec'snameand the game'slevelare the draft's id (draft-<hex>), whatever the spec said.- A build of a spec draft generates the level first, then merges its named places into the game's places (the game's own win over a generated one of the same id):
junction_<i>_<j>at every crossing,park_<i>_<j>,<district>.<archetype>_<n>in front of each building (with its tags and, in a themed district, its name),<district>.<archetype>_<n>.<anchor>at each of its anchors,spawn_<n>. Missions use them like any place:zones: { drop: { place: "junction_1_1", radius: 10 } }, an actorat: "spawn_3". Build once to learn them: the build'splaces(and the draft's) list every id the game has. The build checks that the places the missions use, and the game's own, can be reached from the spawn points; a generated place no mission uses (a volcano's crater, the top of a cliff, a counter behind a shop's till) is offered, never a fault. A region's towns' places are among them (<town>.<place>), merged as the region's places file holds them. A spec the generator refuses comes back as the build's issues underspec.. Every generated level is audited before its preview and before anybody plays it (the level audit) and repaired: what was left out comes back as one warning (14 pieces left out to keep the level clean (9 overlapping, 5 not resting on the ground); nothing to fix in the spec), and a fault no repair clears as a warning of its own naming the piece, the point and its district and saying that anotherseedusually clears it (a warning for now: such faults will stop a build once the generators have shown, over a release, that they never make them). The build'sstats.auditholds the counts, the worker's log a line per generation with the spec's hash, and the API's metricsfoi_level_repairs_totalby rule and template. - Levels are kept by the hash of their spec, so a draft built again with an unchanged spec is not generated again.
GET /cataloguelists everything a game may name ({ version, entries }: templates, buildings, street furniture, places, vehicle classes, the mission actors (vehicles, groups of enemies), verbs, rules, events, conditions, values and actions, each{ id, kind, title, doc, example?, params? });GET /catalogue?q=wordssearches it,GET /catalogue/:idgives one entry.
Where the level is generated: on the worker FOI_WORKER_URL names (apps/worker: POST /generate {spec} answers 200 { level: { name, data }, places, stats, generator }, 422 { issues } or 500 { error }, one generation at a time), else in the API's own process when it finds Blender (FOI_BLENDER, the PATH, the installers' folders), else nowhere: a spec build then fails with an issue saying so. A 3 × 2 town takes 3 to 15 s, most of it Blender's start. A region spec is built without Blender, in the API's own process when no worker is configured, unless it has towns: those the town generator builds in Blender, on the worker or the API's own Blender. A city is solved in TypeScript (@foi/catalogue's buildCity) and its plan built by Blender (city.py), with a time limit of 6 minutes: a 320 m city takes about 10 s, Riverbend's size two to three minutes. Its places are every roundabout's, every hill's top, every bridge's middle (bridge.<road>), the spec's own and every district's buildings.
npm run -w apps/worker dev # the worker on 8090
FOI_WORKER_URL=http://localhost:8090 npm run dev:apiYour games and a build's preview
- Your games (
?browse&list=mine,YourGames.tsx): a creator's drafts (GET /drafts, each with its last build's state and errors, its place in the build queue while it waits (position), its artifact once it passed, and its playtest rooms still open to join) and the games they published, whatever their visibility (GET /games?owner=me). While a build waits or runs the page asks again every 2 s, until it is done. Every game the list gives says whether the caller made it (own). - Friends' (
?browse&list=friends,GET /games?friends=1): the public and link-shared games of the caller's friends (the Social layer's, accepted ones), never a game taken down. - A build's preview (
?preview=<artifact hash>, or a build's id the signed-in creator owns;renderer/build-preview.ts): the artifact, asked of the page's API and kept in the browser by its hash (level-cache.ts), its level drawn as the game draws it under the galleries' free camera, with their panel to find the game's places and the spawns by name and fly to them. Your games links each draft's last good build there, and the MCP server'sdraft_buildanswers with the link (look round the level before playtesting it).
Games pinned to what built them
A game is played as it was built, whatever the platform runs today:
- The manifest. Every build writes what built it (
@foi/protocol'sBuildManifest) into its artifact and its build (GET /builds/:id), and publishing keeps it with the version: the API's release and protocol, the formats of the game document and the level (GAME_SCHEMA_VERSION,LEVEL_SCHEMA_VERSION), and for a generated level the generator's (the release whose solver laid out a region; for towns Blender built, the hash of the town generator's scripts inassets/blender/levels/and Blender's version). - The artifact stays as built. A version is pinned to its artifact's hash: its level's geometry is the one generated then, never generated again. A remix or a rebuild builds with today's generator and records it in its own manifest.
- The formats. A game document and a level say their format (
schemaVersion, 1 when absent: everything made before 0.54); an older one is brought up a step at a time (migrateGamein@foi/missions'migrate.ts,migrateLevelin@foi/world) wherever one comes in: a draft'sPUT, a build, a remix, a game server opening an artifact,FOI_GAMEandFOI_LEVELfiles, the bundled games and levels. A test runs every bundled game and level through the steps. A document of a newer format than the release reads is refused, saying it needs a newer release. - Where it plays. A game plays on its release line or a newer one (
minimumRelease:0.54.3→0.54.0, as a patch never changes a format or the protocol). The fleet places its rooms only on servers that registered such a release (fleet.ts,minRelease; a server named inFOI_GAME_SERVERSsays none and is not passed over), and a game server refuses an artifact of a newer line or newer formats with a message that says which (unplayableReason), so the fleet tries the next one.
Steam: a platform for the game's owners
The game is sold on Steam, and the platform can be for its owners alone (Technical specs §9.6): they sign in through Steam, the API asks Steam whether they have a copy, and the game servers let in only the players the API signed a join token for. Every switch's default keeps the platform as it was before, open to anyone (guests, e-mail accounts), so npm run dev, npm run dev:platform and the end-to-end tests play as before:
| Setting | Default | What |
|---|---|---|
FOI_ENTITLEMENT | open | open (anyone), or steam: the game's owners only, signed in through Steam (POST /auth/guest and /auth/login answer 403, and so does the OAuth page's e-mail form). |
FOI_STEAM | off | Which Steam signs players in: off, web (Steam's Web API with the publisher key) or fake (the API's own, for development and the tests, never in production: anyone signs in as anyone). |
STEAM_PUBLISHER_KEY, FOI_STEAM_APP_ID | none | The Steamworks publisher Web API key and the game's app id, for FOI_STEAM=web. The key never leaves the API. |
FOI_JOIN | open | Set on the API (its /info tells the clients) and on every game server alike: token asks every player for a join token, the default room's and the cities' too. |
FOI_STEAM_STORE_URL | none | The game's page on the Steam store: those without a copy are sent there ("Get it on Steam"). |
FOI_WEB_ORIGINS | none | More origins a Steam sign-in may return to (a web client served elsewhere than FOI_PUBLIC_URL); a platform on a loopback address takes any loopback page. |
What a route asks of its caller is a level (need() in apps/api/src/app.ts, entitlement.ts):
| Level | FOI_ENTITLEMENT=open | FOI_ENTITLEMENT=steam |
|---|---|---|
session | signed in | signed in (GET /me: its Steam account, and what it may do, access) |
player | signed in, a guest too | a copy of the game: its own, or one lent (family sharing, a free weekend); not banned by the publisher |
creator | an account, not a guest | a copy of its own for good: a lent copy plays but never creates; not banned |
The sign-in routes (apps/api/src/steam-routes.ts; with FOI_STEAM=off they answer 404):
| Route | What |
|---|---|
POST /auth/steam {ticket} | The desktop app's ticket (GetAuthTicketForWebApi("five-on-it-api"), hex), checked by ISteamUserAuth/AuthenticateUserTicket: the session. |
GET /auth/steam/openid/start?return=<address> | A browser's sign-in, sent to Steam's OpenID 2.0, to come back to the address: one of the platform's own pages (its public origin, the API's, FOI_WEB_ORIGINS), never another site. |
GET /auth/steam/openid/return | Steam's answer, checked (from Steam, for this address, its fields signed, a nonce never seen and under five minutes old, then confirmed by Steam): the browser goes back with a one-time steam_code (ten minutes), never a session, or with steam_error. |
POST /auth/steam/exchange {code} | The code for the session, once. |
POST /auth/steam/link | An e-mail account (its session, or its email and password) takes a Steam account (a ticket or a code): signing in through Steam lands in it from then on, its drafts and games kept. A Steam account that already signed in on its own is never linked (its own account would be left behind): link first. |
A sign-in asks Steam whether the account has a copy (ISteamUser/CheckAppOwnership/v4) and keeps the answer, the account's entitlement (ownsapp, permanent, ownersteamid, publisherbanned, when it was checked: the store's steam_accounts). On a platform for owners, an account without a copy or banned gets no session (403, with code and the store's page) and no account. The entitlement is asked again at every sign-in, when a caller needs it fresher (need(…, { freshMs }), entitlementOf: creating asks for one an hour old at most), and every day for the accounts signed in with in the last 14 days (a refund's window): a refunded copy stops its sessions. A publisher ban is what a ticket says, kept until a ticket says otherwise (OpenID says nothing of it). A Steam account is an account of its own: steam-<steamid64> with dev accounts, a Nakama account by custom authentication keyed steam-<steamid64> (not Nakama's own Steam authentication, so the ticket and OpenID paths are one). The OAuth sign-in page signs creators in through Steam the same way ("Sign in through Steam", a lent copy refused there), so Claude Code's creators are owners too.
GET /play?level= answers players only on such a platform (or wherever FOI_JOIN=token) and signs a join token for the level's room into the address it gives (…/game?level=<name>&token=…); the client asks it each time it joins, after a drop too. A player refused sees why, "Sign in through Steam" and "Get it on Steam", never a guest instead.
The desktop app signs in with its Steam ticket (apps/client/electron/steam.ts: a Steam build of the app, its app id baked in, GetAuthTicketForWebApi("five-on-it-api"), which the page posts to POST /auth/steam) when the player picks "Sign in through Steam" on Play online or on the game pages; with Steam not running (no ticket) the same button opens Steam's own sign-in page in a window of the app's, which starts at /auth/steam/openid/start with the way back an address of the API's own (<api>/auth/steam/openid/app, which the return check takes as one of the platform's) and closes as Steam's answer comes back, the main process catching the redirect before it loads and the page exchanging the one-time code (POST /auth/steam/exchange). The first sign-in is always the player's choice (a Steam account that signed in on its own can no longer be linked to an e-mail account); after it, the app signs in again by itself with its ticket whenever its session is gone, until the player signs out. Play local never asks the platform or Steam anything.
The fake Steam (FOI_STEAM=fake) plays the whole of it on a laptop and in the end-to-end tests: tickets say who and what copy they are (fake:<steamid64>:<own|shared|none>, :banned after them), and its OpenID provider is a page the API serves (/auth/steam/fake/openid: a Steam id, a name and a copy), whose answers it signs and checks as the real ones. Run the platform for owners with it: FOI_STEAM=fake FOI_ENTITLEMENT=steam FOI_JOIN=token npm run dev:platform (the variables reach the API and the game server alike).
Rooms and join tokens
A game server with FOI_ROOM_SECRET takes rooms: the API sends POST /rooms {id, artifactUrl, maxPlayers, title, moderationUrl?} with the secret as a bearer token (a public game's room names where its chat is moderated; a playtest's and a game's shared by link do not), the server fetches the artifact from the API, checks the game again and opens the room (apps/server/src/rooms.ts). Players connect to /rooms/<id>?token=<join token>; the token is an HS256 JWT the API signs with the same secret, naming the room, the account, the player's name and their party, good for five minutes (@foi/protocol's signJoinToken and verifyJoinToken). A room that stands empty does not simulate (its world waits until somebody joins) and closes after five minutes. A connection to the server's root still joins the default room, the level FOI_LEVEL names, which is how a standalone server and the desktop app play. With FOI_JOIN=token (a platform for the game's owners, above) the default room and the cities' rooms ask for a token too: a bare /game one for the room default, ?level=<name> one for level-<name>, checked before the server looks up or opens the level's room, so nobody without one makes it open a room; a refusal closes with 4401.
Adapters
| What | Development (the default) | Production (the Compose stack) |
|---|---|---|
| Accounts | FOI_AUTH=dev: made on sight | FOI_AUTH=nakama with NAKAMA_URL, NAKAMA_SERVER_KEY, NAKAMA_SESSION_KEY |
| Friends, mutes, kept chat | with dev accounts: in memory, and .foi-data/social.json with the file store | Nakama's friends and storage; the party's and direct messages kept in its channels with NAKAMA_HTTP_KEY (apps/nakama) |
| Drafts, builds, games, ratings, reports, the audit log, the build queue, the daily quotas, the admins' overrides, runs, standings | FOI_STORE=file (.foi-data/store.json) or memory | FOI_STORE=postgres with DATABASE_URL: JSONB documents, migrated at start (postgres.ts) |
| Artifacts and previews | FOI_ARTIFACTS=file (.foi-data/artifacts), or memory with that store | FOI_ARTIFACTS=s3 with S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY (MinIO; s3.ts) |
| Moderation | FOI_MODERATION=off, no moderators | chain (a word list, then a small Claude model; ANTHROPIC_API_KEY); FOI_MODERATORS |
| Admins | none (FOI_ADMINS=user-<email> before npm run dev:platform) | FOI_ADMINS: Nakama user ids |
| Level generator | in the API's process, when Blender is found | the worker: FOI_WORKER_URL (Blender 5.2 in its container) |
| Steam | FOI_STEAM=off (fake: the API's own) | FOI_STEAM=web with STEAM_PUBLISHER_KEY and FOI_STEAM_APP_ID; owners only with FOI_ENTITLEMENT=steam |
| Game servers | FOI_GAME_SERVERS=http://localhost:7777 | a list, each internal-url or internal-url|public-ws-url (the stack's: http://game:7777|ws(s)://<its origin>) |
Every store and artifact store keeps the same contract, tested once against all of them (apps/api/src/stores.test.ts; the Postgres and S3 runs need FOI_TEST_DATABASE_URL and FOI_TEST_S3_ENDPOINT, which CI's adapters job sets). The API warns at start when it runs on development secrets or without moderation.