Sharing
Every version of a game has a link anyone can open:
https://moonspell.app/g/<buildId>
The build id is a random UUID, and it is the only capability: whoever holds the link can play that version, and sees only its title, cover, version and notes, never the repo, the request or the chat (sharedBuild() in src/lib/app-clip.ts). Each version keeps its own link, so a shared link always plays the version that was shared. The share URL is built from PUBLIC_BASE_URL, never from request headers.
What happens when someone opens a link
| Where | What opens |
|---|---|
| A phone with the Moonspell app | The app, through a verified link for /g/*. It looks the version up (GET /api/app/previews/:buildId) and plays its own platform's export |
| An iPhone without the app | The shared-game page, with Safari's App Clip card. The App Clip plays that version without installing the app |
| Any browser | The shared-game page, with Play on web when the version has a web build |
In the app, a link covers the tabs with the game's cover and the download's progress until the game starts, and says so when it can't be opened.
The App Clip is part of the private iOS app. On the server side it only needs the preview route and the well-known file below.
The shared-game page
src/app/g/[buildId]/page.tsx is the game's card: the cover, the title, the version and the notes, then the ways to play:
- Play on web opens
/play/<buildId>in the web app, when the version has a web build. - The app button, on a phone only (a computer gets the one button alone, labelled Play). On an iPhone (
appClipPlayLink()): Play on App Clip with Apple's App Clip link onceCRESCENT_APP_STORE_IDis set; until then Open in the app, amoonspell://playlink that opens an installed app. With the app installed, iOS always opens the app, never the clip. - Play in the app on Android: an
intent://link (androidPlayIntent()) that falls back to the page without the app. - A download button, when there is somewhere to download from (
CRESCENT_APP_STORE_ID, elseCRESCENT_TESTFLIGHT_URL).
The page is not indexed. opengraph-image.tsx next to it draws the link preview. Safari's App Clip card comes from the apple-itunes-app meta tag, which the page only emits once CRESCENT_APP_STORE_ID is set. next.config.ts renders metadata into the head before the body so Safari reads that tag in time.
Well-known files
| File | Route | What it does |
|---|---|---|
/.well-known/apple-app-site-association | src/app/.well-known/apple-app-site-association/route.ts | Links /g/* to the iOS app and declares the App Clip |
/.well-known/assetlinks.json | src/app/.well-known/assetlinks.json/route.ts | Verifies /g/ links for the Android app; extra signing certificate fingerprints come from ANDROID_CERT_SHA256 |
Apple refuses this file behind a redirect or with the wrong content type, which is why the edge Worker serves it with no redirect, as application/json, cached for about five minutes (see Live progress).
Multiplayer invites
A room invite is the version's share link plus ?room=CODE. The app or App Clip passes the link it was opened with to the game, and the engine's useLaunchRoomId() drops the friend into that room. Making codes and invite links is part of the engine: see Networking.
Sharing from the apps
Every app shares the version's shareUrl (the system share sheet, or copy). The web player's share menu offers both the /g/<buildId> link and its own /play/<buildId> link. What each app does is listed in spec/features/share.md.