proto-poker

System Architecture

A free, no-signup planning poker room — real-time voting synced over Server-Sent Events and Redis pub/sub. No WebSockets, no third-party realtime vendor, no accounts.

room defaults — api/_lib/room.js

room id
→ adjective-noun-### slug
TTL
→ 4h inactivity, refreshed per write
deck
→ 1,2,3,5,8,13,21,♠,?
dev port
→ :3210 (vercel dev)
L1 · CLIENT

Web Components — public/components/

pp-app

router

Reads location.pathname once on load, decides landing vs. room. Navigation is a real location.href change, not client-side routing.

pp-landing

create & join

Posts to /api/rooms or /api/rooms/:id/join, caches identity in localStorage keyed per room.

pp-room

everything else

Owns the EventSource connection, topic input, participant list, card deck, controls, and stats — one component by design.

No framework, no build step — plain .js loaded as native ES modules. Shared styling is CSS custom properties on :root, consumed through each component's Shadow DOM.

POST / SSE
L2 · DEPLOYMENT

Vercel

Local

vercel dev · :3210

pnpm run dev:up starts local Redis (:6379) + vercel dev together, idempotent by port.

Production

Vercel

Deployed on Vercel. Pushes to main auto-deploy via the connected GitHub repo.

Same handlers either way — deployment only changes which Redis instance REDIS_URL points at.

routes
L3 · API

/api/rooms — plain Vercel Functions, Node.js runtime

Room lifecycle

Create & join

POST /api/roomsGET /api/rooms/:idPOST /api/rooms/:id/join

Voting

POST .../votePOST .../revealPOST .../new-roundPOST .../topic

Realtime

Push — one per open browser

GET /api/rooms/:id/stream

Discovery — for LLMs & agents, not the app itself

/llms.txt/manifest.json/architecture

stream.js is the one route pinned to the Node.js runtime (not Edge) — holding a live Redis SUBSCRIBE needs a raw TCP socket, which Edge can't do. The discovery files are static (no function behind them) — the SPA's raw HTML is nearly empty until JS runs, so they exist to give something with actual content to fetch.

Every mutation route (vote, reveal, new-round, topic, plus join) carries participantId and is rate-limited on two keys — participantId+room and source IP+room — failing closed with 429 + Retry-After if either is over. The IP layer exists because participantId is self-issued and never authenticated: a script can mint a fresh one per request for free, so it alone isn't a real defense.

calls
L4 · CORE

api/_lib/ — room.js, redis.js & rateLimit.js

Room mutations

  • createRoom()
  • joinRoom()
  • castVote()
  • revealVotes() / newRound()
  • setTopic()

Concurrency safety

  • mutateRoom() retry loop
  • casSet — Lua compare-and-swap
  • jittered backoff on conflict

Redis connection roles

  • getClient()
  • getPublisher()
  • createSubscriber()

Rate limiting

  • enforceRateLimits() — fails closed on first key over
  • checkRateLimit() — fixed window, INCR+EXPIRE
  • getClientIp()

Every mutation goes through mutateRoom() — the only place that also publishes, and the only place that retries a lost compare-and-swap. Skip it and clients silently desync or lose a vote under contention. A mutation callback can also return NO_CHANGE (e.g. re-casting the same vote) to skip the write and PUBLISH without signaling an error.

reads / writes
L5 · DATA

Upstash Redis

room:{id} — state (JSON blob)

idslug
topiceditable string
deckcard values
revealedboolean
participantsname + vote
updatedAtms timestamp
PUBLISH
on every write

room:{id}:events — channel

SUBSCRIBE×N open browsers

Room keys carry a 4h TTL, refreshed on every write — no accounts means no owner to explicitly delete a room, so inactivity is what cleans things up.