Skip to main content

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​

WhatWhere
Routessrc/app/api/dev/**
The error shape, the switch, body parsingsrc/lib/dev/http.ts (devRoute, DevError, devModeOn)
Every limitsrc/lib/dev/limits.ts
Tokens, user codes, invite tokenssrc/lib/dev/tokens.ts
Who is calling, the device flow, CLI tokenssrc/lib/dev/auth.ts (devCallerFromRequest, requireDevCaller)
Rolessrc/lib/dev/access.ts (roleFor, may, devGameFor)
DevGame, DevVersionsrc/lib/dev/shapes.ts
Local gamessrc/lib/dev/games.ts
The game's reposrc/lib/dev/repo.ts
Version rules, puresrc/lib/dev/upload-rules.ts
Begin and complete a versionsrc/lib/dev/uploads.ts
Invites and collaboratorssrc/lib/dev/invites.ts
The coversrc/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 downloadssrc/app/api/dev/starter/, src/app/api/dev/cli/

Unit tests: src/lib/dev/*.test.ts (npm test).

Downloads​

These need no sign-in.

RouteAnswer
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.tgzRedirects 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>.tgzRedirects 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 login gets 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:

CodeStatusWhen
unauthorized401No credentials, a revoked token, or the wrong kind for the route
sign_in_required403An anonymous account
forbidden403A collaborator asking for an owner's action
not_found404No such thing, or not the caller's
dev_mode_off404CRESCENT_DEV_MODE is not on
limit429Too big, too many files, too many versions or games a day
invalid400A malformed body, a bad path, a build that will not play
conflict409The 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_token400The 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):

  1. POST /api/dev/device (no auth) {clientName?} returns {deviceCode, userCode, verificationUri, verificationUriComplete, interval: 3, expiresIn: 600}. userCode looks like ABCD-EFGH and avoids letters and digits that are easy to confuse. verificationUri is <PUBLIC_BASE_URL>/cli.
  2. The person opens /cli?code=... signed in, sees which client is asking, and approves (GET /api/dev/device/:userCode, POST /api/dev/device/approve).
  3. The CLI polls POST /api/dev/device/token (no auth) {deviceCode} every 3 seconds: authorization_pending until approved, slow_down when it polls too fast, access_denied once declined, expired_token for an unknown, expired or used code. Once approved it gets {token, user: {id, name}}. One code gives one token.

Then:

RouteAuthAnswer
POST /api/dev/handoffdev 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/tokennone{code} returns {token, user} once.
GET /api/dev/medev token{user: {id, name}, tokenName, createdAt}
POST /api/dev/logoutdev tokenRevokes it.
GET /api/dev/tokensapp sessionThe account's live tokens, newest first (Settings)
DELETE /api/dev/tokens/:idapp sessionRevokes one.

Games​

RouteWhoBody and answer
POST /api/dev/gamesregistered{title} returns {game: DevGame}: a local game owned by the caller. At most 20 a day per account.
GET /api/dev/gamessigned 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/:idowner, collaborator{game, versions, collaborators}: versions newest first (at most 50)
POST /api/dev/games/:id/repo-tokenowner, 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).

  1. 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.
  2. Upload: the CLI puts each file with its token.
  3. 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​

  • commitSha is a full 40-character hash; summary at most 4,000 characters; gdd at most 30,000.
  • platforms includes ios, 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​

RouteWhoBody and answer
POST /api/dev/games/:id/invitesplay: owner, collaborator; build: owner{kind, maxUses?, expiresInDays?} returns {invite}
GET /api/dev/games/:id/invitesowner (all), collaborator (their own){invites}, live ones, newest first
DELETE /api/dev/invites/:idthe game's owner or the invite's makerRevokes it.
GET /api/dev/invites/:tokennoneThe invite's kind, the game, its latest version, and whether it still works
POST /api/dev/invites/:token/acceptregisteredbuild only: the caller becomes a collaborator. Accepting again, or as the owner, changes nothing.
DELETE /api/dev/games/:id/collaborators/:userIdowner; a collaborator for themselvesRemoves 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_forks table): <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) on main, 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 own fork(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}:

roleWhoWorks on
owner, collaboratorthe game's own peopleits repo (repo-token), as before
contributorany registered player, on a buildable gametheir fork (fork)
nonean 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​

RouteWhoBody → answer
POST /api/dev/games/:id/forkcontributor→ {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/versionscontributorthe 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/completecontributor{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/mergecontributor{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: 409 draft_changed, with draftBranch and draftCommit) and puts the draft on the fork's moonspell/draft. crescent save merges moonspell/draft before 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.