Theme Dash
Lifecycle: BETA — the current frontend and authoritative Nakama runtime are deployed; a real production finisher, DNF, attributed reward, leaderboard write, and responsive shell have been verified.
Theme Dash is Funday game id starter: a skill-first sprint/recover racer where players gain ground by sprinting, release to recover stamina, and finish before the authoritative round ends. It is a native Svelte component, not an iframe. Its public identity and beta state come from games/starter/funday-plugin.json.
Tutorial — race deliberately
- Choose a path. In standalone development, choose round length (30, 45, or 60 seconds) and pace (
chill,quick, orchaos), then select Start practice race. The client creates and joins acreate_time_trial_matchNakama match. In Funday, use the shell’s lobby/matchmaking flow instead; the game attaches to the match lifecycle supplied by the host. (games/starter/frontend/src/lib/StarterGame.svelte) - Opt in. Joining or creating a match does not ready the racer automatically. Select I’m ready! in the warm-up lobby;
session.allowSoloAutostartisfalse. (games/starter/funday-plugin.json,games/starter/shared/src/TimeTrial/index.ts) - Sprint and recover. Focus the race surface, then hold Space (or press and hold on the surface) to sprint. Release to recover stamina; the client sends only running intent and the Nakama engine remains authoritative. (
games/starter/frontend/src/lib/StarterGame.svelte,games/starter/nakama/runtime/src/server/engine.ts) - Read the result honestly. A finisher sees rank and elapsed seconds. A non-finisher sees
DNF · <authoritative progress>%; a DNF has no synthetic elapsed time. (games/starter/shared/src/TimeTrial/index.ts,games/starter/frontend/src/lib/StarterGame.svelte)
How-to — develop and verify
Run these package-script commands from games/starter; they are the exact commands declared in games/starter/package.json:
pnpm check
pnpm test:run
pnpm build
pnpm nakama:buildpnpm nakama:build builds the TypeScript runtime and copies nakama/runtime/build/index.js into nakama/data/modules/build/index.js. For the documented local stack, the game README requires the build before Compose and uses the e2e override that exposes Nakama at 127.0.0.1:17350:
pnpm nakama:build
docker compose -f nakama/docker-compose.yml -f nakama/docker-compose.e2e.yml up -d
pnpm devAfter a runtime source change, rebuild and restart the same local Nakama service:
pnpm nakama:build
docker compose -f nakama/docker-compose.yml -f nakama/docker-compose.e2e.yml restart nakamaThe package also defines pnpm test for Vitest watch mode and pnpm nakama:start for its base Compose configuration. Do not treat either local stack as production evidence. (games/starter/package.json, games/starter/README.md)
Reference — declared and wire-level contract
| Fact | Source-truthful value | Evidence |
|---|---|---|
| Identity and lifecycle | id starter; Theme Dash; beta; Funday Studios | games/starter/funday-plugin.json |
| Integration | svelte-component; entry frontend/src/lib/StarterGame.svelte; web game | games/starter/funday-plugin.json |
| Players and lobby | 1–8 players; lobby enabled | games/starter/funday-plugin.json |
| Backend | nakama-js, authoritative nakama-match, handler/proxy starter_match; modes quick, host, ai; ranked: false | games/starter/funday-plugin.json |
| Leaderboard | starter_highscores, time_attack, ascending order; runtime registers ascending / best so lower finish milliseconds rank first | games/starter/funday-plugin.json; games/starter/nakama/runtime/src/main.ts |
| Protocol | Client: Ready=1, Input=2, Chat=3. Server MatchResults=6; the shared type is the client/runtime wire source of truth. | games/starter/shared/src/TimeTrial/index.ts |
| Shell ownership | Funday owns lobby, acquisition, full chat, leaderboards, and dock/drawer chrome; the game owns the race surface/HUD. | games/starter/README.md; games/starter/AGENTS.md |
| Declared feature boundary | Chat and authoritative chat enabled; spectators and rematch disabled. | games/starter/funday-plugin.json |
Results, persistence, and rewards
ThemeDashRanking is discriminated: only { status: 'finished', finishMs: number } is a finished result; a DNF is { status: 'dnf', finishMs: null } and carries clamped authoritative progress. The engine creates finished status from finishTick !== null, not client timing. (games/starter/shared/src/TimeTrial/index.ts, games/starter/nakama/runtime/src/server/engine.ts)
At terminal state, the handler persists rankings once, broadcasts immutable MatchResults with user-attributed rewards, reports a persistence failure only to that user’s connected presence, emits the final game update, and ends the match. Persistence skips DNF rows, writes rounded finisher milliseconds to starter_highscores, and grants spark_trail only on a first successful inventory write. (games/starter/nakama/runtime/src/server/match_handler.ts, games/starter/nakama/runtime/src/server/rpcs.ts)
The tests cover DNF exclusion, first-finish reward attribution, storage and leaderboard failure isolation, and replay-safe inventory grants. The client completion tests cover finisher-only platform activity, local-only reward refresh, same-match duplicate suppression, and fresh-match re-emission. (games/starter/nakama/runtime/src/server/rpcs.test.ts, games/starter/nakama/runtime/src/server/match_handler.test.ts, games/starter/frontend/src/lib/completion.test.ts)
Explanation — accessibility and evidence boundaries
Accessible play surface
The race surface is focusable with role="application", game roledescription, and an explicit sprint label. It handles non-repeating Space down/up without stealing keyboard input from form fields or dialogs. It exposes a polite live status, labelled stamina and progress indicators with textual values, and an accessible Race result section/table. The How to play view is a labelled modal dialog. (games/starter/frontend/src/lib/StarterGame.svelte)
What beta does and does not mean
The manifest’s beta status describes an implemented game that is still explicitly labelled beta; it does not erase deployment evidence or imply general availability beyond that lifecycle label. Local-authoritative Fun Ops evidence covers a 60-second quick-race finisher and a 30-second no-input DNF. Production evidence recorded below independently covers current-bundle identity, live matchmaking, a real two-browser terminal result, reward and leaderboard persistence, and responsive shell rendering. (docs/fun-ops/audits/2026-07-28-starter-fun-audit.md, docs/fun-ops/plans/2026-07-28-starter-fun-plan.md)
Production proof — verified 2026-07-28
The deployed Nakama pod nakama-5fd979759f-f62rt was ready 1/1 on image heroiclabs/nakama:3.32.0 with immutable image ID sha256:30644d4f62f5d60c9499433b1946a63ea180eedd45cc62699c5e0253cb3d8234. The host bundle and /nakama/data/modules/index.js inside that pod both had SHA-256 658f794b63cfe993eb80e2f343964fd62481f067b4f80e0afbfc9644291c3df3. Startup logs confirmed starter_highscores and create_time_trial_match; live matchmaking logs recorded starter_match creation.
The host funday-frontend.service was active and https://funday.gg/play/starter returned HTTP 200. Two isolated browser sessions joined the same quick match as HastyGnocchi6967 and PlayfulBerserker7036, explicitly readied, and completed a 45-second quick round. The authoritative result rendered HastyGnocchi6967 as #1 in 37.10s with Spark trail unlocked and PlayfulBerserker7036 as DNF · 0%. The shell received the root activity payload and displayed Finished #1 in 37.10 seconds for the finisher and DNF, 0% completed for the non-finisher. PostgreSQL then contained HastyGnocchi6967|37100 in starter_highscores and {"owned":["spark_trail"],"version":1,"equipped":null} in starter_cosmetics/loadout_v1.
The browser run also verified quick-match discovery, a shared 2/8 lobby, explicit readiness, the sprint/recover loop, truthful terminal rows on both clients, and local-only reward presentation. Targeted PERSIST_FAILED injection remains a regression-test concern rather than a production fault exercise.
Responsive production captures


