Architecture
This page explains how the hosted Moonspell product fits together. Players chat with Nova in the Moonspell app. Nova starts a worker run: Claude Code, headless, in a throwaway sandbox, editing that game's own repo with the open-source engine. The run exports the game, the server stores it as a new version, and the app plays it.
Most of this system is public in this repo: the server, Nova, the run workflow, the edge Worker, the contracts and the feature specs. The native iOS and Android app and the bundling CLI are private. What is open and what is private draws that line.
The system on one page
Moonspell apps Server (Next.js on Vercel) Sandbox (Daytona), one per run
-------------- -------------------------- ------------------------------
iOS, Android (private) chat Nova (agent/) Claude Code, headless
web (src/web, src/app) -------> make_game / change_game in a clone of the game's repo
| | |
| v |
| game_runs row (queued) |
| | |
| v |
| game-run workflow (src/workflows/) ------> creates the box, starts run.sh
| | polls every 15 s <------------------ exit code, progress line
| | uploads the exports <--------------- one export per platform
| | pushes the commit ---> GitHub (one private repo per game)
| v
| Postgres (Neon): users, games, builds, runs, credits
| Blob storage (Vercel Blob): every version's files, covers
|
| live progress (WebSocket) Edge Worker (edge/, Cloudflare)
+<--------------------------------- RunChannel Durable Object per run <---- relay in the box posts events
| proxies every other path to the server
|
| play a game Game backend (optional, one Cloudflare Worker per game)
+---------------------------------> leaderboards, presence, rooms, saves
The parts
| Part | Where | Runs on | What it does |
|---|---|---|---|
| Moonspell app | private (iOS, Android); src/web/ and src/app/(app)/ (web) | phones, browsers | The games feed, Nova's chat, the player, sharing. Downloads a version's export for its platform and runs it. |
| Server | repo root: src/, agent/, db/ | Vercel | The app API (src/app/api/app/), Nova (mounted at /eve/v1/*), the run and cover workflows (src/workflows/), the shared-game page /g/<buildId>, the web app. |
| Sandbox | created by src/lib/daytona.ts | a sandbox provider (Daytona) | One throwaway box per run with Claude Code, git, Node and a copy of the game template. Deleted when the run ends. |
| Edge | edge/ | Cloudflare Workers | The public host. Proxies the server, streams live run progress through one Durable Object per run, and meters the builder's Claude calls when credits are on. |
| Game template | templates/crescent-game/ | copied into every game repo | The engine, the starter game and the instructions and skills the worker reads. See Engine overview. |
| Game backend | the template's realtime-backend skill | each game's own Cloudflare Worker | Live leaderboards, "N playing now", rooms and synced saves. See Backend overview. |
next.config.ts wraps the Next.js app with withEve(withWorkflow(...)), so Nova and the workflows deploy with the server as one project.
One request, start to finish
- Chat. The player writes to Nova. Nova asks a question or two at most, then calls
make_game(a new game) orchange_game(an existing one) with a complete brief. - Queue. The tool inserts a
queuedrow ingame_runsand asks the server to start the run (POST /api/internal/runs). A game has at most one active run at a time on each branch. - Build. The workflow creates a sandbox, writes the run's files into it and starts the run script. Claude Code builds in checkpoints, so a run always has a playable version to fall back on. See Nova and the worker.
- Watch. A relay inside the box posts progress lines to the edge, and the app shows them on a building card. See Live progress.
- Store. The workflow uploads each platform's export to blob storage, records the version in
game_buildsand pushes the commit to the game's repo. See Builds and versions. - Tell Nova. The workflow posts a hidden
[Build ready]or[Build failed]message into the chat. Nova answers with a card that plays the new version. - Share. Every version has its own link,
/g/<buildId>. See Sharing.
Server code map
| Area | Files |
|---|---|
| Nova | agent/agent.ts, agent/instructions.ts, agent/instructions/status.ts, agent/tools/, agent/lib/, agent/channels/app.ts, agent/hooks/meter.ts |
| Runs | src/workflows/game-run.ts (the workflow), src/workflows/game-run-steps.ts (its steps) |
| Sandbox and run script | src/lib/daytona.ts |
| Games, versions, runs | src/lib/games.ts |
| Game repos | src/lib/github.ts (a GitHub App creates one private repo per game and mints single-repo tokens) |
| Live progress | src/lib/realtime.ts (signs crt1 tokens, publishes events to the edge) |
| Game backends | src/lib/cloudflare-gateway.ts (signs tokens a Cloudflare gateway accepts for one game's resources) |
| Accounts | src/lib/app-session.ts (anonymous accounts, bearer sessions), src/lib/sign-in.ts (Google and Apple) |
| Which export each app gets | src/lib/client.ts |
| Sharing | src/lib/app-clip.ts, src/app/g/[buildId]/, src/app/.well-known/ |
| Community builds | src/lib/community.ts, community-merge.ts, community-cancel.ts, judge.ts, badges.ts |
| Credits | src/lib/credit-prices.ts, src/lib/credits.ts, src/lib/usage.ts |
| Crash fixes | src/lib/heal.ts |
| Generated art, sounds and models | src/lib/higgsfield.ts, src/lib/models.ts, src/lib/sprite-sheet.ts, src/workflows/game-cover.ts |
| Server-to-server calls | src/lib/internal.ts (INTERNAL_API_SECRET) |
Two base URLs matter (src/lib/users.ts): PUBLIC_BASE_URL is the public host for everything players see, and CRESCENT_APP_URL is where server code reaches the Next.js app directly, never through the edge.
Accounts
Every app starts on an anonymous account (POST /api/app/account), so playing needs nothing. Making or changing a game needs a registered account: Google on every app, Apple on iOS (POST /api/app/google, POST /api/app/apple). Signing in links the anonymous account, or switches to the account that identity already has. CRESCENT_REQUIRE_SIGN_IN turns the rule on or off; unset, it is on once GOOGLE_WEB_CLIENT_ID is set (signInRequired() in src/lib/sign-in.ts).
The API
The app API lives under /api/app/*. It answers JSON with Cache-Control: no-store, and most routes need Authorization: Bearer <session token>.
| Routes | What they cover |
|---|---|
/api/app/account, /me, /google, /apple, /config | Accounts and sign-in |
/api/app/games, /games/:id, /feed, /previews/:buildId | The player's games, the public feed, one shared version |
/api/app/games/:id/play, /games/:id/crash | Play time, crash reports |
/api/app/games/:id/merge, /games/:id/community, /contributions/:id/cancel, /contributions/:id/badges | Community builds |
/api/app/runs/:id/assets, /credits, /usage | Credits |
/api/app/chats, /files | Chat list, picture uploads |
/eve/v1/app/chats/:chatId/messages, /eve/v1/app/sessions/:sessionId/stream, /eve/v1/app/chats/:chatId/cancel, /eve/v1/app/chats/:chatId/build | Nova's chat channel (agent/channels/app.ts); the stream is NDJSON |
/api/internal/runs, /api/internal/meter/*, /eve/v1/app/internal/deliver | Server-to-server, with INTERNAL_API_SECRET |
Every app sends X-Crescent-Client: ios, android or web, and the server hands each one its own export of a version. See Three apps, one product.
Data
Postgres, with the schema in db/migrations/ (applied by npm run db:migrate, which records each file in schema_migrations).
| Table | Holds |
|---|---|
users | A player: anonymous until a Google or Apple identity is linked; credit balance |
games | One game, one repo: title, slug, repo name, cover |
app_chats | One conversation with Nova, tied to a game; contributing when it is about someone else's game |
game_builds | One playable version: version number, commit, one manifest URL per platform, notes, design document. Its random id is the share link. Drafts carry a branch and a draft number instead of a version |
game_runs | One worker run: request, status, progress line, result, error, the version it made |
game_contributions, contribution_badges | Accepted or declined community changes, and badges |
credit_ledger | Every credit change |
game_plays, game_playtests | Play time, and the results of scripted playtests |
service_tokens | Refreshed credentials for server-side services |
A partial unique index on game_runs allows at most one queued or running run per game and branch: one on the game's public branch, and one on each player's draft branch.
Storage
| What | Where |
|---|---|
| Accounts, games, chats, runs, versions, credits | Postgres (DATABASE_URL) |
| Every version's exported files, covers, chat pictures | Vercel Blob (BLOB_READ_WRITE_TOKEN), under games/<gameId>/... and chat/<userId>/... |
| Game source | GitHub, one private repo per game in GAMES_GITHUB_ORG |
| Nova's conversations | eve's durable sessions |
| Live run events | The edge's RunChannel Durable Object for that run |
| Game data (scores, rooms, saves) | The game's own Cloudflare Worker, when it has a backend |