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 @@
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,
},
},
},
});