first commit

This commit is contained in:
sittichok Ouamsiri
2026-06-12 21:55:03 +07:00
commit 835475ac86
40 changed files with 6167 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
frontend/node_modules
frontend/dist
backend/target
+43
View File
@@ -0,0 +1,43 @@
# ── Stage 1: build frontend ──────────────────────────────────────────────────
FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build # → /app/frontend/dist
# ── Stage 2: build backend ────────────────────────────────────────────────────
FROM rust:1.82-alpine AS backend-builder
# musl-dev for static linking on Alpine
RUN apk add --no-cache musl-dev
WORKDIR /app/backend
# Cache dependency layer — copy manifests first, then source
COPY backend/Cargo.toml backend/Cargo.lock ./
RUN mkdir src && echo 'fn main(){}' > src/main.rs && cargo build --release && rm -rf src
COPY backend/src ./src
# Touch main.rs so cargo knows it changed after the dummy build above
RUN touch src/main.rs && cargo build --release
# ── Stage 3: final runtime image ─────────────────────────────────────────────
FROM alpine:3.20
WORKDIR /app
COPY --from=backend-builder /app/backend/target/release/showdown-backend ./showdown-backend
COPY --from=frontend-builder /app/frontend/dist ./dist
ENV PORT=8080
ENV STATIC_DIR=/app/dist
EXPOSE 8080
CMD ["./showdown-backend"]
+78
View File
@@ -0,0 +1,78 @@
# Showdown — room protocol
Contract between the frontend and the backend (Rust, in `backend/`). The
design goal is **no database**: each room lives entirely in server memory and
disappears when its last player disconnects.
## Transport
- One WebSocket per player: `GET /ws/room/{roomId}` (upgrade).
- All frames are JSON text messages.
- The server serves the built frontend (`frontend/dist`) for every other
route, so deep links like `/room/lucky-coyote-07` load the SPA.
## Room lifecycle
- A room is created in memory the first time someone connects to its id.
- Presence **is** the connection: when a socket closes, that player is removed
and a new state snapshot is broadcast (this is why friends vanish when they
close the tab).
- When the last socket closes, the room is deleted. Watchers count as
connections (a TV keeps the room alive) but never as players.
- `playerId` is generated by the browser and persisted in localStorage. A
reconnect with the same `playerId` replaces the old seat (refresh keeps your
identity and your vote).
## Client → server messages
```jsonc
{ "type": "join", "playerId": "uuid", "name": "Billy", "emoji": "🤠" } // first message on every (re)connect
{ "type": "watch" } // alternative first message: spectator (TV display)
{ "type": "vote", "value": "5" } // value: deck card, or null to retract
{ "type": "reveal" } // anyone may reveal; ignored if no votes yet
{ "type": "reset" } // anyone may start a new round
{ "type": "deck", "cards": ["1","2","3"] } // anyone may swap the deck; implies reset
```
A socket that opens with `watch` instead of `join` is a **watcher**: it
receives every state snapshot but never appears in `players`, and any other
message it sends is ignored. The frontend's TV view (`/room/{id}/tv`) connects
this way.
## Server → client messages
The server replies to **every** change (join, leave, vote, reveal, reset,
deck) by broadcasting one full-state snapshot to every socket in the room.
Snapshots are small (≤ ~16 players), so no deltas are needed.
```jsonc
{
"type": "state",
"state": {
"roomId": "lucky-coyote-07",
"deck": ["0","1","2","3","5","8","13","21","34","?","☕"],
"revealed": false,
"round": 3,
"players": [
{ "id": "uuid", "name": "Billy", "emoji": "🤠", "voted": true, "vote": null }
]
}
}
```
Rules the server must enforce:
- `vote` is hidden (`null`) in snapshots until `revealed` is true — only
`voted` leaks before the showdown.
- Votes are rejected while `revealed` is true.
- `reset` and `deck` increment `round`, clear all votes and set
`revealed = false`. Clients use the `round` counter to clear local UI state.
- `reveal` with zero votes is a no-op.
## Server shape (implemented in `backend/`)
All rooms in one `Mutex<HashMap<roomId, Room>>`. Each socket is a single
async task: it registers (join/watch), subscribes to the room's broadcast
channel, then loops — relaying snapshots out and applying client messages in.
Disconnect cleanup removes the seat (and the room, when its socket count hits
zero). No persistence anywhere.
+1
View File
@@ -0,0 +1 @@
target/
+805
View File
@@ -0,0 +1,805 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"base64",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-sink",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "http"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "log"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "mio"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom",
]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "showdown-backend"
version = "0.1.0"
dependencies = [
"axum",
"serde",
"serde_json",
"tokio",
"tower-http",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"sha1",
"thiserror",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "zerocopy"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "showdown-backend"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.8", features = ["ws"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["fs"] }
[profile.release]
opt-level = "z"
lto = true
strip = true
+46
View File
@@ -0,0 +1,46 @@
# Showdown backend
Rust (axum + tokio). Implements `../PROTOCOL.md`: WebSocket rooms that live
entirely in memory and a static file server for the built frontend — the whole
app is one binary plus a `dist/` folder.
## Run it
```sh
# build the frontend first so there is something to serve
cd ../frontend && npm run build && cd ../backend
cargo run # http://localhost:8080
```
- `PORT` — listen port (default `8080`)
- `STATIC_DIR` — built frontend location (default `../frontend/dist`)
During frontend development you can instead run `npm run dev` with
`VITE_WS=1` and Vite proxies `/ws` here.
## How it stays small
- **No database, by design.** A `Room` is a deck, a `revealed` flag, a round
counter and a `Vec` of players — a few hundred bytes. All rooms sit in one
`Mutex<HashMap>`. When the last socket of a room closes, the room is
removed; restart the server and everything is gone, which is the point.
- **One task per socket, nothing else.** Each connection is a single
`tokio::select!` loop (snapshots out, messages in, a 30s keepalive ping).
No per-room goroutine-style actors, no background jobs, no timers per room.
- **Snapshots are serialized once per change** (borrowed data, no clones) and
fanned out through a per-room `broadcast` channel with a small buffer.
## Files
```
src/main.rs routes + static serving with SPA fallback (~40 lines)
src/room.rs room state and the game rules (vote/reveal/reset/deck)
src/ws.rs socket lifecycle: hello → select loop → disconnect cleanup
```
Rules enforced here (mirroring PROTOCOL.md): votes are hidden until reveal
and rejected after it; reveal needs at least one vote; reset/deck-change
start a fresh round; a refresh re-claims the same seat without losing the
vote; watchers (`watch` handshake — the TV view) receive everything and may
send nothing.
+39
View File
@@ -0,0 +1,39 @@
//! Showdown backend: WebSocket rooms held entirely in memory, plus static
//! file serving for the built frontend. No database — a room lives exactly
//! as long as someone has it open. See ../PROTOCOL.md for the wire format.
mod room;
mod ws;
use std::env;
use std::path::PathBuf;
use axum::routing::get;
use axum::Router;
use tower_http::services::{ServeDir, ServeFile};
use crate::room::Rooms;
#[tokio::main]
async fn main() {
let rooms = Rooms::default();
let static_dir =
PathBuf::from(env::var("STATIC_DIR").unwrap_or_else(|_| "../frontend/dist".into()));
// Unknown paths fall back to index.html so deep links like /room/abc work.
let spa = ServeDir::new(&static_dir).fallback(ServeFile::new(static_dir.join("index.html")));
let app = Router::new()
.route("/ws/room/{room_id}", get(ws::ws_handler))
.fallback_service(spa)
.with_state(rooms);
let port: u16 = env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(8080);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
.await
.expect("failed to bind port");
println!("🤠 Showdown saloon open at http://localhost:{port}");
println!(" serving frontend from {}", static_dir.display());
axum::serve(listener, app).await.expect("server error");
}
+214
View File
@@ -0,0 +1,214 @@
//! Room state and rules. Everything lives in one `Mutex<HashMap>` — a room
//! is a few hundred bytes and is dropped the moment its last socket closes.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
pub type Rooms = Arc<Mutex<HashMap<String, Room>>>;
pub const DEFAULT_DECK: &[&str] = &["0", "1", "2", "3", "5", "8", "13", "21", "34", "?", ""];
const MAX_DECK_CARDS: usize = 15;
const MAX_CARD_CHARS: usize = 4;
/// Everything a client may send. The first frame must be `join` or `watch`;
/// the rest only make sense from a seated player.
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ClientMessage {
Join {
#[serde(rename = "playerId")]
player_id: String,
name: String,
emoji: String,
},
Watch,
Vote { value: Option<String> },
Reveal,
Reset,
Deck { cards: Vec<String> },
}
pub struct Player {
pub id: String,
pub name: String,
pub emoji: String,
pub vote: Option<String>,
/// Newest socket claiming this seat. A refresh re-joins with the same
/// player id and takes over the seat (keeping the vote); the old socket's
/// disconnect then sees a newer `conn` and leaves the seat alone.
pub conn: u64,
}
pub struct Room {
pub deck: Vec<String>,
pub revealed: bool,
pub round: u64,
/// Join order is seat order, and scrum teams are small — a Vec beats a map.
pub players: Vec<Player>,
/// Open sockets, players and watchers alike. Zero means delete the room.
pub conns: usize,
/// Every state change is serialized once and fanned out through here.
pub tx: broadcast::Sender<String>,
}
impl Room {
pub fn new() -> Self {
Self {
deck: DEFAULT_DECK.iter().map(|c| c.to_string()).collect(),
revealed: false,
round: 1,
players: Vec::new(),
conns: 0,
tx: broadcast::channel(32).0,
}
}
pub fn join(&mut self, id: &str, name: &str, emoji: &str, conn: u64) {
match self.players.iter_mut().find(|p| p.id == id) {
Some(p) => {
p.name = name.to_string();
p.emoji = emoji.to_string();
p.conn = conn;
}
None => self.players.push(Player {
id: id.to_string(),
name: name.to_string(),
emoji: emoji.to_string(),
vote: None,
conn,
}),
}
}
/// Free the seat unless a newer socket (a refresh) already re-claimed it.
/// Returns whether the player list actually changed.
pub fn leave(&mut self, id: &str, conn: u64) -> bool {
match self.players.iter().position(|p| p.id == id && p.conn == conn) {
Some(pos) => {
self.players.remove(pos);
true
}
None => false,
}
}
pub fn apply(&mut self, player_id: &str, msg: ClientMessage) {
match msg {
ClientMessage::Vote { value } => {
if self.revealed {
return; // no changing your story after the showdown
}
if let Some(v) = &value {
if !self.deck.contains(v) {
return;
}
}
if let Some(p) = self.players.iter_mut().find(|p| p.id == player_id) {
p.vote = value;
}
}
ClientMessage::Reveal => {
if self.players.iter().any(|p| p.vote.is_some()) {
self.revealed = true;
}
}
ClientMessage::Reset => self.new_round(),
ClientMessage::Deck { cards } => {
if let Some(deck) = clean_deck(cards) {
self.deck = deck;
self.new_round();
}
}
// join/watch are handshake messages, meaningless mid-session.
ClientMessage::Join { .. } | ClientMessage::Watch => {}
}
}
fn new_round(&mut self) {
self.revealed = false;
self.round += 1;
for p in &mut self.players {
p.vote = None;
}
}
/// One JSON snapshot of the room. Votes stay hidden until the reveal —
/// only `voted` leaks beforehand. Serializes from borrows; no clones.
pub fn snapshot_json(&self, room_id: &str) -> String {
#[derive(Serialize)]
struct PlayerOut<'a> {
id: &'a str,
name: &'a str,
emoji: &'a str,
voted: bool,
vote: Option<&'a str>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Snapshot<'a> {
room_id: &'a str,
deck: &'a [String],
revealed: bool,
round: u64,
players: Vec<PlayerOut<'a>>,
}
#[derive(Serialize)]
struct StateMsg<'a> {
r#type: &'static str,
state: Snapshot<'a>,
}
let players = self
.players
.iter()
.map(|p| PlayerOut {
id: &p.id,
name: &p.name,
emoji: &p.emoji,
voted: p.vote.is_some(),
vote: if self.revealed { p.vote.as_deref() } else { None },
})
.collect();
serde_json::to_string(&StateMsg {
r#type: "state",
state: Snapshot {
room_id,
deck: &self.deck,
revealed: self.revealed,
round: self.round,
players,
},
})
.expect("room state always serializes")
}
pub fn broadcast(&self, room_id: &str) {
// Send fails only when nobody is listening, which is fine.
let _ = self.tx.send(self.snapshot_json(room_id));
}
}
/// Trim, dedupe and cap a proposed deck. None if fewer than two cards survive.
fn clean_deck(cards: Vec<String>) -> Option<Vec<String>> {
let mut out: Vec<String> = Vec::new();
for raw in cards {
let card = raw.trim();
if card.is_empty() || card.chars().count() > MAX_CARD_CHARS {
continue;
}
if out.iter().any(|c| c == card) {
continue;
}
out.push(card.to_string());
if out.len() == MAX_DECK_CARDS {
break;
}
}
(out.len() >= 2).then_some(out)
}
+196
View File
@@ -0,0 +1,196 @@
//! One async task per socket. The task introduces itself (join/watch), then
//! sits in a single select loop: room snapshots out, client messages in.
//! Disconnect cleanup is what makes presence work — closing the tab is
//! leaving the room.
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use crate::room::{ClientMessage, Room, Rooms};
/// Distinguishes the sockets of a player who refreshed; see `Player::conn`.
static NEXT_CONN: AtomicU64 = AtomicU64::new(1);
const HELLO_TIMEOUT: Duration = Duration::from_secs(10);
const PING_INTERVAL: Duration = Duration::from_secs(30);
enum Seat {
Player { id: String },
Watcher,
}
pub async fn ws_handler(
Path(room_id): Path<String>,
State(rooms): State<Rooms>,
ws: WebSocketUpgrade,
) -> Response {
if !valid_room_id(&room_id) {
return (StatusCode::BAD_REQUEST, "bad room id").into_response();
}
ws.on_upgrade(move |socket| handle_socket(socket, room_id, rooms))
}
fn valid_room_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
async fn handle_socket(mut socket: WebSocket, room_id: String, rooms: Rooms) {
// 1. The first frame must introduce the socket: join (player) or watch (TV).
let hello = match tokio::time::timeout(HELLO_TIMEOUT, socket.recv()).await {
Ok(Some(Ok(Message::Text(text)))) => text,
_ => return,
};
let seat = match serde_json::from_str::<ClientMessage>(&hello) {
Ok(ClientMessage::Join { player_id, name, emoji }) => {
if player_id.is_empty() || player_id.len() > 64 {
return;
}
let conn_id = NEXT_CONN.fetch_add(1, Ordering::Relaxed);
register_player(
&rooms,
&room_id,
&player_id,
&clean_text(&name, 16, "Outlaw"),
&clean_text(&emoji, 8, "🤠"),
conn_id,
);
(Seat::Player { id: player_id }, conn_id)
}
Ok(ClientMessage::Watch) => {
let conn_id = NEXT_CONN.fetch_add(1, Ordering::Relaxed);
register_watcher(&rooms, &room_id, conn_id);
(Seat::Watcher, conn_id)
}
_ => return,
};
let (seat, conn_id) = seat;
// 2. Subscribe to snapshots and send this socket the current one.
let (mut rx, snapshot) = {
let mut map = rooms.lock().unwrap();
let room = map.get_mut(&room_id).expect("registered above");
(room.tx.subscribe(), room.snapshot_json(&room_id))
};
// 3. The whole session is one loop; whenever it ends, fall through to cleanup.
serve(&mut socket, &room_id, &rooms, &seat, &mut rx, snapshot).await;
// 4. Disconnect: free the seat, tell the others, drop the room if empty.
cleanup(&rooms, &room_id, &seat, conn_id);
}
fn register_player(rooms: &Rooms, room_id: &str, id: &str, name: &str, emoji: &str, conn: u64) {
let mut map = rooms.lock().unwrap();
let room = map.entry(room_id.to_string()).or_insert_with(Room::new);
room.conns += 1;
room.join(id, name, emoji, conn);
room.broadcast(room_id);
}
fn register_watcher(rooms: &Rooms, room_id: &str, _conn: u64) {
let mut map = rooms.lock().unwrap();
let room = map.entry(room_id.to_string()).or_insert_with(Room::new);
room.conns += 1;
}
async fn serve(
socket: &mut WebSocket,
room_id: &str,
rooms: &Rooms,
seat: &Seat,
rx: &mut broadcast::Receiver<String>,
first_snapshot: String,
) {
if socket.send(Message::Text(first_snapshot.into())).await.is_err() {
return;
}
let mut ping = tokio::time::interval(PING_INTERVAL);
ping.tick().await; // the first tick is immediate; skip it
loop {
tokio::select! {
// A state change somewhere in the room — relay the snapshot.
update = rx.recv() => {
let json = match update {
Ok(json) => json,
// This socket fell behind; skip straight to the freshest state.
Err(RecvError::Lagged(_)) => match current_snapshot(rooms, room_id) {
Some(json) => json,
None => return,
},
Err(RecvError::Closed) => return,
};
if socket.send(Message::Text(json.into())).await.is_err() {
return;
}
}
// A frame from this client.
frame = socket.recv() => {
let text = match frame {
Some(Ok(Message::Text(text))) => text,
Some(Ok(_)) => continue, // pongs etc. — ignore
_ => return, // closed or errored
};
let Seat::Player { id } = seat else {
continue; // watchers are read-only
};
let Ok(msg) = serde_json::from_str::<ClientMessage>(&text) else {
continue; // malformed frame — ignore
};
let mut map = rooms.lock().unwrap();
if let Some(room) = map.get_mut(room_id) {
room.apply(id, msg);
room.broadcast(room_id);
}
}
// Keepalive so idle rooms survive proxies and NAT timeouts.
_ = ping.tick() => {
if socket.send(Message::Ping(Default::default())).await.is_err() {
return;
}
}
}
}
}
fn current_snapshot(rooms: &Rooms, room_id: &str) -> Option<String> {
rooms.lock().unwrap().get(room_id).map(|r| r.snapshot_json(room_id))
}
fn cleanup(rooms: &Rooms, room_id: &str, seat: &Seat, conn_id: u64) {
let mut map = rooms.lock().unwrap();
let Some(room) = map.get_mut(room_id) else {
return;
};
room.conns -= 1;
if room.conns == 0 {
// Last one out: the whole room vanishes. No database, no leftovers.
map.remove(room_id);
return;
}
if let Seat::Player { id } = seat {
if room.leave(id, conn_id) {
room.broadcast(room_id);
}
}
}
fn clean_text(raw: &str, max_chars: usize, fallback: &str) -> String {
let trimmed: String = raw.trim().chars().take(max_chars).collect();
if trimmed.is_empty() {
fallback.to_string()
} else {
trimmed
}
}
+11
View File
@@ -0,0 +1,11 @@
services:
showdown:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
PORT: 8080
STATIC_DIR: /app/dist
restart: unless-stopped
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.local
+53
View File
@@ -0,0 +1,53 @@
# Showdown frontend
Game-like scrum poker. React + TypeScript + Vite, with the
[Motion](https://motion.dev) library for spring-physics animation.
## Run it
```sh
npm install
npm run dev
```
In dev there is no backend yet, so the app runs in **demo mode**: a local mock
room where three western bots join, think, and vote — every animation and flow
(deal-in, card flips, consensus confetti, deck editing) is exercisable solo.
You'll see a `demo` chip in the top bar.
- `VITE_WS=1 npm run dev` — talk to a real backend on `localhost:8080`
(the Vite proxy forwards `/ws`) instead of the mock.
- `npm run build` — typecheck + production build to `dist/`. Production builds
always use the real WebSocket at `/ws/room/{id}` on the same host.
## TV display
The 📺 button in a room pops up `/room/{id}/tv` — a view-only big-screen
spectator display (cast it to a TV): giant room code and join URL, oversized
seats and cards, no controls, plus a fullscreen toggle. It connects as a
`watch` socket in production; in demo mode it mirrors your game tab over a
BroadcastChannel (or runs a self-playing bot loop if no game tab is open).
## Mobile
On screens ≤700px the hand switches from the overlapping fan to a flat
wrapped grid of full-size cards (~6084px wide), so every card is a
comfortable tap target.
## Layout
```
src/
lib/connection.ts WsConnection (real, reconnecting) + MockConnection (bots)
lib/session.ts localStorage profile + stable playerId + room id words
lib/decks.ts presets + custom deck parsing
lib/router.ts 30-line history router (/ and /room/:id)
pages/Home.tsx create room: name, avatar, deck choice
pages/Room.tsx the game: top bar, table, hand, deck modal, confetti
components/ Table, PlayerSeat, Cards (face/back/flip), CardHand,
Results, Confetti, ProfileForm, DeckModal, Logo
index.css the whole "toy poker table" design system
```
The wire protocol the mock implements is the same one the Rust backend in
`../backend` speaks — see `../PROTOCOL.md`.
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0b2e27" />
<meta name="description" content="Showdown — scrum poker with friends. No sign-up, no database, all showdown." />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🤠</text></svg>" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Lilita+One&family=Nunito:wght@600;700;800;900&display=swap"
rel="stylesheet"
/>
<title>Showdown — Scrum Poker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1775
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "showdown-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"motion": "^12.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.2",
"vite": "^6.0.0"
}
}
+57
View File
@@ -0,0 +1,57 @@
import { usePath } from "./lib/router";
import { Home } from "./pages/Home";
import { Room } from "./pages/Room";
import { TvRoom } from "./pages/Tv";
export default function App() {
const path = usePath();
const roomMatch = path.match(/^\/room\/([\w-]+?)(\/tv)?\/?$/);
return (
<>
<BgScene />
{roomMatch ? (
roomMatch[2] ? (
<TvRoom key={roomMatch[1]} roomId={roomMatch[1]} />
) : (
<Room key={roomMatch[1]} roomId={roomMatch[1]} />
)
) : (
<Home />
)}
</>
);
}
const SUITS = [
{ ch: "♠", x: 6, y: 16, s: 90, d: 7, delay: 0 },
{ ch: "♥", x: 86, y: 12, s: 70, d: 9, delay: 1.2 },
{ ch: "♦", x: 12, y: 74, s: 80, d: 8, delay: 0.6 },
{ ch: "♣", x: 88, y: 70, s: 100, d: 10, delay: 2 },
{ ch: "★", x: 48, y: 88, s: 60, d: 6.5, delay: 1.6 },
{ ch: "♠", x: 70, y: 42, s: 50, d: 11, delay: 0.3 },
{ ch: "♥", x: 26, y: 40, s: 45, d: 9.5, delay: 2.4 },
];
function BgScene() {
return (
<div className="bg" aria-hidden>
<div className="bg__spotlight" />
{SUITS.map((s, i) => (
<span
key={i}
className="bg__suit"
style={{
left: `${s.x}%`,
top: `${s.y}%`,
fontSize: s.s,
animationDuration: `${s.d}s`,
animationDelay: `${s.delay}s`,
}}
>
{s.ch}
</span>
))}
<div className="bg__noise" />
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useState } from "react";
import { motion } from "motion/react";
import { CardFace } from "./Cards";
interface HandProps {
deck: string[];
myVote: string | null;
locked: boolean;
onPick(value: string): void;
}
function useMediaQuery(query: string): boolean {
const [match, setMatch] = useState(() => matchMedia(query).matches);
useEffect(() => {
const mq = matchMedia(query);
const onChange = () => setMatch(mq.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, [query]);
return match;
}
export function CardHand({ deck, myVote, locked, onPick }: HandProps) {
// Desktop gets the overlapping fan; phones get a flat wrapped grid with
// full-size tap targets (layout switch lives in the matching media query).
const fan = useMediaQuery("(min-width: 701px)");
const n = deck.length;
return (
<div className={`hand${locked ? " hand--locked" : ""}`}>
<div className="hand__scroller">
{deck.map((value, i) => {
const rot = fan ? (i - (n - 1) / 2) * 3 : 0;
const selected = value === myVote;
return (
<motion.div
key={value}
className="hand__slot"
style={{ zIndex: selected ? 40 : i }}
initial={{ y: 150, opacity: 0, rotate: fan ? 20 : 0 }}
animate={{ y: 0, opacity: 1, rotate: 0 }}
transition={{ delay: 0.25 + i * 0.045, type: "spring", stiffness: 300, damping: 24 }}
>
<motion.button
type="button"
className="hand__card"
animate={{
y: selected ? (fan ? -22 : -10) : 0,
rotate: selected ? 0 : rot,
scale: selected ? 1.08 : 1,
}}
whileHover={{ y: fan ? -14 : -6, scale: 1.05 }}
whileTap={{ scale: 0.94, y: -6 }}
transition={{ type: "spring", stiffness: 500, damping: 28 }}
onClick={() => onPick(value)}
aria-pressed={selected}
aria-label={`Vote ${value}`}
>
<CardFace value={value} size="lg" selected={selected} />
</motion.button>
</motion.div>
);
})}
</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { motion } from "motion/react";
export type CardSize = "lg" | "sm" | "xs";
export function CardFace({
value,
size,
selected = false,
}: {
value: string;
size: CardSize;
selected?: boolean;
}) {
const long = [...value].length >= 3;
return (
<div className={`cardface cardface--${size}${selected ? " cardface--selected" : ""}`}>
<span className="cardface__corner cardface__corner--tl">{value}</span>
<span className="cardface__star" aria-hidden>
</span>
<span className={`cardface__value${long ? " cardface__value--long" : ""}`}>{value}</span>
<span className="cardface__corner cardface__corner--br">{value}</span>
</div>
);
}
export function CardBack({ size }: { size: CardSize }) {
return (
<div className={`cardback cardback--${size}`}>
<span className="cardback__star" aria-hidden>
</span>
</div>
);
}
/** A seat-sized card that flips face-up when the round is revealed. */
export function FlipCard({ revealed, value, delay }: { revealed: boolean; value: string; delay: number }) {
return (
<div className="flip">
<motion.div
className="flip__inner"
initial={false}
animate={{ rotateY: revealed ? 180 : 0 }}
transition={{ delay: revealed ? delay : 0, type: "spring", stiffness: 280, damping: 22 }}
>
<div className="flip__face flip__face--back">
<CardBack size="sm" />
</div>
<div className="flip__face flip__face--front">
<CardFace value={value} size="sm" />
</div>
</motion.div>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useMemo } from "react";
import { motion } from "motion/react";
const COLORS = ["#ffc233", "#ff5c4d", "#fff4dc", "#3ddc97", "#58c7f3"];
interface Piece {
id: number;
left: number;
delay: number;
duration: number;
color: string;
size: number;
rotate: number;
drift: number;
star: boolean;
}
export function Confetti({ count = 90 }: { count?: number }) {
const pieces = useMemo<Piece[]>(
() =>
Array.from({ length: count }, (_, i) => ({
id: i,
left: Math.random() * 100,
delay: Math.random() * 0.5,
duration: 2.4 + Math.random() * 1.8,
color: COLORS[i % COLORS.length],
size: 7 + Math.random() * 8,
rotate: Math.random() * 720 - 360,
drift: 20 + Math.random() * 50,
star: Math.random() < 0.14,
})),
[count],
);
return (
<div className="confetti" aria-hidden>
{pieces.map((p) => (
<motion.span
key={p.id}
className="confetti__piece"
style={{
left: `${p.left}%`,
width: p.star ? "auto" : p.size,
height: p.star ? "auto" : p.size * 0.62,
background: p.star ? "transparent" : p.color,
color: p.color,
fontSize: p.size + 4,
}}
initial={{ y: "-8vh", opacity: 1, rotate: 0 }}
animate={{
y: "110vh",
x: [0, p.drift, -p.drift, p.drift / 2],
rotate: p.rotate,
opacity: [1, 1, 1, 0.6],
}}
transition={{ duration: p.duration, delay: p.delay, ease: "linear" }}
>
{p.star ? "★" : ""}
</motion.span>
))}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useMemo, useState } from "react";
import { motion } from "motion/react";
import { DECK_PRESETS, parseCustomDeck } from "../lib/decks";
import { CardFace } from "./Cards";
interface DeckModalProps {
current: string[];
onSave(cards: string[]): void;
onClose(): void;
}
export function DeckModal({ current, onSave, onClose }: DeckModalProps) {
const [raw, setRaw] = useState(current.join(", "));
const parsed = useMemo(() => parseCustomDeck(raw), [raw]);
return (
<motion.div
className="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.div
className="modal"
initial={{ scale: 0.8, y: 30, opacity: 0 }}
animate={{ scale: 1, y: 0, opacity: 1 }}
exit={{ scale: 0.85, opacity: 0 }}
transition={{ type: "spring", stiffness: 380, damping: 26 }}
onClick={(e) => e.stopPropagation()}
>
<h2 className="modal__title">🃏 Pick your deck</h2>
<div className="deck-pills">
{DECK_PRESETS.map((d) => (
<button
key={d.id}
type="button"
className={`pill${sameDeck(d.cards, parsed) ? " pill--on" : ""}`}
onClick={() => setRaw(d.cards.join(", "))}
>
{d.label}
</button>
))}
</div>
<label className="field">
<span className="field__label">Cards (comma separated)</span>
<input className="input" value={raw} onChange={(e) => setRaw(e.target.value)} />
</label>
<div className="deck-preview">
{(parsed ?? []).map((v) => (
<CardFace key={v} value={v} size="xs" />
))}
</div>
<p className="modal__hint">Changing the deck starts a fresh round for everyone.</p>
<div className="modal__actions">
<button type="button" className="btn" onClick={onClose}>
Cancel
</button>
<button
type="button"
className="btn btn--gold"
disabled={!parsed}
onClick={() => parsed && onSave(parsed)}
>
Save &amp; re-deal
</button>
</div>
</motion.div>
</motion.div>
);
}
function sameDeck(a: string[], b: string[] | null): boolean {
return b !== null && a.length === b.length && a.every((v, i) => v === b[i]);
}
+26
View File
@@ -0,0 +1,26 @@
import { motion } from "motion/react";
const LETTERS = [..."SHOWDOWN"];
const TILTS = [-5, 3, -2, 4, -3, 2, -4, 5];
export function Logo() {
return (
<div className="logo-big" role="heading" aria-level={1} aria-label="Showdown">
<span className="logo-big__star" aria-hidden>
</span>
{LETTERS.map((ch, i) => (
<motion.span
key={i}
className="logo-big__letter"
aria-hidden
initial={{ y: -90, opacity: 0, rotate: -12 }}
animate={{ y: 0, opacity: 1, rotate: TILTS[i] }}
transition={{ delay: 0.06 * i, type: "spring", stiffness: 380, damping: 16 }}
>
{ch}
</motion.span>
))}
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { motion } from "motion/react";
import type { Player } from "../types";
import { FlipCard } from "./Cards";
interface SeatProps {
player: Player;
isMe: boolean;
revealed: boolean;
/** Seat position, in percent of the table zone. */
x: number;
y: number;
/** Top-half seats render their card below the avatar, toward the felt. */
flip: boolean;
flipDelay: number;
}
export function PlayerSeat({ player, isMe, revealed, x, y, flip, flipDelay }: SeatProps) {
const left = `${x}%`;
const top = `${y}%`;
return (
<motion.div
className={`seat${flip ? " seat--flip" : ""}${isMe ? " seat--me" : ""}`}
style={{ left, top }}
initial={{ opacity: 0, scale: 0, x: "-50%", y: "-50%" }}
animate={{ opacity: 1, scale: 1, x: "-50%", y: "-50%", left, top }}
exit={{ opacity: 0, scale: 0, x: "-50%", y: "-50%", transition: { duration: 0.25 } }}
transition={{ type: "spring", stiffness: 320, damping: 25 }}
>
<div className="seat__cardspot">
{player.voted ? (
<div className={revealed ? undefined : "wobble"}>
<FlipCard revealed={revealed && player.vote !== null} value={player.vote ?? ""} delay={flipDelay} />
</div>
) : (
<div className="seat__waiting">
{revealed ? (
<span className="seat__novote"></span>
) : (
<span className="dots">
<span />
<span />
<span />
</span>
)}
</div>
)}
</div>
<div className="seat__avatar">{player.emoji}</div>
<div className="seat__name">{isMe ? "you" : player.name}</div>
</motion.div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useState } from "react";
import type { ReactNode } from "react";
import type { Profile } from "../types";
const AVATARS = ["🤠", "🐴", "🌵", "🦅", "🐺", "🦊", "🐻", "🐍", "⭐", "🦬"];
interface ProfileFormProps {
title: string;
cta: string;
initial?: Profile | null;
onSubmit(profile: Profile): void;
/** Extra fields (e.g. the deck picker on the home page). */
children?: ReactNode;
}
export function ProfileForm({ title, cta, initial, onSubmit, children }: ProfileFormProps) {
const [name, setName] = useState(initial?.name ?? "");
const [emoji, setEmoji] = useState(
() => initial?.emoji ?? AVATARS[Math.floor(Math.random() * AVATARS.length)],
);
return (
<form
className="poster"
onSubmit={(e) => {
e.preventDefault();
const trimmed = name.trim().slice(0, 16);
if (trimmed) onSubmit({ name: trimmed, emoji });
}}
>
<h2 className="poster__title">{title}</h2>
<label className="field">
<span className="field__label">Outlaw name</span>
<input
className="input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Billy the Kid"
maxLength={16}
autoFocus
/>
</label>
<div className="field">
<span className="field__label">Pick your face</span>
<div className="emoji-grid">
{AVATARS.map((a) => (
<button
type="button"
key={a}
className={`emoji-opt${a === emoji ? " emoji-opt--on" : ""}`}
onClick={() => setEmoji(a)}
aria-pressed={a === emoji}
>
{a}
</button>
))}
</div>
</div>
{children}
<button type="submit" className="btn btn--gold btn--big poster__cta" disabled={!name.trim()}>
{cta}
</button>
</form>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { motion } from "motion/react";
import type { Variants } from "motion/react";
import type { RoomState } from "../types";
import { CardFace } from "./Cards";
interface ResultsProps {
state: RoomState;
consensus: boolean;
/** Omitted in spectator (TV) mode — the button is hidden. */
onReset?(): void;
}
const pop: Variants = {
hidden: { opacity: 0, scale: 0.5, y: 10 },
show: { opacity: 1, scale: 1, y: 0, transition: { type: "spring", stiffness: 400, damping: 20 } },
};
export function Results({ state, consensus, onReset }: ResultsProps) {
const votes = state.players.map((p) => p.vote).filter((v): v is string => v !== null);
const numeric = votes.map(Number).filter((x) => Number.isFinite(x));
const avg = numeric.length > 0 ? Math.round((numeric.reduce((a, b) => a + b, 0) / numeric.length) * 10) / 10 : null;
const dist = (() => {
const counts = new Map<string, number>();
for (const v of votes) counts.set(v, (counts.get(v) ?? 0) + 1);
return [...counts.entries()].sort(
(a, b) => b[1] - a[1] || state.deck.indexOf(a[0]) - state.deck.indexOf(b[0]),
);
})();
return (
<motion.div
className="results"
initial="hidden"
animate="show"
variants={{ show: { transition: { staggerChildren: 0.1, delayChildren: 0.45 } } }}
>
{consensus && (
<motion.div className="consensus-banner" variants={pop}>
🤝 Consensus!
</motion.div>
)}
{avg !== null ? (
<motion.div className="results__avg-wrap" variants={pop}>
<span className="results__avg-label">average</span>
<span className="results__avg">{avg}</span>
</motion.div>
) : (
dist.length > 0 && (
<motion.div className="results__avg-wrap" variants={pop}>
<span className="results__avg-label">the call</span>
<span className="results__avg">{dist[0][0]}</span>
</motion.div>
)
)}
<motion.div className="dist" variants={pop}>
{dist.map(([value, count]) => (
<span key={value} className="dist__chip">
<CardFace value={value} size="xs" /> ×{count}
</span>
))}
</motion.div>
{onReset && (
<motion.button type="button" className="btn btn--gold" variants={pop} onClick={onReset}>
🔄 New round
</motion.button>
)}
</motion.div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { useMemo } from "react";
import { AnimatePresence, motion } from "motion/react";
import type { RoomState } from "../types";
import { PlayerSeat } from "./PlayerSeat";
import { Results } from "./Results";
interface TableProps {
state: RoomState;
playerId: string;
consensus: boolean;
copied?: boolean;
/** View-only (TV display): no reveal/reset/invite controls. */
spectator?: boolean;
onReveal?(): void;
onReset?(): void;
onInvite?(): void;
}
export function Table({ state, playerId, consensus, copied, spectator, onReveal, onReset, onInvite }: TableProps) {
// Rotate the seating order so the local player always sits at the bottom.
const ordered = useMemo(() => {
const idx = state.players.findIndex((p) => p.id === playerId);
if (idx <= 0) return state.players;
return [...state.players.slice(idx), ...state.players.slice(0, idx)];
}, [state.players, playerId]);
// TV seats are much larger, so they sit on a tighter ring to stay on screen.
const rx = spectator ? 41 : 44;
const ry = spectator ? 38 : 43;
const n = Math.max(ordered.length, 1);
const seats = ordered.map((player, i) => {
const angle = (Math.PI * (90 + (i * 360) / n)) / 180;
const sin = Math.sin(angle);
return {
player,
x: 50 + rx * Math.cos(angle),
y: 50 + ry * sin,
flip: sin < -0.25,
};
});
const votedCount = state.players.filter((p) => p.voted).length;
const allVoted = state.players.length > 0 && votedCount === state.players.length;
const lonely = spectator ? state.players.length === 0 : state.players.length <= 1;
const joinUrl = `${location.host}/room/${state.roomId}`;
return (
<div className="table-zone">
<div className="table-felt">
<div className="table-center">
{state.revealed ? (
<Results state={state} consensus={consensus} onReset={spectator ? undefined : onReset} />
) : lonely ? (
spectator ? (
<>
<div className="center-title">Waitin for the posse</div>
<div className="center-sub center-sub--url">join at {joinUrl}</div>
</>
) : (
<>
<div className="center-title">Its lonely out here, partner</div>
<div className="center-sub">Rustle up a posse with the invite link</div>
<button type="button" className="btn btn--gold" onClick={onInvite}>
{copied ? "✓ Copied!" : "🔗 Copy invite link"}
</button>
</>
)
) : (
<>
<motion.div
key={votedCount}
className="center-count"
initial={{ scale: 1.35 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 400, damping: 15 }}
>
{votedCount}
<span className="center-count__total">/{state.players.length}</span>
</motion.div>
<div className="center-title">
{votedCount === 0 ? "Place your bets!" : allVoted ? "Everyones in!" : "Waitin on the rest…"}
</div>
{!spectator && (
<button
type="button"
className={`btn btn--coral btn--big${allVoted ? " btn--party" : ""}`}
disabled={votedCount === 0}
onClick={onReveal}
>
Reveal
</button>
)}
</>
)}
</div>
</div>
<AnimatePresence>
{seats.map((s, i) => (
<PlayerSeat
key={s.player.id}
player={s.player}
isMe={!spectator && s.player.id === playerId}
revealed={state.revealed}
x={s.x}
y={s.y}
flip={s.flip}
flipDelay={i * 0.09}
/>
))}
</AnimatePresence>
</div>
);
}
File diff suppressed because it is too large Load Diff
+336
View File
@@ -0,0 +1,336 @@
import type { ClientMessage, ConnStatus, Profile, RoomState, ServerMessage } from "../types";
import { DEFAULT_DECK } from "./decks";
export interface RoomConnection {
send(msg: ClientMessage): void;
close(): void;
}
export interface ConnectionHandlers {
onState(state: RoomState): void;
onStatus(status: ConnStatus): void;
}
export interface ConnectionOptions {
/** View-only (TV display): receives state but never takes a seat. */
spectator?: boolean;
}
/**
* In dev there is no Go backend yet, so the app runs a local mock room with
* bot players. Set VITE_WS=1 to test against a real backend during
* development. Production builds always use the real WebSocket.
*/
export function createConnection(
roomId: string,
playerId: string,
profile: Profile,
handlers: ConnectionHandlers,
options: ConnectionOptions = {},
): RoomConnection {
const useMock = import.meta.env.DEV && import.meta.env.VITE_WS !== "1";
if (options.spectator) {
return useMock
? new MockSpectatorConnection(roomId, handlers)
: new WsConnection(roomId, playerId, profile, handlers, true);
}
return useMock
? new MockConnection(roomId, playerId, profile, handlers)
: new WsConnection(roomId, playerId, profile, handlers);
}
/* ------------------------------------------------------------------ */
/* Real WebSocket client — matches PROTOCOL.md for the Go backend. */
/* ------------------------------------------------------------------ */
class WsConnection implements RoomConnection {
private ws: WebSocket | null = null;
private queue: ClientMessage[] = [];
private closed = false;
private attempts = 0;
constructor(
private roomId: string,
private playerId: string,
private profile: Profile,
private handlers: ConnectionHandlers,
private spectator = false,
) {
this.connect();
}
private connect(): void {
this.handlers.onStatus("connecting");
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/ws/room/${encodeURIComponent(this.roomId)}`);
this.ws = ws;
ws.onopen = () => {
this.attempts = 0;
this.handlers.onStatus("online");
const hello: ClientMessage = this.spectator
? { type: "watch" }
: { type: "join", playerId: this.playerId, name: this.profile.name, emoji: this.profile.emoji };
ws.send(JSON.stringify(hello));
for (const msg of this.queue.splice(0)) {
ws.send(JSON.stringify(msg));
}
};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data) as ServerMessage;
if (msg.type === "state") this.handlers.onState(msg.state);
} catch {
/* malformed frame — ignore */
}
};
ws.onclose = () => {
if (this.closed) return;
this.handlers.onStatus("offline");
const backoff = Math.min(10_000, 1_000 * 2 ** this.attempts++);
setTimeout(() => {
if (!this.closed) this.connect();
}, backoff);
};
}
send(msg: ClientMessage): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
} else {
this.queue.push(msg);
}
}
close(): void {
this.closed = true;
this.ws?.close();
}
}
/* ------------------------------------------------------------------ */
/* Mock room with bot players, for developing the UI without a server. */
/* ------------------------------------------------------------------ */
interface MockPlayer {
id: string;
name: string;
emoji: string;
vote: string | null;
bot: boolean;
}
const BOTS: Array<Pick<MockPlayer, "id" | "name" | "emoji">> = [
{ id: "bot-1", name: "Calamity Jane", emoji: "🐴" },
{ id: "bot-2", name: "Doc Holliday", emoji: "🦅" },
{ id: "bot-3", name: "Wild Bill", emoji: "🌵" },
{ id: "bot-4", name: "Annie Oakley", emoji: "⭐" },
];
const channelName = (roomId: string) => `showdown:room:${roomId}`;
class MockConnection implements RoomConnection {
private deck = [...DEFAULT_DECK];
private revealed = false;
private round = 1;
private players: MockPlayer[] = [];
private timers = new Set<ReturnType<typeof setTimeout>>();
/** Index in the deck the bots loosely agree on, re-rolled every round. */
private botTargetIndex = 0;
private disposed = false;
private revealScheduled = false;
/** Mirrors state to spectator tabs (the TV view) while in demo mode. */
private channel: BroadcastChannel | null = null;
constructor(
private roomId: string,
playerId: string,
profile: Profile,
private handlers: ConnectionHandlers,
/** Self-running mode for a standalone TV demo: bots only, auto reveal/reset. */
private autopilot = false,
) {
if (!autopilot && typeof BroadcastChannel !== "undefined") {
this.channel = new BroadcastChannel(channelName(roomId));
this.channel.onmessage = (e) => {
if (e.data?.type === "sync") this.emit();
};
}
if (!autopilot) {
this.players.push({ id: playerId, name: profile.name, emoji: profile.emoji, vote: null, bot: false });
}
this.rollBotTarget();
handlers.onStatus("demo");
this.emit();
const bots = autopilot ? BOTS : BOTS.slice(0, 3);
bots.forEach((bot, i) => {
this.after(1200 + i * 1400, () => {
this.players.push({ ...bot, vote: null, bot: true });
this.emit();
this.scheduleBotVote(bot.id);
});
});
}
send(msg: ClientMessage): void {
switch (msg.type) {
case "join":
case "watch":
break;
case "vote": {
if (this.revealed) break;
const me = this.players.find((p) => !p.bot);
if (me) me.vote = msg.value;
break;
}
case "reveal":
if (this.players.some((p) => p.vote !== null)) this.revealed = true;
break;
case "reset":
this.startNewRound();
break;
case "deck":
this.deck = msg.cards;
this.startNewRound();
break;
}
this.emit();
}
close(): void {
this.disposed = true;
for (const t of this.timers) clearTimeout(t);
this.timers.clear();
this.channel?.close();
this.channel = null;
}
private startNewRound(): void {
this.revealed = false;
this.revealScheduled = false;
this.round += 1;
this.rollBotTarget();
for (const p of this.players) {
p.vote = null;
if (p.bot) this.scheduleBotVote(p.id);
}
}
private rollBotTarget(): void {
const numericCount = this.deck.filter((c) => Number.isFinite(Number(c))).length || this.deck.length;
this.botTargetIndex = Math.floor(Math.random() * Math.min(numericCount, this.deck.length));
}
private scheduleBotVote(botId: string): void {
this.after(1500 + Math.random() * 4500, () => {
const bot = this.players.find((p) => p.id === botId);
if (!bot || this.revealed || bot.vote !== null) return;
bot.vote = this.pickBotVote();
this.emit();
this.maybeAutopilot();
});
}
/** Standalone TV demo loop: reveal once everyone voted, then start over. */
private maybeAutopilot(): void {
if (!this.autopilot || this.revealed || this.revealScheduled) return;
if (this.players.length < 2 || !this.players.every((p) => p.vote !== null)) return;
this.revealScheduled = true;
this.after(1800, () => {
if (this.revealed) return;
this.revealed = true;
this.emit();
this.after(6000, () => {
this.startNewRound();
this.emit();
});
});
}
private pickBotVote(): string {
const roll = Math.random();
let idx = this.botTargetIndex;
if (roll > 0.9 && this.deck.includes("?")) return "?";
if (roll > 0.6) idx += Math.random() > 0.5 ? 1 : -1;
idx = Math.max(0, Math.min(this.deck.length - 1, idx));
return this.deck[idx];
}
private after(ms: number, fn: () => void): void {
const t = setTimeout(() => {
this.timers.delete(t);
if (!this.disposed) fn();
}, ms);
this.timers.add(t);
}
private emit(): void {
const state: RoomState = {
roomId: this.roomId,
deck: [...this.deck],
revealed: this.revealed,
round: this.round,
players: this.players.map((p) => ({
id: p.id,
name: p.name,
emoji: p.emoji,
voted: p.vote !== null,
vote: this.revealed ? p.vote : null,
})),
};
this.handlers.onState(state);
this.channel?.postMessage({ type: "state", state });
}
}
/**
* Spectator in demo mode: mirrors the mock room of an open game tab via
* BroadcastChannel. If no game tab answers, falls back to a self-running
* bots-only demo so the TV is never blank.
*/
class MockSpectatorConnection implements RoomConnection {
private channel: BroadcastChannel | null = null;
private fallback: MockConnection | null = null;
private fallbackTimer: ReturnType<typeof setTimeout> | null = null;
private hasHost = false;
constructor(roomId: string, handlers: ConnectionHandlers) {
handlers.onStatus("connecting");
if (typeof BroadcastChannel !== "undefined") {
this.channel = new BroadcastChannel(channelName(roomId));
this.channel.onmessage = (e) => {
if (e.data?.type !== "state") return;
this.hasHost = true;
if (this.fallbackTimer) {
clearTimeout(this.fallbackTimer);
this.fallbackTimer = null;
}
if (this.fallback) {
this.fallback.close();
this.fallback = null;
}
handlers.onStatus("demo");
handlers.onState(e.data.state as RoomState);
};
this.channel.postMessage({ type: "sync" });
}
this.fallbackTimer = setTimeout(() => {
if (!this.hasHost) {
this.fallback = new MockConnection(roomId, "", { name: "", emoji: "" }, handlers, true);
}
}, 900);
}
send(): void {
/* view-only */
}
close(): void {
if (this.fallbackTimer) clearTimeout(this.fallbackTimer);
this.fallback?.close();
this.channel?.close();
this.channel = null;
}
}
+42
View File
@@ -0,0 +1,42 @@
export interface DeckPreset {
id: string;
label: string;
cards: string[];
}
export const DECK_PRESETS: DeckPreset[] = [
{
id: "fibonacci",
label: "Fibonacci",
cards: ["0", "1", "2", "3", "5", "8", "13", "21", "34", "?", "☕"],
},
{
id: "tshirt",
label: "T-Shirt",
cards: ["XS", "S", "M", "L", "XL", "XXL", "?", "☕"],
},
{
id: "powers",
label: "Powers of 2",
cards: ["1", "2", "4", "8", "16", "32", "64", "?", "☕"],
},
];
export const DEFAULT_DECK = DECK_PRESETS[0].cards;
const MAX_CARDS = 15;
const MAX_CARD_LEN = 4;
/** Parse a comma/space separated custom deck. Returns null if unusable. */
export function parseCustomDeck(raw: string): string[] | null {
const seen = new Set<string>();
const cards: string[] = [];
for (const piece of raw.split(/[,\s]+/)) {
const v = piece.trim();
if (!v || [...v].length > MAX_CARD_LEN || seen.has(v)) continue;
seen.add(v);
cards.push(v);
if (cards.length === MAX_CARDS) break;
}
return cards.length >= 2 ? cards : null;
}
+7
View File
@@ -0,0 +1,7 @@
import type { RoomState } from "../types";
export function isConsensus(state: RoomState | null): boolean {
if (!state?.revealed) return false;
const votes = state.players.map((p) => p.vote).filter((v): v is string => v !== null);
return votes.length >= 2 && votes.every((v) => v === votes[0]);
}
+16
View File
@@ -0,0 +1,16 @@
import { useEffect, useState } from "react";
export function navigate(to: string): void {
history.pushState({}, "", to);
dispatchEvent(new PopStateEvent("popstate"));
}
export function usePath(): string {
const [path, setPath] = useState(location.pathname);
useEffect(() => {
const onPop = () => setPath(location.pathname);
addEventListener("popstate", onPop);
return () => removeEventListener("popstate", onPop);
}, []);
return path;
}
+65
View File
@@ -0,0 +1,65 @@
import type { Profile } from "../types";
const PROFILE_KEY = "showdown:profile";
const PLAYER_ID_KEY = "showdown:pid";
const PENDING_DECK_KEY = "showdown:pendingDeck";
export function loadProfile(): Profile | null {
try {
const raw = localStorage.getItem(PROFILE_KEY);
if (!raw) return null;
const p = JSON.parse(raw);
if (typeof p?.name === "string" && typeof p?.emoji === "string" && p.name) {
return { name: p.name, emoji: p.emoji };
}
} catch {
/* corrupted storage — treat as signed out */
}
return null;
}
export function saveProfile(p: Profile): void {
localStorage.setItem(PROFILE_KEY, JSON.stringify(p));
}
/** Stable per-browser identity so a refresh re-claims the same seat. */
export function getPlayerId(): string {
let id = localStorage.getItem(PLAYER_ID_KEY);
if (!id) {
id =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
localStorage.setItem(PLAYER_ID_KEY, id);
}
return id;
}
/** The deck chosen on the home page, applied right after the room creator joins. */
export function stashPendingDeck(cards: string[]): void {
sessionStorage.setItem(PENDING_DECK_KEY, JSON.stringify(cards));
}
export function takePendingDeck(): string[] | null {
const raw = sessionStorage.getItem(PENDING_DECK_KEY);
if (!raw) return null;
sessionStorage.removeItem(PENDING_DECK_KEY);
try {
const cards = JSON.parse(raw);
if (Array.isArray(cards) && cards.every((c) => typeof c === "string")) {
return cards;
}
} catch {
/* ignore */
}
return null;
}
const ADJECTIVES = ["dusty", "rowdy", "lucky", "sneaky", "golden", "wild", "lone", "rusty", "swift", "fancy"];
const NOUNS = ["cactus", "saloon", "wagon", "sheriff", "coyote", "mustang", "nugget", "lasso", "spur", "tumbleweed"];
export function generateRoomId(): string {
const pick = (arr: string[]) => arr[Math.floor(Math.random() * arr.length)];
const num = String(Math.floor(Math.random() * 90) + 10);
return `${pick(ADJECTIVES)}-${pick(NOUNS)}-${num}`;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { MotionConfig } from "motion/react";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<MotionConfig reducedMotion="user">
<App />
</MotionConfig>
</StrictMode>,
);
+113
View File
@@ -0,0 +1,113 @@
import { useState } from "react";
import { motion } from "motion/react";
import type { Profile } from "../types";
import { DECK_PRESETS, DEFAULT_DECK, parseCustomDeck } from "../lib/decks";
import { generateRoomId, loadProfile, saveProfile, stashPendingDeck } from "../lib/session";
import { navigate } from "../lib/router";
import { Logo } from "../components/Logo";
import { ProfileForm } from "../components/ProfileForm";
import { CardBack, CardFace } from "../components/Cards";
export function Home() {
const [deckId, setDeckId] = useState(DECK_PRESETS[0].id);
const [customRaw, setCustomRaw] = useState("1, 2, 3, 5, 8");
const chosenDeck =
deckId === "custom"
? parseCustomDeck(customRaw)
: (DECK_PRESETS.find((d) => d.id === deckId)?.cards ?? DEFAULT_DECK);
const create = (profile: Profile) => {
saveProfile(profile);
stashPendingDeck(chosenDeck ?? DEFAULT_DECK);
navigate(`/room/${generateRoomId()}`);
};
return (
<div className="home">
<header className="hero">
<HeroCards />
<Logo />
<motion.p
className="tagline"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.55 }}
>
Saddle up. Point stories. Settle the score.
</motion.p>
</header>
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.45, type: "spring", stiffness: 220, damping: 22 }}
>
<ProfileForm title="Join the posse" cta="Deal me in →" initial={loadProfile()} onSubmit={create}>
<div className="field">
<span className="field__label">Deck</span>
<div className="deck-pills">
{DECK_PRESETS.map((d) => (
<button
key={d.id}
type="button"
className={`pill${deckId === d.id ? " pill--on" : ""}`}
onClick={() => setDeckId(d.id)}
>
{d.label}
</button>
))}
<button
type="button"
className={`pill${deckId === "custom" ? " pill--on" : ""}`}
onClick={() => setDeckId("custom")}
>
Custom
</button>
</div>
{deckId === "custom" && (
<input
className="input"
value={customRaw}
onChange={(e) => setCustomRaw(e.target.value)}
placeholder="1, 2, 3, 5, 8"
/>
)}
<div className="deck-preview">
{(chosenDeck ?? []).map((v) => (
<CardFace key={v} value={v} size="xs" />
))}
</div>
</div>
</ProfileForm>
</motion.div>
<p className="footnote">
No sign-up. No database. Rooms vanish like tumbleweed when everyone leaves.
</p>
</div>
);
}
function HeroCards() {
return (
<div className="hero-cards" aria-hidden>
<motion.div
className="hero-card hero-card--l"
initial={{ y: 120, opacity: 0, rotate: -40 }}
animate={{ y: 0, opacity: 1, rotate: -16 }}
transition={{ delay: 0.5, type: "spring", stiffness: 220, damping: 18 }}
>
<CardFace value="13" size="lg" />
</motion.div>
<motion.div
className="hero-card hero-card--r"
initial={{ y: 120, opacity: 0, rotate: 40 }}
animate={{ y: 0, opacity: 1, rotate: 14 }}
transition={{ delay: 0.62, type: "spring", stiffness: 220, damping: 18 }}
>
<CardBack size="lg" />
</motion.div>
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence } from "motion/react";
import type { ConnStatus, Profile, RoomState } from "../types";
import { createConnection } from "../lib/connection";
import type { RoomConnection } from "../lib/connection";
import { getPlayerId, loadProfile, saveProfile, takePendingDeck } from "../lib/session";
import { isConsensus } from "../lib/game";
import { navigate } from "../lib/router";
import { Table } from "../components/Table";
import { CardHand } from "../components/CardHand";
import { DeckModal } from "../components/DeckModal";
import { Confetti } from "../components/Confetti";
import { Logo } from "../components/Logo";
import { ProfileForm } from "../components/ProfileForm";
export function Room({ roomId }: { roomId: string }) {
const playerId = useMemo(getPlayerId, []);
const [profile, setProfile] = useState<Profile | null>(loadProfile);
const [state, setState] = useState<RoomState | null>(null);
const [status, setStatus] = useState<ConnStatus>("connecting");
const [myVote, setMyVote] = useState<string | null>(null);
const [deckOpen, setDeckOpen] = useState(false);
const [copied, setCopied] = useState(false);
const connRef = useRef<RoomConnection | null>(null);
useEffect(() => {
if (!profile) return;
const conn = createConnection(roomId, playerId, profile, {
onState: setState,
onStatus: setStatus,
});
connRef.current = conn;
const pending = takePendingDeck();
if (pending) conn.send({ type: "deck", cards: pending });
return () => {
conn.close();
connRef.current = null;
};
}, [roomId, profile, playerId]);
// A new round (reset or deck change) clears the local selection.
const round = state?.round;
useEffect(() => {
setMyVote(null);
}, [round]);
const consensus = useMemo(() => isConsensus(state), [state]);
const castVote = (value: string) => {
if (!state || state.revealed) return;
const next = myVote === value ? null : value;
setMyVote(next);
connRef.current?.send({ type: "vote", value: next });
};
const openTv = () => {
const url = `${location.origin}/room/${roomId}/tv`;
if (!window.open(url, "showdown-tv", "popup=yes,width=1280,height=800")) {
window.open(url, "_blank");
}
};
const copyInvite = async () => {
try {
await navigator.clipboard.writeText(location.href);
} catch {
/* clipboard unavailable — the room code in the URL still works */
}
setCopied(true);
setTimeout(() => setCopied(false), 1600);
};
if (!profile) {
return (
<div className="gate">
<Logo />
<ProfileForm
title="Take a seat"
cta="Join the game →"
onSubmit={(p) => {
saveProfile(p);
setProfile(p);
}}
/>
</div>
);
}
return (
<div className="room">
<header className="topbar">
<a
className="topbar__logo"
href="/"
onClick={(e) => {
e.preventDefault();
navigate("/");
}}
>
<span className="topbar__star"></span>
<span className="topbar__word">SHOWDOWN</span>
</a>
<button type="button" className={`room-pill${copied ? " room-pill--copied" : ""}`} onClick={copyInvite}>
{copied ? "✓ Link copied!" : `🔗 ${roomId}`}
</button>
<div className="topbar__right">
{status === "demo" && <span className="demo-chip">demo</span>}
<span className={`status-dot status-dot--${status}`} title={status} />
<button type="button" className="btn btn--ghost btn--small" onClick={openTv} title="Open the TV display">
📺<span className="btn-label"> TV</span>
</button>
<button type="button" className="btn btn--ghost btn--small" onClick={() => setDeckOpen(true)} title="Change the deck">
🃏<span className="btn-label"> Deck</span>
</button>
</div>
</header>
{status === "offline" && <div className="toast">Lost connection wranglin it back</div>}
{state ? (
<>
<Table
state={state}
playerId={playerId}
consensus={consensus}
copied={copied}
onReveal={() => connRef.current?.send({ type: "reveal" })}
onReset={() => connRef.current?.send({ type: "reset" })}
onInvite={copyInvite}
/>
<CardHand deck={state.deck} myVote={myVote} locked={state.revealed} onPick={castVote} />
</>
) : (
<div className="loading">Shufflin the deck</div>
)}
<AnimatePresence>
{deckOpen && state && (
<DeckModal
current={state.deck}
onClose={() => setDeckOpen(false)}
onSave={(cards) => {
connRef.current?.send({ type: "deck", cards });
setDeckOpen(false);
}}
/>
)}
</AnimatePresence>
{consensus && state && <Confetti key={state.round} />}
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useMemo, useState } from "react";
import type { ConnStatus, RoomState } from "../types";
import { createConnection } from "../lib/connection";
import { isConsensus } from "../lib/game";
import { Table } from "../components/Table";
import { Confetti } from "../components/Confetti";
/** Big-screen spectator view: no seat, no controls, just the showdown. */
export function TvRoom({ roomId }: { roomId: string }) {
const [state, setState] = useState<RoomState | null>(null);
const [status, setStatus] = useState<ConnStatus>("connecting");
const [fullscreen, setFullscreen] = useState(false);
useEffect(() => {
const conn = createConnection(
roomId,
"tv",
{ name: "TV", emoji: "📺" },
{ onState: setState, onStatus: setStatus },
{ spectator: true },
);
return () => conn.close();
}, [roomId]);
useEffect(() => {
const onChange = () => setFullscreen(Boolean(document.fullscreenElement));
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
}, []);
const consensus = useMemo(() => isConsensus(state), [state]);
const toggleFullscreen = () => {
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
} else {
document.documentElement.requestFullscreen().catch(() => {});
}
};
return (
<div className="tv">
<header className="tvbar">
<div className="tvbar__logo">
<span className="topbar__star"></span>
<span>SHOWDOWN</span>
</div>
<div className="tvbar__room">
<div className="tvbar__code">{roomId}</div>
<div className="tvbar__url">join at {location.host}/room/{roomId}</div>
</div>
<div className="tvbar__right">
{status === "demo" && <span className="demo-chip">demo</span>}
<span className={`status-dot status-dot--${status}`} title={status} />
<button type="button" className="btn btn--ghost btn--small" onClick={toggleFullscreen}>
{fullscreen ? "✕ Exit" : "⛶ Fullscreen"}
</button>
</div>
</header>
{state ? (
<Table state={state} playerId="" consensus={consensus} spectator />
) : (
<div className="loading">Tunin in</div>
)}
{consensus && state && <Confetti key={state.round} count={140} />}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Shared shapes for the Showdown room protocol.
* The Go backend mirrors these — see PROTOCOL.md at the repo root.
*/
export interface Player {
id: string;
name: string;
emoji: string;
/** True once the player has picked a card this round. */
voted: boolean;
/** The actual card. Null until the round is revealed (server hides it). */
vote: string | null;
}
export interface RoomState {
roomId: string;
deck: string[];
revealed: boolean;
/** Increments on every reset / deck change. Lets clients detect new rounds. */
round: number;
players: Player[];
}
export type ClientMessage =
| { type: "join"; playerId: string; name: string; emoji: string }
| { type: "watch" }
| { type: "vote"; value: string | null }
| { type: "reveal" }
| { type: "reset" }
| { type: "deck"; cards: string[] };
export type ServerMessage = { type: "state"; state: RoomState };
export type ConnStatus = "connecting" | "online" | "offline" | "demo";
export interface Profile {
name: string;
emoji: string;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"useDefineForClassFields": true,
"skipLibCheck": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
// Forwarded to the Go backend once it exists.
"/ws": {
target: "ws://localhost:8080",
ws: true,
},
},
},
});