Local developer mode
In local developer mode a developer builds a Moonspell game on their own machine with their own coding agent, and the crescent CLI publishes it to the Moonspell app. Moonspell runs no sandbox and no model for it. This page is the server side: the routes, the sign-in, the rules. The developer's side is Publish to the Moonspell app. The CLI itself is handed out built; its source is private (What is open and what is private).
Everything here is behind CRESCENT_DEV_MODE=on. When it is off (the default), every /api/dev/* route answers 404 {error: "dev_mode_off"}. The tables come from db/migrations/0015_local_dev_mode.sql.
A local game is a game like any other: owned by the account that signed the CLI in, listed in that player's games on every app, and public at once. Its first version goes into the feed for every player, plays from its /g/<buildId> link and the App Clip, and is open to community builds, like a game made in the app. games.source and game_builds.source (worker or local) only record where a game started and where each version came from.
It works the other way round too: every game is its owner's dev game, wherever it was made. A game made in the app can be continued and published from the owner's machine, and from a collaborator's (crescent continue <gameId>). One rule between the two: a publish waits while a worker run is building the game (409).
Code
| What | Where |
|---|---|
| Routes | src/app/api/dev/** |
| The error shape, the switch, body parsing | src/lib/dev/http.ts (devRoute, DevError, devModeOn) |
| Every limit | src/lib/dev/limits.ts |
| Tokens, user codes, invite tokens | src/lib/dev/tokens.ts |
| Who is calling, the device flow, CLI tokens | src/lib/dev/auth.ts (devCallerFromRequest, requireDevCaller) |
| Roles | src/lib/dev/access.ts (roleFor, may, devGameFor) |
DevGame, DevVersion | src/lib/dev/shapes.ts |
| Local games | src/lib/dev/games.ts |
| The game's repo | src/lib/dev/repo.ts |
| Version rules, pure | src/lib/dev/upload-rules.ts |
| Begin and complete a version | src/lib/dev/uploads.ts |
| Invites and collaborators | src/lib/dev/invites.ts |
| The cover | src/lib/dev/cover.ts |
Someone else's game: access, the fork, drafts (rules pure in fork-rules.ts) | src/lib/dev/fork.ts, src/lib/dev/fork-rules.ts |
| The starter and CLI downloads | src/app/api/dev/starter/, src/app/api/dev/cli/ |
Unit tests: src/lib/dev/*.test.ts (npm test).
Downloads
These need no sign-in.
| Route | Answer |
|---|---|
GET /api/dev/starter | {url, version, sha256, size}: the current starter archive. Its files sit at the archive's root. |
GET /api/dev/cli | {url, version, sha256, size, blobUrl}: the current CLI package. url is a versioned URL on moonspell.app that npm and npx take. |
GET /api/dev/cli/latest.tgz | Redirects to the current CLI package (cached for a minute), for npx https://moonspell.app/api/dev/cli/latest.tgz init my-game. |
GET /api/dev/cli/crescent-cli-<version>.tgz | Redirects to that version's package. A version's bytes never change. |
The starter is the template's tracked files with the overlay in templates/crescent-game-local/ laid over them: rewrites.md (exact rewordings of the skills and docs that name the hosted builder's sandbox) and files/ (the moonspell-dev skill, .claude/settings.json, the save hook). scripts/pack-starter.mts packs it, and fails when a rewording no longer matches the template or a file still names something only the hosted builder has. The template itself is never changed for this mode. Packing also builds the CLI, so it needs the CLI's private source.
Who is calling
Every other route takes Authorization: Bearer <token> with either:
- a dev token, which
crescent logingets through the device flow below. Only its SHA-256 is stored; revoking it marks it revoked. - the app session (
src/lib/app-session.ts), which the web app sends, so the developer pages on the web call the same routes.
Making or changing anything needs a registered account (signed in with Google, or Apple on iOS): an anonymous one gets 403 sign_in_required.
Errors
Every answer is JSON with Cache-Control: no-store, and every success is 200. Errors are {error: "<code>", message}, where message is a sentence for the developer:
| Code | Status | When |
|---|---|---|
unauthorized | 401 | No credentials, a revoked token, or the wrong kind for the route |
sign_in_required | 403 | An anonymous account |
forbidden | 403 | A collaborator asking for an owner's action |
not_found | 404 | No such thing, or not the caller's |
dev_mode_off | 404 | CRESCENT_DEV_MODE is not on |
limit | 429 | Too big, too many files, too many versions or games a day |
invalid | 400 | A malformed body, a bad path, a build that will not play |
conflict | 409 | The commit is not pushed, the upload expired, a worker run is building the game, a version number was taken |
authorization_pending, slow_down, access_denied, expired_token | 400 | The device flow's poll |
An invalid answer from completing a version also carries problems: string[].
Sign-in: the device flow
It follows the shape of the OAuth device flow (RFC 8628):
POST /api/dev/device(no auth){clientName?}returns{deviceCode, userCode, verificationUri, verificationUriComplete, interval: 3, expiresIn: 600}.userCodelooks likeABCD-EFGHand avoids letters and digits that are easy to confuse.verificationUriis<PUBLIC_BASE_URL>/cli.- The person opens
/cli?code=...signed in, sees which client is asking, and approves (GET /api/dev/device/:userCode,POST /api/dev/device/approve). - The CLI polls
POST /api/dev/device/token(no auth){deviceCode}every 3 seconds:authorization_pendinguntil approved,slow_downwhen it polls too fast,access_deniedonce declined,expired_tokenfor an unknown, expired or used code. Once approved it gets{token, user: {id, name}}. One code gives one token.
Then:
| Route | Auth | Answer |
|---|---|---|
POST /api/dev/handoff | dev token | {code, expiresIn: 300}: a one-time code that signs the CLI in as the same account (crescent login --code). The Moonspell MCP server hands this to the machine. |
POST /api/dev/handoff/token | none | {code} returns {token, user} once. |
GET /api/dev/me | dev token | {user: {id, name}, tokenName, createdAt} |
POST /api/dev/logout | dev token | Revokes it. |
GET /api/dev/tokens | app session | The account's live tokens, newest first (Settings) |
DELETE /api/dev/tokens/:id | app session | Revokes one. |
Games
| Route | Who | Body and answer |
|---|---|---|
POST /api/dev/games | registered | {title} returns {game: DevGame}: a local game owned by the caller. At most 20 a day per account. |
GET /api/dev/games | signed in | {games: DevGame[]}: the caller's games, made in the app or on a machine, and the ones they collaborate on, most recently touched first |
GET /api/dev/games/:id | owner, collaborator | {game, versions, collaborators}: versions newest first (at most 50) |
POST /api/dev/games/:id/repo-token | owner, collaborator | {remoteUrl, username, password, branch: "main", expiresAt}; creates the repo on the first call |
DevGame = { id, title, slug, repo, role: "owner" | "collaborator", coverUrl, devUrl, latest: DevVersion | null }
DevVersion = { buildId, version, createdAt, summary, commitSha, by: { id, name }, shareUrl,
platforms: { ios: true, android: boolean, web: boolean } }
devUrl is <PUBLIC_BASE_URL>/dev/<id> and shareUrl is <PUBLIC_BASE_URL>/g/<buildId>. Anyone without a role on a game gets not_found.
The repo is the game's own private repo, the same one worker runs push to (Game repos). The answer is host-neutral (remoteUrl, username, password), so the CLI never needs to know where repos live. The password is a short-lived token for that one repo; the CLI hands it to git per command and does not write it to disk.
Versions
A version's files go from the developer's machine straight to blob storage, one client token per file; the server never proxies them. The layout is the same as for worker runs: games/<id>/v<n>/ for iOS, with android/ and web/ beside it (Builds and versions).
- Begin:
POST /api/dev/games/:id/versions(owner, collaborator) with{commitSha, summary, platforms, files}. The server checks the limits, that the repo has the commit (409 until it is pushed), and that no worker run is building the game (409). It then reserves the next version number and answers{uploadId, version, expiresAt, uploads}, one upload token per declared file. - Upload: the CLI puts each file with its token.
- Complete:
POST /api/dev/games/:id/versions/:uploadId/complete(whoever began it) with{gdd?}. The server checks that every declared file arrived at its declared size and that each platform's build is complete and refers only to files of this version. It then records the version, and from that moment it is public: in the owner's games, the feed, at/g/<buildId>, in the App Clip and the Android and web apps. It answers{version, shareUrl, devUrl, warnings}; completing again answers with the same version.
iOS must pass. Android and the web are best effort, as for worker runs: a platform that fails its checks is left out of the version, and warnings says why. If the game has no cover yet, the cover workflow starts once, from the version's notes.
Limits
commitShais a full 40-character hash;summaryat most 4,000 characters;gddat most 30,000.platformsincludesios, and each file belongs to one listed platform.- A path is relative, with forward slashes, no empty,
.or..segment, at most 300 characters of letters, digits and. _ - @ + ~. - At most 60 MB a file, 2,000 files and 200 MB a version, 30 versions a game in 24 hours, and an upload lasts 30 minutes.
An abandoned upload holds its version number until it expires; if a later version completes meanwhile, that number is skipped. Worker runs step over live reservations, so the two never take the same number.
Invites and collaborators
| Route | Who | Body and answer |
|---|---|---|
POST /api/dev/games/:id/invites | play: owner, collaborator; build: owner | {kind, maxUses?, expiresInDays?} returns {invite} |
GET /api/dev/games/:id/invites | owner (all), collaborator (their own) | {invites}, live ones, newest first |
DELETE /api/dev/invites/:id | the game's owner or the invite's maker | Revokes it. |
GET /api/dev/invites/:token | none | The invite's kind, the game, its latest version, and whether it still works |
POST /api/dev/invites/:token/accept | registered | build only: the caller becomes a collaborator. Accepting again, or as the owner, changes nothing. |
DELETE /api/dev/games/:id/collaborators/:userId | owner; a collaborator for themselves | Removes them. |
An invite's link is <PUBLIC_BASE_URL>/i/<token>. A build invite defaults to one use within 7 days; maxUses goes up to 1,000 and expiresInDays to 365. A play invite is not used up by being opened.
A collaborator may read the game, get a repo token, publish versions and make play invites, but not make build invites or remove other people.
Signing in from an anonymous account to an existing one (moveEverything, src/lib/sign-in.ts) carries tokens, games and collaborations over.
Someone else's game
Any registered player may continue a game they don't own, as long as it takes other players' changes (PublicGame.buildable: a repo with a commit on its latest version, and community runs open). They work on their own copy, publish drafts only they play, and merge through the same merge run and judge as the app's merge card (Community builds).
The rule that shapes it: a contributor's credentials never write the game's repo, neither its public branch nor anyone's draft. A token for one shared repo can't promise that (GitHub's and Artifacts' tokens are per repo, not per branch), so:
- The fork is a repo of its own, one per game and player (the
game_forkstable):<game repo name>-fork-<12 hex of the player's id>, private, on the same repo host as the game, holding the game's public branch (or the player's unmerged draft, when they have one) onmain, never the game's other branches. On GitHub it is a new repo in the same organization seeded by a copy (GitHub can't fork a repo into the org that owns it); on Artifacts it is the binding's ownfork(name, {defaultBranchOnly: true}), then the same copy when the seed is a draft. The player's machine only ever gets a write credential for that repo. - The draft stays where every draft is,
community/<userId>on the game's repo, where Nova's draft runs, the merge run and the judge already read it. Only the server moves that branch: when a draft version completes, it copies the fork's commit over plain git smart HTTP (upload-pack from the fork with a read token, receive-pack to the game's repo with a write token, both minted server-side and used once:copyCommit,src/lib/game-repo/smart-http.ts). No git binary on Vercel, no change to the merge run.
Why not let the merge run fetch the fork instead: Nova's draft runs continue community/<userId> and decide whether a draft starts over from what is there, and cancelling and the judge read it too. One home for the draft means none of them changes.
Who is who
GET /api/dev/games/:id/access (any signed-in caller, anonymous included) → {role, buildable, reason?, game: {id, title, slug}, draft, forked}:
role | Who | Works on |
|---|---|---|
owner, collaborator | the game's own people | its repo (repo-token), as before |
contributor | any registered player, on a buildable game | their fork (fork) |
none | an anonymous account, or a game that can't take changes (reason says which) | nothing |
draft is a contributor's unmerged draft {buildId, draft, baseVersion, commitSha, summary, createdAt} (made with Nova or on their machine), the one a merge would put in. continueRoleFor (fork-rules.ts) decides; the web's continueRole and the CLI's continueAccess follow it.
Routes
| Route | Who | Body → answer |
|---|---|---|
POST /api/dev/games/:id/fork | contributor | → {remoteUrl, username, password, branch: "main", expiresAt, draft: {branch: "moonspell/draft", commitSha} | null}: an hour of write access to their fork, made on the first call (that call counts toward the day's community changes). draft: the player's draft as the game's repo has it, copied onto the fork's moonspell/draft on every call, so their machine can merge what Nova changed in the app. |
POST /api/dev/games/:id/versions | contributor | the version body plus draft: true → {uploadId, draft: true, expiresAt, uploads}. The commit must be on their fork (409 until pushed); no Nova run on their draft and no merge of it may be running (409); the day's limit (429); nothing on their draft branch may be missing from the commit (409 reason: "draft_changed", below). Files go to games/<id>/drafts/community-<userId>/local-<uploadId>/. |
POST /api/dev/games/:id/versions/:uploadId/complete | contributor | {gdd?, draft: true} → {draft: {buildId, draft, baseVersion, commitSha, summary, createdAt, platforms}, warnings, notice}. Files and manifests are checked as for a version; then community/<userId> moves to the commit (compare-and-swap on its old value); then the draft build is recorded in recordBuild's draft shape (branch, the next draft, base_version, version null, source: 'local'). From then on only that player plays it, in the feed and on Home in place of the public version, as with Nova's drafts. |
POST /api/dev/games/:id/merge | contributor | {confirmed: true} (the app's tick, never implied: anything else is 400) → {run, message}. Calls startMerge and starts the merge run, as POST /api/app/games/:id/merge does. Refused while a draft from their machine is still uploading. |
An owner or collaborator sending draft: true, or asking to merge, gets 400 invalid (their publish makes the next public version). A none caller gets 403 forbidden with the reason.
Drafts from two places
A player may change their draft with Nova in the app and on their machine. Neither loses the other's commits:
- Machine, then Nova: the draft branch has the machine's published draft, and Nova's next draft run continues it (it has an unmerged draft, so it doesn't start over) and pushes a fast-forward.
- Nova, then machine: a draft from the fork moves
community/<userId>only when nothing on it would be lost (draftMove): the branch doesn't exist (create); it is already there (none); nothing on it is unmerged (start-over, forced, as a draft run started over does); or its head is in the commit's history (forward, checked on the fork: GitHub's compare API, or the edge walking every parent on Artifacts). Otherwise it refuses (refuse: 409draft_changed, withdraftBranchanddraftCommit) and puts the draft on the fork'smoonspell/draft.crescent savemergesmoonspell/draftbefore every push (a conflict stops it and says how to merge by hand), so the next publish goes forward. - A publish refuses while a Nova run is changing the draft or a merge of it is running; a merge refuses while a draft from the machine is still uploading.
Saved-but-unpublished commits stay on the fork: a merge takes the last published draft (crescent merge says so when the folder has newer commits).
Limits
The community rules hold: buildable games only, a registered account, and the day's limit of 5 community changes per player and game (contributionBlock, contributionsToday: Nova's draft runs and drafts from a machine together, counted when the fork is first made and at each draft publish). The author can cancel an accepted change as for any other.
On the machine
crescent continue <gameId> asks access first. For a contributor it calls fork, clones it, lays the local overlay, and writes .moonspell/game.json with fork: true. The marker never reaches a commit: the file is committed without it first when the game has none, and git is told to leave the local change out (update-index --skip-worktree), so a merged draft never makes the game's own repo look like a fork (and continue/link for the game's own people drop a stray marker). save pushes to the fork, merging moonspell/draft first; publish makes a draft and says that only they play it until they merge it (no share link, no QR code); crescent merge --confirm (MCP moonspell_merge {confirmed: true}) asks for the merge, and says a judge decides whether it becomes the game's next version. The remote MCP server's moonspell_start hands out the same continue command for other people's buildable games.
Not done yet
- No live "new version" event: a local version has no worker run, so open apps see it on their next fetch of the games list or the feed.
- A game backend deployed from the developer's machine, and generated sounds, sprites, models or a new cover on demand.
- A share link for a draft. A draft plays only for its player, signed in, in the app and on moonspell.app.