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

  1. Choose a path. In standalone development, choose round length (30, 45, or 60 seconds) and pace (chill, quick, or chaos), then select Start practice race. The client creates and joins a create_time_trial_match Nakama 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)
  2. Opt in. Joining or creating a match does not ready the racer automatically. Select I’m ready! in the warm-up lobby; session.allowSoloAutostart is false. (games/starter/funday-plugin.json, games/starter/shared/src/TimeTrial/index.ts)
  3. 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)
  4. 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:build

pnpm 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 dev

After 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 nakama

The 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

FactSource-truthful valueEvidence
Identity and lifecycleid starter; Theme Dash; beta; Funday Studiosgames/starter/funday-plugin.json
Integrationsvelte-component; entry frontend/src/lib/StarterGame.svelte; web gamegames/starter/funday-plugin.json
Players and lobby1–8 players; lobby enabledgames/starter/funday-plugin.json
Backendnakama-js, authoritative nakama-match, handler/proxy starter_match; modes quick, host, ai; ranked: falsegames/starter/funday-plugin.json
Leaderboardstarter_highscores, time_attack, ascending order; runtime registers ascending / best so lower finish milliseconds rank firstgames/starter/funday-plugin.json; games/starter/nakama/runtime/src/main.ts
ProtocolClient: 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 ownershipFunday 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 boundaryChat 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

Theme Dash production desktop shell with the lobby drawer open

Theme Dash production mobile shell with the bottom-sheet lobby open

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/matches and one match_join for a whole session, holding across drawer toggles and reactive churn. Pooled hosts stay inert on non-play routes (zero requests).
  • MovementArrowRight produced authoritative opcode-4 RoamInput {sequence:1,x:1,z:0} in 448 ms with changed canvas pixels, and a zero vector on release; 799 authoritative RoamUpdate frames, zero page errors.
  • Shared shard via invite — a second isolated browser opening ?join=<matchId>&mode=roam joined the host’s shard: label players: 2, both HUDs Free roam — 2 in the park.
  • Roster mirroring — the Theme Dash mirrors the authoritative roam roster into both drawers Playwright 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-match never 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-state from the shard a player had just raced out of rewrote lobby.matchId and 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 (MatchStartedMatchFinished 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/starter concurrently returned the same match_id on 5/5 runs.
  • Four simultaneous roam requests against a stale record all returned the same match_id, with exactly one SUCCESS new_match and one rendezvous published in 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 raise workers only 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 inherit use.baseURL, so relative page.request calls on hand-made contexts threw Invalid URL as soon as a context was used before its first navigation. Every manual context now passes baseURL explicitly.

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.svelte and games/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.

0 items under this folder.

    Ask Docs

    AI assistant to help answer questions about the documentation. Answers are read-only and cite docs/source.

    Hi! How can I help you with the documentation today? Answers are read-only and cite docs/source.

    Ctrl+Enter to send