These captures show the deployed pre-match shell at 1440×1000 CSS pixels and 390×844 CSS pixels. Both preserve Theme Dash identity and avoid horizontal overflow. The pending Connecting… state visible in the captures was transient in those screenshot sessions; the later two-browser match above proves the same deployed route connected, matched, raced, and persisted a result.
Free-roam production proof — verified 2026-07-30
/play/starter lands in a shared free-roam park with zero clicks and no matchmaking UI. Verified live on https://funday.gg:
- Exactly-once activation — one
POST /api/matchesand onematch_joinfor a whole session, holding across drawer toggles and reactive churn. Pooled hosts stay inert on non-play routes (zero requests). - Movement —
ArrowRightproduced authoritative opcode-4RoamInput{sequence:1,x:1,z:0}in 448 ms with changed canvas pixels, and a zero vector on release; 799 authoritativeRoamUpdateframes, zero page errors. - Shared shard via invite — a second isolated browser opening
?join=<matchId>&mode=roamjoined the host’s shard: labelplayers: 2, both HUDsFree roam — 2 in the park. - Roster mirroring — the
Theme Dash mirrors the authoritative roam roster into both drawersPlaywright spec passes against production, including in-world face avatars and theme-derived racer colors. - Responsive shell — the native viewport spec passes on desktop and mobile viewports.
Fixes landed with this proof: idempotent reuseExisting roam re-attach; Set-Cookie preservation in the SSR proxy (three identity cookies were collapsing to one, causing a two-principal mismatch and 409 username storms); deterministic DiceBear racer faces; host-editable capacity carried inside GameUpdate.settings; invite mode derived from the joined match instead of the address bar; host authority preserved across snapshots that omit hostUserId; and bounded Nakama socket auto-reconnect with a live-match rejoin (an idle drop used to be terminal, leaving a stale roster).
Full lifecycle proof — verified 2026-07-30
The whole joins a shared roam, races explicitly, then recovers to roam spec passes against production, and the Theme Dash suite is 5/5 green (roster mirroring, mobile shell, full lifecycle, native viewport, and the shared Connect 4 iframe viewport contract).
Defects this closed:
- Host authority in a game-created race — races the game creates through
funday:request-matchnever pass the drawer’s join flow, so no label creator was recorded and the host saw the read-only settings summary. The shell now records creation as a local fact (lobby.selfCreatedMatch) and derives host with explicit precedence: an authoritative roster host wins when this client is identifiable in it (host transfer still demotes), otherwise creation evidence decides — including rosters that name no host at all, like roam shards. - Stale-match snapshot hijack — a late
funday:lobby-statefrom the shard a player had just raced out of rewrotelobby.matchIdand repopulated the previous roster, wiping the new match’s host authority. Snapshots are now scoped to the live match. - Cross-match creator leakage — a creator recorded for a previous match (an open roam shard someone else created) could decide host for the next one; the recorded creator is now scoped to its own match id.
- Parks evaporating during a race — a roam shard was reaped on the race timescale (60 s empty), so two players who raced together could return to two different parks. A roam shard now outlives a full round and its results screen (
THEME_DASH_ROAM_EMPTY_TIMEOUT_TICKS, 300 s), while race matches keep the short reap.
Race semantics confirmed on the wire: readiness is explicit on every client (there is no host Start control in this contract), a round is exactly its configured length (MatchStarted → MatchFinished measured at ~60 s for roundSeconds: 60), settings are host-owned during WarmUp and mirrored to peers as a read-only summary, a third player is rejected from a two-player race, and finished-versus-DNF standings are server-derived.
Atomic park convergence — verified 2026-07-30
The 300 s roam grace narrowed the “two racers, two parks” window but did not close it: list→create is not atomic, so two clients asking for roam in the same instant could each create a park. roam now publishes its shard through a compare-and-swap rendezvous record — see Mode policies for the record shape, the adoption rule, the isolation rule, and the storage-outage caveat.
Verified live, after the record owner was fixed (the first cut used userId: '', which Nakama rejects, so the rendezvous silently never ran — the Nakama log line rendezvous read unavailable was the tell):
- Two isolated browsers navigating
/play/starterconcurrently returned the samematch_idon 5/5 runs. - Four simultaneous roam requests against a stale record all returned the same
match_id, with exactly oneSUCCESS new_matchand onerendezvous publishedin the runtime log — one park, one create. - Isolation still holds: a request that excludes the recorded park gets a private shard and leaves the shared pointer untouched, which is what keeps
?join=<id>harness setups and private shards separate.
Test-suite constraints on this host
- The Theme Dash spec runs serially (
test.describe.configure({ mode: "serial" })). Two concurrent WebGL tests starve the software renderer on this shared VM: three separate 2-worker experiments went red with an absent roster peer, a stalled runner, and viewport timeouts — all resource starvation, not product defects. Flip the serial mode and raiseworkersonly on a host with spare cores. - Timing evidence must come off the wire, not from point-in-time DOM sampling. At load average ≥ 15, a Playwright CDP call can cost seconds: a 5.5 s sprint cadence measured 20 s per cycle, which starved the runner into a DNF. The race cadence is therefore pumped inside the page (after a real held key proves the input pipeline), chat bubbles are captured in one in-page pass because they live ~6 s, and animated viewport geometry is asserted as a settled end state.
- A latent harness bug is fixed:
browser.newContext()does not inherituse.baseURL, so relativepage.requestcalls on hand-made contexts threwInvalid URLas soon as a context was used before its first navigation. Every manual context now passesbaseURLexplicitly.
Canonical sources
games/starter/funday-plugin.json— public identity, beta lifecycle, integration, backend, lobby, leaderboard, feature, session, and dock declarations.games/starter/shared/src/TimeTrial/index.ts— shared messages, discriminated rankings, and opcode values.games/starter/frontend/src/lib/StarterGame.svelteandgames/starter/frontend/src/lib/completion.ts— entry, controls, presentation, accessibility, local effects.games/starter/nakama/runtime/src/server/— authoritative engine, terminal delivery, and persistence.games/starter/README.md— local Compose workflow.