What changed, and why
A shared run (#88) already carries its objective in the public projection. This PR lets a viewer play that objective, not just read it: a "Play this objective" button on /r/:token starts a fresh run seeded with the shared run's captured GameObjective, reused verbatim — no model call, no reroll cost, no re-running #71's bounds (the objective was bounded when first composed). That closes a viral loop: play → share → someone replays → shares their run.
The replayed run also records its lineage and shows a one-hop "remixed from" credit. The whole design turns on one decision: lineage is a pointer, not a copy. We store a stable pointer to the parent run and walk it at read time to the parent's current title and opt-in name — so attribution always reflects the source's current privacy choice (un-share → the credit goes anonymous), and no identity is ever copied into the child.
Read the contract first, then the one column it rests on, then the two store methods that write and walk it, then the seam and the UI.
| Layer | File | What it does |
|---|---|---|
| Contract | utils/run-snapshot.ts | RemixCredit + PublicRun.remixedFrom |
| Schema | supabase/migrations/0007_run_lineage.sql | derived_from_run_id pointer column |
| Store | server/utils/run-snapshot-store.ts | stamp the pointer · walk one hop |
| Seam | server/api/run-save.post.ts · stores/game.ts | carry the source token · replay state |
| UI | MissionSelect · RunView · EndGameScreen · RemixCreditLine · pages/r/[token].vue | seed + render the credit |
The contract: a credit that's resolved, not stored
RemixCredit is the one-hop lineage of a replayed run. It is deliberately small: the source's opt-in display name (null when they shared anonymously), the source's objective title, and the source's live share token (for an optional backlink, null once un-shared). It carries no parent run id, no owner, no device. It hangs off PublicRun as an optional remixedFrom — a projection field, exactly like attribution already is, never part of the stored snapshot blob.
RemixCredit + the optional remixedFrom on the public projection.
utils/run-snapshot.ts · 161 lines
⋯ 114 lines hidden (lines 1–114)
⋯ 13 lines hidden (lines 149–161)
One nullable column carries the lineage
The pointer is a single nullable column on run_snapshots: derived_from_run_id, a foreign key to runs(id). It points at the parent run (null = an original, not a replay). ON DELETE SET NULL means a child outlives a deleted parent — the link just goes quiet, never a dangling reference — and the index serves the later "every run remixed from X" spread query.
Why the pointer is a stable run id, not the revocable share token.
supabase/migrations/0007_run_lineage.sql · 20 lines
Writing the pointer — stamped once, never nulled
Lineage is stamped at save time. The client only ever holds the public share token, so saveRun resolves it server-side to the parent's stable run id and writes that. Two safeguards make this robust: it guards self-derivation (parentId !== snapshot.runId), and — the key fix — it sets the column only on a successful resolve and never writes null. So a replay whose source un-shares mid-run keeps the pointer an earlier save stamped, and an ordinary run's upsert is byte-identical to before (the column is simply absent).
Supabase saveRun: resolve the token to a stable id; stamp on success, never null.
server/utils/run-snapshot-store.ts · 382 lines
⋯ 217 lines hidden (lines 1–217)
⋯ 135 lines hidden (lines 248–382)
Reading the credit — walk exactly one hop
getPublicRun reads the run by token (as before), then walks the pointer one hop: fetch the parent's row by run_id and project its current title, share name, and share token into a RemixCredit. toRemixCredit is the single place that decides anonymity — a blank/absent name becomes null ("an anonymous run" in the UI), and the token is carried only for the backlink. The owner-scoped reads (getRun, listRuns) never select the column, so the pointer stays server-side.
toRemixCredit (the anonymity rule) · resolveRemixCredit (the hop) · getPublicRun wiring.
server/utils/run-snapshot-store.ts · 382 lines
⋯ 81 lines hidden (lines 1–81)
⋯ 148 lines hidden (lines 102–249)
⋯ 86 lines hidden (lines 266–351)
⋯ 19 lines hidden (lines 364–382)
The seam: carrying the source token through a run
The token rides from the shared page to the save as a sibling of the snapshot, never inside the immutable blob. On the client, a replay is held in replayState — the seed objective, the source token, and the source's name at seed time. preseedReplay resets first (the share page may follow an abandoned run in the same tab) then records the seed; it survives chooseObjective so it can stamp lineage at save, and is cleared by resetGame so a fresh run carries none of it.
ReplaySeed + preseedReplay / clearReplayState.
stores/game.ts · 1455 lines
⋯ 211 lines hidden (lines 1–211)
⋯ 895 lines hidden (lines 231–1125)
⋯ 312 lines hidden (lines 1144–1455)
At save time the store appends remixedFromToken only when replayState is set, and the route uuid-gates it before handing it to saveRun. Best-effort throughout: a malformed or unresolved token is simply ignored.
saveRunSnapshot: the token rides alongside the snapshot, not within it.
stores/game.ts · 1455 lines
⋯ 1382 lines hidden (lines 1–1382)
⋯ 65 lines hidden (lines 1391–1455)
The route reads the sibling field, uuid-gates it, passes it through.
server/api/run-save.post.ts · 42 lines
⋯ 28 lines hidden (lines 1–28)
The UI: a focused replay, and one credit line everywhere
MissionSelect grows a replay mode: when replayState is set it shows just the seeded objective, ready to play, with the credit above it — not the full browse/compose briefing. begin() commits the seeded objective verbatim, so replay runs no generation. "Choose a different objective" drops the seed, so an opted-out run is never mislabeled.
The replay block, and begin() preferring the seeded objective.
components/MissionSelect.vue · 242 lines
⋯ 4 lines hidden (lines 1–4)
⋯ 194 lines hidden (lines 36–229)
The share page wires the button to preseedReplay then routes to /play, and passes its own remixedFrom down to RunView (so a shared run that is itself a replay shows its credit). The credit is rendered by one small shared component, RemixCreditLine, reused by RunView (the public + saved pages) and EndGameScreen (the live end screen) — name only where opted in, a backlink only while the source is still shared.
Play this objective → preseed → /play; the page's own remixedFrom.
pages/r/[token].vue · 159 lines
⋯ 42 lines hidden (lines 1–42)
⋯ 35 lines hidden (lines 51–85)
⋯ 56 lines hidden (lines 104–159)
The single credit renderer — anonymity + optional backlink in one place.