2026-04-12
Signal: leaderboard is the feature
Kris (#1) joined yesterday, Allan (#2) has been an early tester — both had the strongest reactions to seeing themselves on the leaderboard. Carrie pitched the guild idea to Garrett; his eyes lit up the moment she mentioned the leaderboard.
If Guild were rebuilt from scratch for other shops, it would just be the leaderboard. That’s the core. Tiers, points, check-ins, perks — scaffolding. The leaderboard is how the shop publicly recognizes its top supporters, and that recognition is what people actually respond to.
Design tension to hold onto: it can’t feel pay-to-win. XP from attending events and doing stuff needs to matter enough that anyone can climb. Paid tier multipliers are a boost, not a moat — someone who shows up should be able to catch up to someone who just pays.
Shipped
Kiosk v2 — SSE infrastructure + lite kiosk (PR #96)
- Server-Sent Events stream for real-time check-in push to kiosk displays and (future) member apps
X-Kiosk-Keyheader auth onPOST /api/check-infor NFC daemon access- New
/kiosk/litepage with phone-number keypad — zero-hardware-cost check-in for partner shops using mounted phones/tablets checkInserver action generalized to acceptnfcorphonemethod- Foundation for decoupling input from display (#95), unblocks fragile HID focus issue (#94)
- Design doc:
docs/plans/guild-kiosk-v2.md
Post-merge polish (same-day follow-ups)
- SSE plumbing fix — server actions and route handlers compile to separate bundles in Next.js, giving
sse.tstwo module instances with distinct connection Maps. Frames pushed by the action never reached the subscriber. Pinned the Map toglobalThisvia a shared Symbol so every instance in the same JS realm resolves to the same store. - Cooldown payload restored on SSE — cut the cooldown fields initially (redaction reflex), then walked it back: the monitor’s CooldownTimer needs them to render the countdown on duplicate phone check-ins. Not sensitive, keep them.
- Monitor subscribes to SSE stream — hero card overlay now fires on phone check-ins, not just NFC taps. Widened
HeroCardto accept a narrower type so bothCheckInResultandKioskCheckInEventpayloads satisfy it. Dedupe still guards counter/leaderboard against local NFC echoes. - Security + memory hardening (PR review feedback)
- Capped seen check-in IDs to 500 (FIFO) to prevent unbounded growth on long-running kiosks
- Redacted SSE payload — dropped
member.name,dollarValue,subscriptionStatusfrom the public stream - Removed the unauthenticated
memberIdSSE branch; endpoint now requires a valid kiosk API key - Lite kiosk shows an unauthorized fallback when
getGuildDisplayreturns null instead of hanging on the loading state
- Layout — lite kiosk now fills any phone viewport uniformly (720x360 intrinsic canvas, symmetric 24px gutters, card grows beside a fixed keypad column).
- Glow tuning — halved tier glow spread (80px→40px blur, 20px→10px) so the hero card no longer bleeds off small viewports. Also applies to the full kiosk monitor where 80px was excessive.
- UX — events moved from the right sidebar to the central monitor space; hero card surfaces as a fullscreen overlay on NFC events only. Reset delay 30s → 15s. Dropped overlay blur for perf. Glitch animation on live-updating numbers. “Ready now” message removed (server may still dedupe as duplicate; auto-dismiss is cleaner). Activity feed hidden until real data wiring lands (#99).
Phone kiosk hardware setup
- OnePlus 5 (OxygenOS, Android 10) + Fully Kiosk Browser Plus license ($7 one-time). Sideloaded the APK — Play Store not needed.
- Tailwind v4 styles broke on stock Chrome until System WebView was updated (
oklch()/color-mix()require Chrome 111+). Burner Google account is the least-friction path to keep it current. - Post-sleep digitizer freeze on stale OxygenOS. Daily reboot at 4am in Fully Kiosk avoids the overnight wedge. Double-tap-to-wake is a system-level gesture that works even when Fully’s touch capture doesn’t.
- The 7-tap exit gesture is unreliable when the page captures
pointerdownevents (our kiosk page does). Fully’s Remote Administration on LAN (:2323+ password) is the real escape hatch. Enable ADB before lockdown as a secondary rescue. - Skipped Fully Cloud (~$2/device/month). Worth revisiting only when partner-shop kiosks come online and we need remote fleet management.
Thinking out loud
Two-tier kiosk architecture
Decoupling NFC from the browser opens up a cheap partner tier. Full kiosk (Pi + WalletMate II + monitor) stays the flagship for Dungeon Books; lite kiosk (mounted phone with phone-number keypad) is the entry point for quest-hub partners. Both hit the same check-in API. Partner onboarding cost collapses from ~0 (a device they already own + a wall mount). Upgrade path to full kiosk stays open.
Phone check-in UX quirks
- iOS blocks Web NFC entirely (no Chrome NFC on iOS either), so NFC-over-phone is Android-only and not worth the cross-platform split. Keypad entry works everywhere.
- QR codes are trivially spoofable (SCVNGR lesson) and cachable from home. Physical presence at the mounted device is the trust boundary — same model as the current kiosk.
- Native app (Expo/RN) isn’t worth the ongoing cost for a wall-mounted browser. Android Screen Pinning and iOS Guided Access both give zero-code kiosk mode.
SSE scalability path
Current in-memory Map + globalThis pinning works for single-instance. The moment we scale horizontally (Railway replica > 1, or partner shops on their own instances), frames won’t cross process boundaries. Fix path: Postgres LISTEN/NOTIFY (we already have Postgres, no new infra) combined with Payload afterChange hooks on the collections that drive kiosk state — check-ins, members, loyalty-transactions. The hook publishes domain events (“check-in”, “subscription-started”, “tier-changed”); every instance listens and fans out to its local connection map. No pushX() call at every write site — mutations from webhooks, admin, scripts, and server actions all flow through the same notification channel. Defer until multi-instance is needed or until we want the activity feed (#99) real-time — whichever comes first.
Copilot review was useful
Flagged four real issues I wouldn’t have caught: unbounded Set growth, PII leak in SSE payload, unauthenticated memberId branch, lite kiosk hanging on bad key. All legitimate, all fixed pre-merge. Treat Copilot review as signal, not noise.
Housekeeping
- #93 — PII audit: replace
birthday(full date) withbirthMonth(1–12); treatnameas a Square-sourced cache. Square is system of record. - #94 — Kiosk Pi overnight reliability: WiFi not persisting, Tailscale not autostarting, input loss after cold boot. Separate from the architectural fix but needed for unattended operation.
- Tightened Claude Code project permissions — removed
mcp__github__issue_writeandupdate_pull_requestfrom auto-allow so every GitHub write requires explicit approval.
Open
- #93 — PII minimization in Members collection
- #94 — Pi overnight reliability (WiFi, Tailscale, cold boot input)
- #97 — pinned “next event” card above the rotating list
- #98 — render QR code from URL client-side instead of uploaded image
- #99 — wire Guild Activity feed to real check-ins and member events
- Mount the OP6 lite kiosk in the shop and observe live customer use
- SSE generalization to LISTEN/NOTIFY + Payload hooks (when multi-instance or #99 lands)