ScribblaZ Verification
This page separates source-confirmed contracts from browser and multi-client evidence that still must be collected.
| Field | Value |
|---|---|
| Play route | https://funday.gg/play/scribblaz |
| Server timing | 5 Hz; 10 s word pick, 3 s round end, configured 30–180 s drawing |
| Required active players | At least 2 |
| Authority | server/match_handler.ts |
Docs: Hub · Architecture · Game design · Opcodes · Runbook · Nakama Multiplayer · Games Catalog
Evidence checklist
| Claim | How verified | Status |
|---|---|---|
Plugin config is scribblaz with scribblaz_ts_match, server/match_handler.ts, 5 Hz, and 2–12 players. | Read funday-plugin.json:3-45, :28-29. | Source confirmed |
Server uses waiting → word_pick → drawing → round_end → game_end. | Read GamePhase in src/lib/gameState.ts:40-45 and transition/tick logic in server/match_handler.ts:621-791, :1089-1163. | Source confirmed |
A stored real match requests rejoin and sends REQUEST_SYNC after confirmation/reconnect. | Reload a client that is inside a live match and confirm it lands back in the match rather than free draw. | Verified 2026-07-31 · 2 clients · isolated stage on 127.0.0.1:5200 against production Nakama · after reload the page hopped …/play/scribblaz → …?join=<matchId> → back to a clean URL, and the roster showed 2/12 · HungryCreepe7ea39cf2, TerrificKangaroo4339 with the stored id intact |
| A stale stored match ID falls back to solo/free draw. | Set localStorage.scribblaz_matchId to a bogus ID, reload, and watch storage plus the phase. | Verified 2026-07-31 · 1 client · cleared and in free draw after 5.9 s, via hops /play/scribblaz (1.3 s) → ?join=bogus-match-id (3.6 s) → clean URL (4.9 s). Note the timing change: the old 3 s figure predates the rejoin redirect. A bogus id now costs one extra route hop, which is the price of a live match surviving reload at all. |
A drawer reconnecting during word_pick receives its options again. | Two clients, drawer reloads while the picker is open; confirm the options return and the server logs the re-send. | Verified 2026-07-31 · match a9440d65-7530-4b5b-b94f-c60f02e9536c · picker returned after 5.4 s with the identical options Profit / Fork / Frequency, and Nakama logged [scribblaz] word_pick resync: re-sending 3 options to drawer f68333a3-6daa-4553-baa6-1eb63c395581 |
| A match below two connected players in an active phase resets to waiting rather than fast-forwarding. | Disconnect one client from a two-player match in an active phase; observe the system message, the lobby, and the server log. | Verified 2026-07-31 · match a0640981-6332-4257-abee-9646debfcbee · Nakama logged [scribblaz] quorum reset: 1 connected in phase drawing — returning to lobby; the surviving client showed chat System — Not enough players — returning to lobby. and returned to WaitingLobby at 1/12 |
| Non-drawer word-pick notice and final-round presentation are visible and correctly layered. | Open word_pick with two clients and read both views. | Verified 2026-07-31 · the drawer saw Pick a word to draw with three plaintext words; the guesser simultaneously saw Drawer is choosing a word · 5s · Get ready to guess. from PhaseNotice, with no picker mounted |
| Drawer round-end autosave sends structured private art. | Complete a round as drawer and inspect the outbound RPC. | Verified 2026-07-31 (contract corrected) · the call now goes to POST /api/plugins/rpc with {pluginId:'scribblaz', method:'scribblaz_save_drawing', payload:{word, drawerName, lines, isPublic}}. The previous /api/rpc/* URL does not exist on the platform and returned the SvelteKit 404 document; see the note below. |
| Publish sends exactly one public structured save and gallery displays it. | Exercise Publish and Public Gallery. | Verified 2026-08-01 · two clients, real match c794bf1b-2a62-4af0-9f97-bb8e9acc6497, round 1 word triplets ended by a correct guess. Publish in ScoreReveal returned success, scribblaz_get_gallery returned a non-empty drawings array (word: triplets, by: HeavenlyBaguette9026, 7 stroke events), and the grid rendered it — thumbnail canvas measured 1156 non-white pixels, not a blank tile. This closes 10.14. |
| The gallery RPC actually receives its arguments. | Call an argument-taking RPC through the platform gateway. | Verified 2026-08-01 (defect found and fixed) · Publish failed live with Missing word or lines. client.rpc already JSON-encodes its input (nakama-js.esm.mjs: rpcFunc(token, id, JSON.stringify(input))), so the gateway’s pre-stringified payload encoded twice; the handler’s JSON.parse yielded a string and every field read undefined — at HTTP 200. scribblaz_get_gallery hid this for a full cycle because all of its fields have defaults, which is exactly why the earlier {limit:5} probe proved nothing. Gateway now passes the object; a real Publish and scope:'mine' both carry their arguments. |
| A player’s own round-end saves are reachable. | Open the gallery’s Mine tab. | Verified 2026-08-01 · every round-end autosave writes isPublic:false under the drawer’s own id while the RPC listed only the system user, so those objects were unreachable storage. With scope, the Mine tab returned the player’s own triplets autosave and aria-pressed tracked the active tab. |
| Drawing RPC bounds what it stores. | Probe the size cap and the publish cooldown against production. | Verified 2026-08-01 · a 210,221-byte lines payload returns drawing too large while 196,511 bytes is accepted. The cap had to move: Nakama refuses a request body over ~256 KB (261,403 accepted, 278,083 → http: request body too large), so the original 512,000-byte cap could never fire and every oversized save surfaced as an opaque transport error. A second publish inside 30 s returns too fast. |
| Drawing RPC supports private/public writes and public listing. | Read server/rpc.ts; unit-tested in server/rpc.test.ts. | Source confirmed + 13 unit tests |
| Existing checklist distinguishes completed dock/palette polish from open gallery/public-toggle and trainer work. | Read CHECKLIST.md:50-67. | Source confirmed |
| Keyboard focus is always visible inside the game root. | Tab within .scribblaz-root and read the computed outline. | Verified 2026-07-31 · focused Brush button matched :focus-visible with outline: 2px solid oklch(0.45 0.24 277.023) and outline-offset: 2px |
| Free-draw canvas is actually persisted and restored. | Draw in free draw, watch the storage write, reload, compare the canvas. | Verified 2026-07-31 · triggerFreeDrawSave had computed the PNG and discarded it, so nothing was ever written; after the fix one debounced POST /api/storage/write carried 57712 bytes (collection: scribblaz, key: freedraw), and after reload GET /api/storage/read?collection=scribblaz&key=freedraw restored the canvas at byte-identical size 57574 |
| Undo does not destroy a restored free-draw drawing. | Restore, draw one stroke, undo, redo — compare non-white pixel counts, not byte lengths. | Verified 2026-07-31 · restored 15252 ink px → +stroke 29637 → undo 15252 → redo 29637, both exact. Resize retains the base too: 15252 → 12825 while canvas area fell to 0.842×, i.e. exactly proportional |
| Clear hides the restored drawing and undo brings it back. | Right-click the Eraser (the clear secondary action) then press Cmd/Ctrl+Z. | Verified 2026-07-31 · ink 12825 → 0 on clear → 12825 on undo, exact. Also covered by 5 state-machine tests in canvasEngine.test.ts, including that a canvas with no restored base keeps its old clear-on-empty no-op |
| The plugin RPC gateway accepts a modern session and forwards arguments. | Call it as a guest holding only funday-identity. | Verified 2026-07-31 · it read only the legacy funday-session cookie and 401’d every current session; it then 500’d by JSON.parse-ing an object payload; and it delivered {payload:"…"} instead of Nakama’s JSON string, so argument-taking RPCs saw nothing. After all three fixes scribblaz_get_gallery {limit:5} returns 200 {"success":true,"drawings":[],"cursor":null} |
| Privileged namespaces cannot transit the public gateway. | POST with pluginId: "kanban". | Verified 2026-07-31 · returns 400 invalid_method without reaching Nakama. This was only defence in depth: the dev board and gameFeedback store call kanban_* directly through the Nakama client, so the gateway was never the main entrance — see the row below for the real fix |
| Kanban board mutations are authorized server-side. | Deploy the module, then drive a real guest session against production Nakama. | Verified 2026-08-01 · all seven mutating RPCs were probed individually in one sweep with a genuine guest session — save_board, save_feedback_board, bulk_import, delete_ticket, update_ticket_status, seed_games, set_operators — and every one returned forbidden and wrote nothing. Each now goes through requireOperator, which owns the audit log; add_ticket calls the silent predicate instead so ordinary player traffic never writes a denial line. |
| Authority cannot be granted by anything a client controls. | Read the gate; probe the transport. | Verified 2026-08-01 (narrowed since last cycle) · authority is now user ids only, held in kanban_admin/operators under the system user with permissionRead: 0, permissionWrite: 0. Username matching was removed because usernames are client-settable here (/api/auth/register calls updateAccount with a user session), so a renamed or deleted operator freed a name the next account could claim. The dead DEV_ACCESS_* env branches are gone — they were never plumbed into the Nakama runtime yet read as live policy. Account metadata is still not consulted. Sessionless dispatch was measured, not assumed: the public server key is refused as basic auth and as ?http_key= (both 401), an unauthenticated call is refused, and only the secret runtime key returns 200. ⚠️ That last property depends on runtime.http_key staying secret and distinct from socket.server_key. |
| Operator provisioning resolves names to ids, and says so when it cannot. | Re-provision against production and read unresolved. | Verified 2026-08-01 (defect found and fixed) · the first run returned all seven names in unresolved and collapsed the live allowlist to a single id: normalizeList lowercased every entry, but nk.usersGetUsername matches exactly. Ids stay folded, names keep their case; the re-run resolved 7/7 with unresolved: [] and 8 operator ids stored. This is why the contract returns unresolved instead of dropping names silently. |
| Player feedback submission still works and cannot be abused. | File tickets from an ordinary guest session against production. | Verified 2026-08-01 · accepted on the seeded board with the caller’s own id preserved (fb-live-probe-0801) so the client’s local copy is not orphaned, a forged status: "resolved" stored as open, and an implausible createdAt: 1 replaced with server time. A repeat of the same id returned success with one stored copy — idempotence is checked before the cooldown precisely so a retry never reads as a failure — while a second distinct submission inside 60 s returned too fast. An unknown slug returns unknown game; a known game with no seeded board now has one created rather than losing its feedback button. The board refuses at 500 items (board full) instead of evicting the oldest real tickets. |
| A rejected kanban write is reported as a rejection. | Read the frontend sync helpers. | Verified 2026-08-01 · syncTicketAddedToNakama and its two siblings returned true for any resolved RPC without reading the payload, so forbidden — and the new board full / unknown game / too fast — all reported as successful writes and invalidated the SSOT cache. All three now parse the payload and require success === true. |
Manual test record
For every pending row, record the date, route/environment, number of clients, exact observed phase/messages, and any RPC payload. Do not upgrade a pending row based only on source inspection: source confirms intent and implementation visibility, not deployment or interactive behavior.
Current-cycle boundary
Every server-side row above was proven against production Nakama. The module is hostPath-mounted from /home/usr/funday/nakama-modules, so shipping it is npm --prefix nakama-modules run build followed by sudo k3s kubectl -n funday-platform rollout restart deployment/nakama; the runtime confirmed the load with ✅ scribblaz_ts_match registered (TypeScript runtime). Client-side rows were proven on an isolated detached-master worktree served by Vite on 127.0.0.1:5200, proxying /api and /ws to https://funday.gg with the Origin/Referer headers rewritten so the CSRF guard accepts them. Production was deliberately not rebuilt: the shared tree carries other agents’ uncommitted work, which AGENTS.md §01.2/§01.5 forbid shipping.
Three defects were found by running the thing rather than reading it, and all three are fixed in this cycle:
- The gallery RPCs were unreachable.
drawingPersistence.tsposted to/api/rpc/<method>, which no route serves; the platform contract isPOST /api/plugins/rpcwith a{pluginId, method, payload}envelope, and the gateway rejects any method not prefixed by its plugin id. Both call sites were corrected and the scoped test now asserts the envelope rather than the old URL. - The drawer could never resync.
matchLeaveended the round the instant the drawer’s presence dropped, so a reloading drawer always came back to a finished round — the re-send branch was unreachable.word_pickis now excluded from both the round-end and the quorum-reset branches and rides out its bounded 10 s window instead, with the quorum rule re-applied at pick expiry so a match can never stall. - A reload always dropped the player. The shell creates its Nakama socket lazily from lobby/route actions, while
funday:request-matchonly reads it — so a game asking to rejoin right after mount always gotSocket is unavailable, measured 7 refusals over 2.4 s. Retrying in place cannot work; the game now hands the rejoin to the route’s?join=entry point, once per match per tab.
Nothing from that list remains open: 10.14 was closed on 2026-08-01 with a real two-client round, a real Publish, and a rendered grid.
2026-08-01 cycle — hardening
This cycle shipped to production. master was deployed with scripts/build-atomic.sh and the Nakama bundle was rebuilt and rolled out. Four defects were found by running it, three of which source review had already passed over:
- Operator authority could be inherited. The gate matched an allowlisted username, and usernames are client-settable here — a renamed or deleted operator freed a name for the next account to claim. Authority is now the Nakama-resolved user id alone.
- Provisioning silently unresolved every operator.
normalizeListlowercased entries, butnk.usersGetUsernamematches exactly, so the first live re-provision returned all seven names inunresolvedand left one operator id standing. Caught only because the RPC reportsunresolvedinstead of dropping names. - The plugin gateway delivered no arguments.
client.rpcalready JSON-encodes its input, so the gateway’s pre-stringified payload encoded twice and every RPC field readundefinedat HTTP 200. It survived the previous cycle because the only RPC exercised through the gateway,scribblaz_get_gallery, defaults every field it reads. A live Publish answeredMissing word or linesand exposed it. Its unit test had asserted the buggy contract against a mock. - The drawing size cap was unreachable. 512,000 bytes sits above Nakama’s ~256 KB request-body limit, so oversized saves died in the transport with an opaque error and the guard never ran. Measured the ceiling (261,403 accepted / 278,083 refused) and moved the cap to 200 KB.
Operator provisioning (current procedure)
Authority lives in one system-owned object, kanban_admin/operators, with permissionRead: 0, permissionWrite: 0. Its value is { userIds: string[], resolvedFrom: { [username]: userId } }; only userIds authorizes — resolvedFrom is an audit trail. Provision through the runtime HTTP key, which is the only transport Nakama dispatches sessionlessly:
IP=$(sudo k3s kubectl -n funday-platform get svc nakama -o jsonpath='{.spec.clusterIP}')
REAL=$(sudo k3s kubectl -n funday-platform get secret nakama-runtime-secrets -o jsonpath='{.data.runtime_http_key}' | base64 -d)
curl -s -X POST "http://$IP:7350/v2/rpc/kanban_set_operators?http_key=$REAL" \
-H 'Content-Type: application/json' \
-d '"{\"userIds\":[\"<uuid>\"],\"usernames\":[\"ExactCaseName\"]}"'Usernames are case-sensitive and are resolved to ids once, here. Require unresolved: [] in the response; a name that does not resolve has no Nakama account — report it, never invent an id. Re-provision in the same window as a rollout that changes the stored shape, or session-backed operators lose board writes until you do.
⚠️
.agents/skills/funday-game-feedback-kanban/SKILL.md§0 still documents the superseded procedure (nokanban_set_operators, no provisioned user id). Agents must not edit user-authored skills, so this section is the current source of truth.
2026-08-01 cycle — backlog close
The remaining CHECKLIST.md backlog shipped in the same session: reduced-motion and haptics, fluid type and a portrait hint, component extraction with lazy modals, emotes and likes, the Drawing Trainer, and a two-client Playwright spec. 76/77 items are now closed; the one open item is 3.4 animation mode, deferred by design.
Three defects surfaced only by playing the deployed build, and each was invisible to the test suite that “covered” it:
- A single Like ended the match.
likesThisRoundwas aSetin match state. Nakama does not preserveSetidentity between ticks, so it returned as plain data andhandleRateDrawingthrewTypeError: Object has no member 'has'— and Nakama stops a match whose loop throws. The unit tests passed throughout because vitest keeps a realSet; the regression test now round-trips the state through JSON, which is what actually reproduces the runtime. - Emotes could never be sent. Outgoing match state is base64-encoded with
btoa, which throws on any code unit above U+00FF, and the send resolves asynchronously so the throw escapedsend()’stry. The wire now carries an index intoALLOWED_EMOTES, andsend()escapes any remaining non-Latin-1 character — a player typing an emoji into a guess hit the identical wall. - The relay handler was dead on arrival. The controller used
ALLOWED_EMOTESwithout importing it, throwingReferenceErrorthe moment a float mounted.svelte-checkreported clean the whole time:games/is outside the frontend tsconfig, and the platform’s undefined-identifier guard reads.sveltetemplates only, never.svelte.ts. The rule now lives in a puredecodeEmote()the root runner can execute.
| Claim | How verified | Status |
|---|---|---|
| Emotes relay between clients, and the throttle holds. | Two contexts, live match. | Verified 2026-08-01 · 🔥 clicked on one client rendered on the other in 145 ms with the correct character and the … reacted with label. Of three in a burst (👏, 🤔 at +300 ms, 🎨 at +3 s), only 👏 and 🎨 relayed — the server dropped the one inside the 2 s window. |
| A like counts once and the match survives it. | Two contexts, round end. | Verified 2026-08-01 · the liker’s control moved 👍 0 Like → 👍 0 Liked (optimistic) → 👍 1 Liked from the server’s RATING_UPDATE, the drawer’s own control read Your drawing and stayed disabled, and the match went on to the next round with zero match_loop errors in the pod log. |
| Trainer mode unlocks and scores. | Konami sequence in solo free draw. | Verified 2026-08-01 · unlocked Drawing Trainer · EASY with motif 🌳 Tree and progress 0/3; a rough three-stroke doodle scored 7% and reported the tier back. The motif fetch now fires on activation, not on mount. |
| Reduced motion is honoured. | DevTools emulation of prefers-reduced-motion: reduce. | Verified 2026-08-01 · of 106 elements inspected under .scribblaz-root, zero retained a non-trivial animation or transition duration. |
| No hard-coded pixel type remains. | grep -rn 'text-\[[0-9]' games/scribblaz/src. | Verified 2026-08-01 · no matches; every size is a clamp() whose ceiling is the previous fixed value. |
✅ Resolved 2026-08-03:
games/micro-racing,games/pacman,games/tetrisandgames/wolfenfunall have trackedfunday-plugin.jsonmanifests (git ls-files --error-unmatchsucceeds for each), so the committedgame_registry.generated.tsregenerates identically in a cleanHEADworktree.bash scripts/build-atomic.sh --from-headcompleted with its registry-freshness gate passing inside the deploy worktree and restartedfunday-frontend(ActiveEnterTimestamp=Mon 2026-08-03 09:37:02 CEST). TheAGENTS.md§01.2 deploy protection is intact.
2026-08-01 cycle — visual review
The previous cycle verified the trainer behaved — it unlocked, scored 7%, and reported its tier — and shipped it. Nobody looked at it. Playing it shows why that is not enough, and player feedback in the lobby chat had already said so: “Drawing Trainer ultra thrashy ui/ux and broken”.
The trainer told the player to “trace the key lines” over an empty canvas. TRAINER_MOTIFS has carried full stroke geometry from the start, but nothing ever rendered it: the UI showed the motif’s emoji on a 24 rem card parked over the drawing area. With no target on the canvas, scoreDrawing was comparing a sketch against an invisible reference — a recognisable star scored 0%. The 7% recorded above was noise, not a score.
The motif is now the interface. It paints onto its own canvas layer and the card is gone.
| Claim | How verified | Status |
|---|---|---|
| The motif renders on the canvas and the score means something. | Solo trainer, traced the ghost by hand. | Verified 2026-08-01 · a rough trace of Sun scored 52%, where the same effort against no reference scored 0%. |
| The ghost never becomes user ink. | Sampled every canvas layer after Next. | Verified 2026-08-01 · on a fresh motif the stroke layer held 0 ink samples while the trace layer held 2174, so the ghost cannot reach canvasEvents, the gallery payload or scoreDrawing’s input. |
| Motifs keep their authored proportions and corners. | Cycled House, Tree, Star on a 1440×~700 canvas. | Verified 2026-08-01 · square walls, pitched roof and door; clean triangle and trunk; sharp five-point star. Previously the canvas aspect stretched every motif and drawStroke’s bezier smoothing rounded the house’s walls into an oval. |
| Chat survives a burst without drowning the drawer. | Lobby drawer against real messages. | Verified 2026-08-01 · a run of 9 messages from one sender now costs one name line instead of 9 avatar + header + bubble blocks; own messages still read distinctly as You. |
Two notes for later, deliberately not acted on here:
renderTraceandscoreDrawingsharefitTrace. They must keep sharing it — the moment the ghost is drawn with one mapping and scored with another, the player traces one shape and is graded against a different one. That is the exact class of bug this cycle fixed.- A player asks in chat: “what the fudge is the 2nd Chat message and where does it even end up using the other chat input?” — ScribblaZ’s in-game
GuessChatand the platform drawer chat are two separate inputs with no relationship shown. Real confusion, out of scope for this pass.
⚠️ Free-draw autosave is rejected in production and nothing surfaces it. A single click on the solo canvas fires the debounced
POST /api/storage/write, and Nakama answers400 — "Storage write rejected - permission denied."The drawing is silently not saved. This is not caused by the trace work: a plain page load produces zero failing requests, and the same click reproduces the 400 with the trainer never activated. It contradicts the10.15row verified on 2026-07-31, which observed a successful 5771-byte write — so either the write permission changed server-side or the earlier check ran as a differently-privileged account. Needs its own pass; the client should at minimum report the failure rather than discard it.