Guild Kiosk V2 — Decoupled Input Architecture
Problem
The current kiosk reads NFC cards via HID keyboard emulation — the WalletMate II acts as a USB keyboard, typing the card UID into a hidden browser input field and pressing Enter. This couples card reading to browser focus state. Any event that steals focus — OS notifications, WiFi reconnect dialogs, screen blanking, crash recovery — silently drops card taps with no error.
The Pi crashed overnight (2026-04-09). On reboot, WiFi required manual setup, Tailscale didn’t autostart, and the browser input field had no focus. All card taps were lost until a staff member physically intervened. The focus recovery fix in #90 handles idle drift, but can’t survive a full reboot or browser crash.
The root cause isn’t a bug — it’s a structural flaw. The browser should never be in the input path for an unattended device.
Architecture
Three layers, fully decoupled:
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Input Layer │────>│ Server Layer │────>│ Display Layer │
│ │ POST│ │ SSE │ │
│ NFC daemon (Pi) │ │ Check-in API │ │ Kiosk screen │
│ Phone entry (web)│ │ SSE endpoint │ │ Member webapp │
└─────────────────┘ └──────────────────┘ └─────────────────┘
- Input layer reads the card or accepts phone number, POSTs to the check-in API. No browser involvement.
- Server layer processes the check-in (existing logic), then pushes the result via SSE to connected clients.
- Display layer receives SSE events and plays animations. Optional — check-in succeeds with or without a display.
Input Layer
Full Kiosk: NFC Daemon
A background service on the Pi reads the NFC reader directly via PC/SC (not HID keyboard emulation) and POSTs each card tap to the check-in API.
Stack: Node.js with nfc-pcsc (the WalletMate II is PC/SC compliant via CCID). Runs as a systemd service.
Pseudocode:
const { NFC } = require('nfc-pcsc')
const nfc = new NFC()
nfc.on('reader', reader => {
reader.on('card', async card => {
await fetch(`${SERVER_URL}/api/check-in`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${API_KEY}` },
body: JSON.stringify({
method: 'nfc',
identifier: card.uid,
kioskId: KIOSK_ID
})
})
})
})systemd unit: starts on boot, restarts on crash, runs as a dedicated user with access to the USB device. No GUI, no browser, no focus to lose.
WalletMate II mode switch: the reader needs to be in CCID/PC/SC mode instead of HID keyboard emulation mode. ACS provides a mode-switching tool, or it can be configured via the reader’s firmware settings.
Lite Kiosk: Phone Number Entry
A web page on any device (old phone, tablet, cheap Android) mounted on the wall. Members type their phone number and tap a button. The page POSTs directly to the check-in API.
No NFC hardware. No Pi. No special software. Just a browser pointing at a URL.
Auth: same kiosk API key mechanism, embedded in the URL query param (server-side validated via server action, never exposed to client JS).
Trust model: the device is physically in the store, so presence is implied — same as the current kiosk. The phone number entry page is not publicly accessible in a way that enables remote check-in.
Comparison
| Full Kiosk | Lite Kiosk | |
|---|---|---|
| Hardware | Pi + WalletMate II + display | Any phone/tablet |
| Input | NFC card tap | Phone number |
| Starting cost | ~$150-200 | ~$0-10 (device + wall mount) |
| Setup | Flash SD, configure daemon | Open a URL |
| Best for | Dungeon Books, serious partners | New partners, events, pop-ups |
Both hit the same check-in API. The server doesn’t care what read the card.
Server Layer
Check-in API Changes
The existing POST /api/check-in route and processCheckIn() logic stay as-is. Two additions:
-
Kiosk API key auth on the HTTP endpoint. Currently the REST endpoint uses Payload user auth (Bearer token). The server action uses kiosk API key auth. The NFC daemon will call the REST endpoint, so it needs to accept kiosk API key auth too. Unify around: if the request has an
X-Kiosk-Keyheader, validate against the kiosks collection (same logic asresolveKioskContext). -
SSE push after check-in. After
processCheckIn()completes, push the result to connected SSE clients for the relevant kiosk/shop/member.
SSE Endpoint
GET /api/events/check-in?kioskId=abc (or ?shopId=1 or ?memberId=42)
- Validates the connection (kiosk API key for shop displays, member auth for personal feeds)
- Holds the HTTP connection open with
Content-Type: text/event-stream - Pushes
CheckInResultas JSON when a check-in occurs at the relevant kiosk/shop/for the relevant member - Client reconnects automatically via
EventSourcebuilt-in behavior
Server-side connection registry: an in-memory Map<string, Set<Response>> keyed by kiosk/shop/member ID. When a check-in completes, look up connected responses and write the SSE event. Connections are removed on close.
This is single-process — fine for the current scale. If we move to multiple server instances later, swap the in-memory map for Redis pub/sub.
Client-side:
const es = new EventSource('/api/events/check-in?kioskId=abc')
es.onmessage = (e) => {
const result = JSON.parse(e.data)
// play welcome animation with result.member, result.xpAwarded, etc.
}Event Fanout
A single check-in can push to multiple subscribers:
| Subscriber | Channel | Use case |
|---|---|---|
| Shop display (kiosk screen) | kioskId or shopId | Welcome animation, leaderboard update |
| Member’s webapp | memberId | Personal XP animation, level progress |
| Staff dashboard (future) | shopId | Real-time foot traffic |
Display Layer
Kiosk Screen
The current kiosk-screen.tsx becomes a pure display. Remove:
- Hidden input field and all focus management logic
- HID keyboard event listeners
- The
recaptureRefand keydown capture handler
Keep:
HeroCardwelcome animation- Idle screen with leaderboard, weather, events
GlitchText,CooldownTimer,BuffTimercomponents
Add:
EventSourceconnection that triggers the welcome animation on check-in events- Replace the 5-minute polling interval with SSE for guild data refresh (or keep polling for non-critical data like weather)
The phone number fallback stays in the UI for walk-ups without a card, but it’s now a form submission rather than a keyboard input hack.
Member Webapp
When a member has the portal open and checks in at a kiosk, their browser receives the SSE event and can show real-time feedback: XP gained, level progress, buff activation, streak update. Same data the kiosk display gets, rendered in the member’s own context.
Migration Path
- Add SSE endpoint and connection registry — no breaking changes, purely additive
- Add kiosk API key auth to the REST check-in endpoint — backwards compatible
- Switch WalletMate II to PC/SC mode, write and deploy the NFC daemon on the Pi
- Update kiosk-screen.tsx to receive check-in results via SSE instead of form submission
- Remove HID input field and focus management code — the cleanup step
- Build lite kiosk page — phone number entry for partner shops
Steps 1-2 can ship first. Steps 3-5 are the Pi cutover (test with current kiosk, then swap). Step 6 is independent.
Quest Hub Network Scaling
This architecture scales to the partner network:
- Partner onboarding: create a kiosk record in Payload, hand them the URL with API key. They mount a phone on the wall. Done.
- Upgrade path: partners who want NFC get a Pi + reader. Same API, same check-in flow, just a different input layer.
- Display is optional: a partner might run check-in on a phone with no separate display. Or they might put a TV on the wall showing the leaderboard. Both work independently.
- Server handles all kiosks: SSE connections are cheap (one per display). Check-in POSTs are infrequent. Hundreds of kiosks are trivial.
Pi Reliability (Separate from Architecture)
The overnight crash exposed infra issues independent of the input architecture (#94):
- WiFi: ensure NetworkManager saves the connection for headless reconnect
- Tailscale:
sudo systemctl enable tailscaledfor remote access after reboot - Chromium kiosk mode: auto-launch on boot via systemd or autostart, pointed at the display URL
- NFC daemon: systemd service with
Restart=always - Watchdog: consider a hardware watchdog timer or
systemd-watchdogto auto-reboot on hang
With the decoupled architecture, a browser crash only loses the display — check-ins still work via the daemon. A daemon crash only loses NFC — phone number entry still works via the lite kiosk page. Full Pi death is the only scenario that takes out the full kiosk, and Tailscale + systemd monitoring make it recoverable without a store visit.
Open Questions
- WalletMate II mode switch: does it need a firmware flash or just a software toggle? Need to test with ACS tools on the Pi.
- SSE connection limits: Vercel (if we deploy there) has restrictions on long-lived connections. May need to self-host or use a different SSE provider for production. Current Tailscale funnel setup avoids this.
- Lite kiosk anti-abuse: phone number entry is presence-gated by physical device location, but should we add rate limiting per device?
- Member webapp SSE: does the member need to be authenticated to subscribe, or is a member-specific token sufficient?
- Apple/Google Wallet passes: the WalletMate II supports VAS/Smart Tap in PC/SC mode too — worth planning the pass provisioning pipeline alongside this migration?
Related
- guild-kiosk — original kiosk concept and hardware
- guild-kiosk-architecture — monolith vs extraction decision (still valid)
- guild-quest-hub-network — partner network that drives the lite kiosk tier
- membership-platform — core loyalty engine