01-projects/printables-product

Scribble Works "Customize a game" — build spec (Stages 1-3)

2026-09-01·status: spec
scribble-worksproductengine

Customize a game — build spec

Trust-ladder rung 2. A parent types a sentence about their kid; the printed page is theirs. Stage 1 needs no accounts, queue, or new bill.

1. The moment, in six beats

  1. Door. On /game/<slug>/, beside Print: "Make it theirs". A paper-slip drawer with two fields, "Their name" and "About them" (placeholder "she loves horses and purple, we speak Spanish at home"), and an honest line: "Their name stays on this device. The rest goes to the model that draws the page; we keep none of it. Three a day keeps this free."
  2. Ask. Tap "Customize". Pencil-scribble loader, Ray: "Sharpening my pencil…". Target 3-8 s; 12 s: "Still going"; 25 s: abort to beat 5.
  3. Reveal. The game re-renders in the frame motif, changed slots pulsing once. Ray says what changed and what he could not do: "Horses need new pictures and purple needs a colour slot, which I can't do yet. I put Mae's name on it and switched to Spanish."
  4. Print. window.print() on the iframe opens the print dialog; Ray says "Headers and footers off, margins Default." (Chrome honours the page's Letter size; Safari/Firefox use the dialog's.) "Back to the original" restores the untouched page; another try counts against the three.
  5. Failure. Every failure leaves the original on screen and printable. Invalid output after one retry: "I couldn't make that one work. Try fewer requests." Rate-limited: "That's three for today." Network/5xx: "The pencil broke." Each ends "The original still prints."
  6. Keep (Stage 2). "Put this in my playset" renders a server PDF into a tray slot, badged "kept for 24 hours".

2. Input contract and privacy

3. Stage 1 engine (no server rendering)

Prerequisite: font pin (ruling 3). Every game.html uses system fonts (Chalkboard SE, Comic Sans MS), which Windows and Linux lack. Bundle one open font via @font-face in all 18 games and re-render the library.

Serve the HTML. Prebuild copies content/games/<slug>/game.html to public/games/<slug>/game.html (gitignored like game-previews/): same origin, so the page can iframe and script it.

Slot manifest. scripts/build-slot-manifests.mjs parses every game.html into src/data/slot-manifests.json: model-writable slots (kind, constraints, value, language); never data-answers or data-slot-lock elements.

Pages Function POST /api/customize: rate gate (§6) → scrub → Claude via AI Gateway (Unified Billing, ruled in); system = slot rules + Scribble Works voice, user = manifest + scrubbed text, structured output against a closed schema (additionalProperties: false, one optional field per slot) → validate: length (name 24, title 32, ask 80, ray 90, instruction 110), chars, lang, palette membership (src/data/sw-print-palette.json = sw-print-v1, 8 fills), no PII, no markup, {{name}} only where allowed, and an ask containing any digit or any token of the game's recompute output (case/diacritic-folded) is rejected, since ask prints beside the key; one re-ask, then 422 → recompute answers by code → return { values, answers, changed, declined }.

Answers rule, airtight by construction. src/lib/customize/games/<slug>.js exports manifest and a pure recompute(values) → { [answersId]: string }. The model schema has no answers field. Server and client run the same isomorphic recompute; the client refuses to apply on mismatch. CI asserts: any game with a data-answers element and a model-writable slot exports recompute; the key changes whenever a covered slot changes; no element carries both data-slot and data-slot-lock; no data-slot element descends from a data-answers element. The client never writes into locked or answers-descendant nodes. ask is model-writable where the table lists it; locked or recomputed elsewhere.

Client apply: querySelector('[data-slot="id"]').textContent = value in the iframe. Color slots are named vars: the manifest lists each with its CSS selector and original hex, rewritten in the stylesheet as fill: var(--sw-<slot>, <hex>). Fills that encode meaning (vowel/consonant, star/gem, syllable family) or object identity get no slot; at Stage 1 that is every game, so Accent is no on all 18. The name wrapper shows only when non-empty, so untouched games stay pixel-identical.

Model tier and cost. Default Claude Sonnet 5 (claude-sonnet-5, $2/$10 per MTok; rates cached 2026-06-24, re-read before the prepay), adaptive thinking, effort low. Assumptions: ~2,800 input tokens (system 1,200 + manifest 1,000 + text), ~350 output. $0.0056 + $0.0035 = $0.009; +5 % Unified Billing ≈ $0.010 per call, ≈ $0.012 at a 20 % re-ask rate. Opus 5 ≈ $0.024 at the same cached rates. Prompt caching not counted. Pages Functions and KV stay free until the Stage 2 flip.

