# Builds and versions

> What a Moonspell version is, how a run exports it once per platform, where the files are stored, and how each app gets its own export.

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](https://docs.moonspell.app/moonspell/nova-and-the-worker.md)) |
| 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](https://docs.moonspell.app/start/open-source.md). Outside Moonspell, a game is exported with plain Expo, for example `npm run export:web` in the template (see [Web](https://docs.moonspell.app/platforms/web.md)).

## 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](https://docs.moonspell.app/moonspell/community-builds.md)) |
| 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:

1. `readOutcome`: the commit, the notes and the next ideas.
2. Sounds, art and 3D models the builder asked for are made and committed, and the game is exported again with them.
3. `nextVersion`: the game's highest version plus one.
4. `uploadExport`, `uploadAndroidExport`, `uploadWebExport`: each platform's files.
5. `recordBuild`: the `game_builds` row. It is idempotent per game and version, so a retried step lands on the same row.
6. `pushGame`: the commit goes to the repo.
7. `succeedRun`: the run is marked succeeded, and a `build` event 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 |
