Showdown 🃏
Real-time scrum poker for your team. Create a room, share the link, everyone votes — no accounts, no database, no setup. When the tab closes, the player disappears.
Live demo → showdown.thistine.com
Features
- Instant rooms — hit the home page, pick a name and deck, get a shareable link
- No login — identity lives in your browser's localStorage (stable across refreshes)
- Fibonacci, T-Shirt, or custom decks — change mid-session, resets the round for everyone
- Anyone can reveal or reset — no host privilege, no admin
- TV / big-screen view — open the 📺 popup, cast it to a display in the meeting room; shows the room code, join URL, and oversized seats. Goes fullscreen with one click
- Mobile-first card hand — switches from an overlapping fan to a tappable grid on screens ≤ 700 px
- Heavy animations — spring-physics cards, confetti on consensus, staggered reveals
- No database — all state lives in RAM; rooms vanish when the last person leaves
Stack
| Layer | Tech |
|---|---|
| Frontend | React 19 + TypeScript, Vite 6, Motion (Framer Motion v12) |
| Backend | Rust, axum 0.8, tokio |
| Transport | WebSockets (JSON snapshots) |
| Deploy | Single Docker image (binary + static files) |
Quick start (Docker)
The easiest way — one command, no Node or Rust required:
git clone https://github.com/ThisTine/showdown.git
cd showdown
docker compose up --build
Open http://localhost:8080.
Local development
You need Node 22+ and Rust 1.80+.
1. Frontend (dev server with mock bots — no backend needed)
cd frontend
npm install
npm run dev # http://localhost:5173
The app runs in demo mode: three western bots join, think, and vote automatically. Every animation, card flip, confetti burst, and deck-edit flow works without a backend. You'll see a demo chip in the top bar.
2. Backend
cd backend
cargo run # http://localhost:8080
3. Frontend talking to the local backend
cd frontend
VITE_WS=1 npm run dev
Vite proxies /ws → localhost:8080. The demo chip disappears and you're on the real WebSocket.
4. Production build (what Docker does)
cd frontend && npm run build # → frontend/dist/
cd ../backend && cargo run # serves /ws + frontend/dist on :8080
Project layout
showdown/
├── frontend/
│ ├── src/
│ │ ├── lib/
│ │ │ ├── connection.ts WsConnection (prod) + MockConnection (dev bots)
│ │ │ ├── session.ts localStorage profile, stable playerId, room-id words
│ │ │ ├── decks.ts deck presets + custom deck parser
│ │ │ └── router.ts 30-line history router
│ │ ├── pages/
│ │ │ ├── Home.tsx create room: name, avatar, deck
│ │ │ ├── Room.tsx the game: table, hand, deck modal, confetti
│ │ │ └── Tv.tsx big-screen spectator view
│ │ ├── components/ Table, PlayerSeat, Cards, CardHand, Results,
│ │ │ Confetti, ProfileForm, DeckModal, Logo
│ │ └── index.css full "toy poker table" design system
│ └── vite.config.ts
├── backend/
│ └── src/
│ ├── main.rs axum router + static-file SPA fallback
│ ├── room.rs game rules: vote / reveal / reset / deck
│ └── ws.rs socket lifecycle: hello → select loop → cleanup
├── PROTOCOL.md full wire protocol spec (client ↔ server)
├── Dockerfile 3-stage build: Node → Rust → alpine runtime
└── docker-compose.yml
Wire protocol
See PROTOCOL.md for the full spec. Short version:
- One WebSocket per player at
GET /ws/room/{roomId} - First frame is always
{ "type": "join", ... }(or"watch"for the TV view) - Every change triggers one full-state JSON snapshot broadcast to the whole room
- Votes are hidden in snapshots until
revealed: true— onlyvoted: true/falseleaks before the showdown
Environment variables
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
Port the server listens on |
STATIC_DIR |
../frontend/dist |
Path to the built frontend files |
Self-hosting
The Docker image is a single alpine container (~15 MB). Drop it behind any reverse proxy that supports WebSocket upgrades.
nginx example (the only non-obvious part is proxy_read_timeout — keep-alive pings are 30 s, so set it higher):
server {
listen 443 ssl;
server_name showdown.example.com;
location / {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 120s;
}
}
Fly.io / Railway / Render — point them at the Dockerfile in the repo root, set PORT to whatever the platform expects, done.
How "no database" works
A Room is a Rust struct — a deck, a revealed flag, a round counter, and a Vec of players. All rooms sit in one Mutex<HashMap<roomId, Room>> in RAM. When a socket closes, that player is removed. When the last socket of a room closes, the room is deleted from the map. Restart the server and everything is gone — which is the point.
Player identity is a UUID generated by the browser and stored in localStorage. A reconnect (page refresh) with the same UUID reclaims the seat and keeps the vote; the server uses a per-connection counter to tell a refresh from a genuine disconnect.
License
MIT