4. Stage 2: server PDF and playsets

POST /api/customize/pdf receives {slug, values, name} (the name arrives here only); the server loads its own game.html, re-runs the §3 validator and recompute, applies, renders via Browser Rendering's /pdf REST endpoint (Letter, no header/footer, print media), gates 1 page 612x792 like render-games.mjs, writes R2 customized/<random>.pdf (24 h lifecycle), returns the URL. Client-supplied HTML is never rendered (no SSRF, no unvalidated answers). The tray gains { kind: 'customized', url, expires, baseSlug } slots; playset-pdf.js already fetches by URL; expired entries fall back to the base game.

Cost. Browser Rendering REST is on the free plan (10 min/day), but production wants the Workers Paid $5/mo floor (10 browser-hours/mo included, $0.09/hour after; at ~5 s per render, an unmeasured assumption PR-E confirms, ≈ 7,000 renders/mo included). The flip is inside the founder's <$20/mo standing authority; announce on execution. Ceiling for Stages 1-2: $5 + $10 of credits billed $10.50 with the 5 % purchase fee = $15.50/mo (the per-call 5 % is that same fee).

5. Stage 3: art slots

data-slot-kind="art" slots run the engine spec's ruled pipeline unchanged (text pass writes the slot spec; corpus lookup first; Grok Imagine via AI Gateway, FLUX schnell as free fallback; evaluator watermark / embedded-text / photorealistic-child screen mandatory for every model; load-bearing facts verified by observation), swapped in as <svg><image>. Stays code-drawn, permanently: letterforms, numerals, letters inside shapes, mazes, dot-to-dot, grids, shape outlines. First targets: the <use> icon games (finish-the-leaf-pattern, count-school-supplies, snack-shop-sums, big-bigger-biggest).

6. Interim usage gate (no accounts)

Rung 2 belongs behind a capped free account; v1 has none, so: 3 customizations per browser per day, enforced twice: localStorage (UX) and a KV counter keyed sha256(CUSTOMIZE_SALT + UTC date + ip), 26 h expiry (CUSTOMIZE_SALT: Pages secret; keys rotate at UTC midnight). KV read-modify-write is not atomic (concurrent requests can slip past); its free 1,000 writes/day is account-wide, shared with downloads.js. Shared-IP households share the three. The only hard ceiling is the Gateway credit prepay with no auto-refill ($10 ≈ 800-1,000 calls); exhausted credits return the rate-limited copy.

7. Build plan (PRs with acceptance tests and critic gates)

8. Overnight studio round (dispatchable)

[engineering] Slot-mark all 18 games. Branch studio/customize-slots. First copy snack-shop-sums and treasure-boxes game.html from ~/rdco-vault/01-projects/life/printables/library/games/<slug>/ into content/games/<slug>/. Then, per the audit table, add data-slot* / data-answers attributes to every game.html; add <span class="for" data-slot="name" hidden> directly under <h1> (11pt, right-aligned, zero height hidden); no accent rewrites (Accent is no on all 18). data-answers goes on the Answers <span> inside <p class="strip">, never the paragraph (the Ask span is its sibling); snack-shop-sums instead hosts data-answers="rounds" on its "For grown-ups" paragraph; treasure-boxes needs none. Change no visible text or geometry. Write src/lib/customize/games/<slug>.js with manifest and recompute (static strings for EASY; price arithmetic for snack-shop-sums). Run node scripts/render-games.mjs --all --png /tmp/sw-slots, render main's 18 games to a baseline dir first (.pdf-staging is gitignored), then pixel-diff; any page over 0.1 % returns to the queue with its diff. Acceptance: manifest test green, 18/18 renders, diff report, one PR per 6 games. Stop after 2 consecutive gate failures ([[2026-08-31-studio-charter]] §8c).

