Builds and versions
A version is one playable state of a game: a commit in the game's repo plus that commit exported once for each platform. Every successful run makes one version. This page covers what a version holds, where it is stored and how each app finds its own copy.
One source, three exports
A game is written once and plays on iOS, Android and the web. A React Native bundle is platform-specific (Metro resolves .web.ts, Platform.OS and each native module's JavaScript per platform), so a run exports the same commit three times inside the sandbox:
| Export | When | If it fails |
|---|---|---|
| iOS | Always; the builder's checkpoints check it | The run falls back to the last checkpoint (see Nova and the worker) |
| Android | After the iOS export passes | Best effort: the version ships without it |
| Web | After the iOS export passes | Best effort: the version ships without it |
The export step uses the private bundling CLI. The format it writes and how the private app loads it are not documented here; see What is open and what is private. Outside Moonspell, a game is exported with plain Expo, for example npm run export:web in the template (see Web).
Where a version lives
| Piece | Where |
|---|---|
| Source | The commit, pushed to the game's private repo (pushGame) |
| iOS files | Vercel Blob, games/<gameId>/v<N>/ |
| Android files | games/<gameId>/v<N>/android/ |
| Web files | games/<gameId>/v<N>/web/ |
| A player's draft of someone else's game | games/<gameId>/drafts/<branch>/d<N>/ (see Community builds) |
| The record | A game_builds row: game, version, commit, one manifest URL per platform (manifest_url, android_manifest_url, web_manifest_url), the builder's notes and the game's GDD.md |
uploadExportFiles in src/lib/export-upload.ts uploads every file of an export first and its entry file last, so a version is never visible before the files it points to.
The order of the steps
From src/workflows/game-run.ts, after the builder's pass succeeds:
readOutcome: the commit, the notes and the next ideas.- Sounds, art and 3D models the builder asked for are made and committed, and the game is exported again with them.
nextVersion: the game's highest version plus one.uploadExport,uploadAndroidExport,uploadWebExport: each platform's files.recordBuild: thegame_buildsrow. It is idempotent per game and version, so a retried step lands on the same row.pushGame: the commit goes to the repo.succeedRun: the run is marked succeeded, and abuildevent goes to the edge so the building card turns into a play card.
If the push fails after the version was recorded, withdrawBuild deletes the row. A version only exists when its commit is in the repo, so the next run can always build on it.
Which export each app gets
Every app sends X-Crescent-Client: ios, android or web. Every response that carries a build goes through clientJson in src/lib/client.ts, which leaves one URL in manifestUrl: the asking app's own export, or an empty string when that version has none, which the app shows as "not on Android yet" or "not on the web yet". The other platforms' URLs never leave the server.
Playing a version on the web
Blob storage does not run scripts on its own pages, so the server serves the page that starts a web export: GET /w/<buildId> (src/app/w/[buildId]/route.ts, gamePage() in src/lib/web-play.ts). The bundle and assets still load from storage.
A game is code a builder wrote from anyone's prompt, so it never runs on the web app's own origin, where the player's session lives. The web app frames /w/<buildId> from a separate play origin: CRESCENT_PLAY_ORIGIN when set, else the deployment's own domain when players reach the app through the edge, and locally the other loopback name (localhost and 127.0.0.1). The game page moves itself to the play origin when opened anywhere else, and frame-ancestors only lets this server's pages frame it. The client half is src/web/play.ts.
The starter game
Every new account starts with Sunset Sprint, the template's own game, as version 1 (src/lib/starter.ts). Its exports are uploaded once with npm run starter:upload, which prints the URLs for CRESCENT_STARTER_MANIFEST_URL, CRESCENT_STARTER_ANDROID_MANIFEST_URL and CRESCENT_STARTER_WEB_MANIFEST_URL. The starter has no repo, so the first change Nova makes starts from the template and creates the game's repo with version 2.
Scripts that export
These root scripts export games, so they need the private CLI and stop with one sentence in a public clone (scripts/require-private.mjs):
| Script | What it does |
|---|---|
npm run starter:upload | Exports and uploads the starter game |
npm run android:backfill -- [--game <id>] [--dry-run] | Gives older games an Android export |
npm run web:backfill -- [--game <id>] [--dry-run] | Gives older games a web export |
npm run snapshot:pack | Packs the CLI into the sandbox snapshot |