9. Founder rulings (Ray's default in bold)

  1. Model tier: Sonnet 5, ≈ $0.01/call; Opus 5 (≈ $0.024) if cohort Spanish samples fall short.
  2. Interim gate: 3/browser/day via KV + salted-hash IP, honest copy; alternatives: 1/day, or none.
  3. Font pin: one bundled open font across all 18 games, library re-rendered, before Stage 1 (visible change to every PDF).
  4. Instrumentation: one first-party counter total:customize (no slug, no IP, downloads.js shape) so kill criteria can see rung 2; alternative: zero counting.

Audit: 18 games, slots and classes

Convention: data-slot="<id>" (name, title, instruction, ask, ray, word:N, label:N, price:N, art:<name>, accent) · data-slot-kind="text|list|art|color" · data-slot-constraints="len<=N,len=N,lang=en|es|any,chars=letters|lower|any,palette=sw-print-v1" · data-answers="<id>" on any answer/key element (recomputed by code, never model-written) · data-slot-lock on visible text that must not change (letters, numerals, shape names tied to geometry). Every game gets name, instruction and ray (where a bubble exists) at Stage 1; the table lists what else.

Game Lang Text slots (beyond name/instruction/ray) Art slots (Stage 3) Structural constraints / answers Accent Class
color-the-dino en title, ask ("What is your dino's name?") art:dino (whole-page subject) none; answers static "no single answer" no EASY
bunnys-snack-maze en title, ask art:bunny, art:carrot (start/goal markers) maze geometry code-drawn, fixed; answers static no EASY
star-dot-to-dot en title, ask none (star is geometry) 10 numbered dots locked; answers static "a star" no EASY
trace-the-shapes en title, ask; label:1-3 (circle/square/triangle, lang only) none dashed shape outlines code-drawn, locked; answers static no EASY
big-bigger-biggest en title, ask art:pumpkin/leaf/acorn (<use> at 3 scales) 3x3 size grid fixed; answers static (small/medium/big) no EASY
count-school-supplies en title, ask art:pencil/crayon/book/glue/apple (<use>; counts invariant) numerals + 15 trace paths locked; data-answers="counts" static 3·5·2·4·1 no EASY
treasure-boxes en title, rules list, legend, board headers, ask, grown-ups note none (stars/gems code-drawn) 6x6 grids + 37-point sum locked; player-name blanks; no answers element no (.star/.gem encode weight) EASY
snack-shop-sums en title; label:1-8 item names (len<=10); price:1-8 (cents, 25-350); round questions recomputed from items/prices art:apple…sandwich (<use>) data-answers="rounds" on the "For grown-ups" paragraph = totals/change from recompute; rounds must stay solvable within the stated cash no MEDIUM
build-the-word en title, ask; word:1-3 len=3 CVC lower art:cat/sun/dog paired to words one glyph per tile (3 tiles/row); data-answers="words"; word change needs its picture → Stage 3 no (tiles encode phonics) HARD
construye-la-palabra es title, ask; syllable tiles word:1-3 as CV+CV art:mamá/mapa/mono paired 2 tiles per row; data-answers="words"; phonics family (m-) constraint no (tiles encode phonics) HARD
word-and-picture en title; word:1-4 len=3; ask locked (rhymes with "hat") art:bed/sun/cat/dog paired, position-locked data-answers="pairs" ordinal mapping no HARD
une-la-palabra es title; word:1-4 (sol/oso/mamá/mapa); ask locked (counts "oso" syllables) art:mapa/mamá/sol/oso paired data-answers="pairs" bilingual key no HARD
first-sounds en title; ask (references /m/, recomputed) ; caption list recomputed art:sun/dog/ball/moon must start with B/S/M/D letters B/S/M/D data-slot-lock; data-answers="pairs" no HARD
sonido-inicial es title; ask (recomputed); caption recomputed art:mano/barco/dado/sol initial-sound bound letters locked; data-answers="pairs" no HARD
who-eats-what en title, ask ("What is your favorite snack?") art:bunny/cat/dog/bird + art:carrot/fish/bone/worm (pairs) no on-page words; data-answers="pairs" from art names no HARD
finish-the-leaf-pattern en title, ask art:leaf/acorn/pumpkin (<use>, whole game) 5 pattern rows AB/AAB/ABC/ABB/AABB locked; data-answers="next" = five values from the icon-name map no HARD
trace-abc en title, ask; label:1-3 (apple/ball/cat) only with matching art art:apple/ball/cat first-letter bound 9 dashed letterforms locked; answers static no HARD
silabas-de-m es title, ask; label:1-5 (mano/mesa/micrófono/mono/mu) only with matching art art: ×5, syllable bound 15 dashed syllable paths locked; tiles ma/me/mi/mo/mu locked; answers static no HARD

Counts: EASY 7 · MEDIUM 1 · HARD 10. Stage 1 lands the moment on all 18 (name, title, instruction, language; Ray's line on the 3 games with a bubble); word lists swap only on the MEDIUM game; HARD content moves at Stage 3, and Ray says so in beat 3.