"No Volunteers Needed" — A gamified team member picker that turns the boring task of choosing who does what into an arcade experience with slot machines, races, card flips, and more.
Built with React 19 + TypeScript + Vite + Tailwind CSS v4 + Zustand.
- Quick Start
- What Is This?
- Architecture Overview
- Project Structure
- Key Concepts
- Data Flow
- Pages & Routes
- Game Components
- Store (State Management)
- Theming (Light / Dark / System)
- Seed Data
- Deployment (Docker)
- Common Tasks
- Tech Stack
# Install dependencies
npm install
# Start dev server (hot reload on http://localhost:5199)
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
# Lint
npm run lintFate Games is a single-page app that helps teams randomly pick members for tasks (on-call, demo hosting, code review, etc.) using fun arcade-style games. Instead of a boring random number generator, you get:
- Slot Machine — spinning reels that land on the chosen person
- T-Rex Pit — a dinosaur head chomps down on the unlucky pick
- Grand Prix — emoji racers compete in lanes (with heats for large teams)
- Tarot Draw — mystery cards flip to reveal the chosen one
- Wheel of Death — a spinning wheel of fortune with skulls
It also supports Quick Decisions — enter a question + options, pick a game, and let fate decide (e.g., "Where should we eat lunch?").
All data is stored in localStorage — no backend needed. Seed data loads on first launch to demo the app.
┌─────────────────────────────────────────────┐
│ App.tsx │
│ (BrowserRouter + Routes) │
├─────────────────────────────────────────────┤
│ AppShell.tsx │
│ ┌──────────┬───────────┐ │
│ │ Sidebar │ <Outlet> │ │
│ │ (nav) │ (pages) │ │
│ └──────────┴───────────┘ │
├─────────────────────────────────────────────┤
│ useAppStore (Zustand) │
│ users | groups | memberships | gameRuns │
│ challenges | quickDecisions | settings │
├─────────────────────────────────────────────┤
│ localStorage (spa_* keys) │
└─────────────────────────────────────────────┘
One store, no backend. The Zustand store is the single source of truth. Every mutation writes to both Zustand state and localStorage. On app load, state hydrates from localStorage (or seed data on first visit).
src/
├── main.tsx # React 19 entry point
├── App.tsx # Router with all routes
├── index.css # Tailwind v4 theme tokens (light + dark)
│
├── types/
│ └── index.ts # All TypeScript types + game constants
│
├── store/
│ └── useAppStore.ts # Zustand store (all state + actions + selectors)
│
├── storage/
│ └── adapter.ts # localStorage get/set/remove wrapper
│
├── hooks/
│ └── useTheme.ts # Light/dark/system theme toggle
│
├── data/
│ ├── seedData.ts # Demo data: 16 users, 3 teams, 8 challenges, ~120 game runs
│ └── quotes.ts # 202 snarky quotes for the Play page
│
├── components/
│ ├── layout/
│ │ ├── AppShell.tsx # Sidebar + page content wrapper
│ │ └── Sidebar.tsx # Left nav with emoji icons
│ │
│ ├── ui/
│ │ ├── Avatar.tsx # User avatar (photo > emoji > auto-assigned snarky emoji)
│ │ ├── Celebration.tsx # Confetti + streamer celebration overlay
│ │ └── ConfirmDialog.tsx # Modal with confirm/cancel buttons
│ │
│ └── games/ # All share Props: candidates, winner, onComplete, animationSpeed
│ ├── SlotMachine.tsx # Spinning reels, adaptive grid (1-30 reels)
│ ├── ClawMachine.tsx # T-Rex head chomps target from prize pit
│ ├── RaceGame.tsx # Emoji lane race (heats for >10 candidates)
│ ├── MysteryCardFlip.tsx # Tarot-themed card flip reveal
│ └── WheelOfDeath.tsx # SVG spinning wheel with death theme
│
└── pages/
├── PlayPage.tsx # Main game: Team Pick + Quick Decision modes
├── ActivityPage.tsx # Timeline of all picks + decisions
├── LeaderboardPage.tsx # Winners (most picked) + Losers (survivors)
├── AdminUsersPage.tsx # User CRUD with emoji/photo/color
├── AdminTeamsPage.tsx # Team CRUD with member management
├── AdminImportPage.tsx # CSV user import with auto-assign icons/colors
├── AdminGroupsPage.tsx # Redirect to AdminTeamsPage (legacy)
├── TeamDetailPage.tsx # Team detail: overview, challenges, members, activity
├── UserDetailPage.tsx # User profile: stats, teams, activity, "WTF stats"
└── SettingsPage.tsx # Speed, sound, theme, stats, import/export, reset
- A Team (called
Groupin the code/types) is a collection of users (e.g., "Engineering") - A Challenge belongs to a team and represents a recurring task (e.g., "On-Call Rotation")
- Challenges have a pick mode:
depleting— each person is picked once before the pool resets (round-robin style)fullRandom— truly random every time, anyone can be picked again immediately
Five visual games, all producing the same result (a random winner). The game is purely cosmetic — the winner is determined by the store's playGame() before the animation starts.
| Key | Name | Icon |
|---|---|---|
slot |
Slot Machine | 🎰 |
claw |
T-Rex Pit | 🦖 |
race |
Grand Prix | 🏎️ |
cards |
Tarot Draw | 🃏 |
wheel |
Wheel of Death | ☠️ |
Standalone mode — no team needed. Enter a question + options, pick a game, and the app randomly selects a winner. Options are temporarily converted to fake User objects for the game components.
Users earn titles based on how many times they've been picked:
| Picks | Title |
|---|---|
| 0+ | Fresh Meat 🥩 |
| 1+ | Rookie 🌱 |
| 6+ | Regular 🎯 |
| 16+ | Veteran ⚔️ |
| 31+ | Legend 👑 |
| 51+ | Immortal 💀 |
User selects team + challenge + game type
│
▼
PlayPage calls store.playGame(groupId, challengeId)
│
▼
Store picks winner from candidate pool
(depleting: removes from cycle, fullRandom: picks from all)
│
▼
Returns { winner, candidates }
│
▼
PlayPage renders <GameComponent candidates={...} winner={...} />
│
▼
Game animation plays (winner is predetermined, animation is cosmetic)
│
▼
Game calls onComplete()
│
▼
PlayPage calls store.recordGameRun()
│
▼
Shows winner card with celebration confetti
User enters question + options
│
▼
Options converted to temporary User objects
│
▼
Random winner index selected
│
▼
Game animation plays
│
▼
store.recordQuickDecision() (unless user skips)
│
▼
Shows result with all options (winner highlighted)
| Route | Page | Purpose |
|---|---|---|
/play |
PlayPage | Main interface — pick games and make decisions |
/activity |
ActivityPage | Chronological timeline of all picks + decisions |
/leaderboards |
LeaderboardPage | Who gets picked most/least with filters |
/admin/users |
AdminUsersPage | Create, edit, delete users |
/admin/users/:userId |
UserDetailPage | Deep user profile with stats and activity |
/admin/teams |
AdminTeamsPage | Create, edit, delete teams; manage members |
/admin/teams/:teamId |
TeamDetailPage | Team overview, challenges, members, activity |
/admin/import |
AdminImportPage | Bulk import users via CSV |
/settings |
SettingsPage | App preferences, data management |
* |
→ /play |
Catch-all redirect |
All games implement the same interface:
interface Props {
candidates: User[]; // All users in the pool
winner: User; // Pre-determined winner (animation is cosmetic)
onComplete: () => void; // Called when animation finishes
animationSpeed: number; // 0.5 to 2.0 multiplier
}Scaling logic: Dynamically adjusts layout based on candidate count.
- 1-3 candidates → large reels, single row
- 4-6 → medium reels, single row
- 7-10 → small reels, single row
- 11-20 → small reels, 2 rows
- 21-30 → small reels, 3 rows (capped at 30 reels)
Reels stop left-to-right with staggered timing. Every candidate's avatar cycles through the reels during the spin.
Heat system: For more than 10 candidates, splits into tournament heats.
- Each heat races up to 10 candidates at 1.5x speed
- Heat winners accumulate in a visible "Finalists" row above the track
- After all heats, a final round races all heat winners at normal speed
- The predetermined overall winner wins their heat AND the final
Speed curves: Uses buildSpeedCurve() with seeded waypoints and random burst points so races look unpredictable. Winners aren't obviously fast from the start.
Phases: scan → lock → drop → grab → lift → done. The T-Rex head is built entirely with CSS (no images). Uses seededRandom() for deterministic prize positions so the head targets the correct location.
Phases: dealing (spring animations) → shuffling (random offsets) → picking (user clicks) → revealing (3D CSS flip). Each card back shows a different tarot emoji.
SVG wheel with computed segment paths (inner radius creates donut shape). Features 32 decorative bulbs, dramatic spin deceleration (7-9 rotations), death explosion particles on reveal.
File: src/store/useAppStore.ts
Single Zustand store. Every state change writes to localStorage immediately.
| Action | What it does |
|---|---|
playGame(groupId, challengeId) |
Picks a random winner, handles depleting pools |
recordGameRun(...) |
Saves a completed game to history |
addUser / updateUser / deleteUser |
User CRUD |
importUsers(data[]) |
Bulk create users from CSV import |
addGroup / updateGroup / deleteGroup |
Team CRUD |
addMembership / removeMembership |
Add/remove user from team |
addChallenge / archiveChallenge |
Challenge management |
recordQuickDecision(...) |
Save a quick decision result |
updateSettings(...) |
Change app preferences |
resetAllData() |
Wipe everything and reload seed data |
| Selector | Returns |
|---|---|
getUsersInGroup(groupId) |
All users in a team |
getRemainingUsersForChallenge(groupId, challengeId) |
Users not yet picked in current cycle |
getWinnersBoard(filters?) |
Leaderboard: most picked users |
getLosersBoard(filters?) |
Leaderboard: users who survived most |
getChallengePickHistory(challengeId) |
All game runs for a challenge |
All prefixed with spa_:
spa_users, spa_groups, spa_memberships, spa_gameRuns, spa_cycleState, spa_settings, spa_challenges, spa_quickDecisions
How it works: The useTheme hook reads settings.theme from the store and toggles a dark class on <html>. Tailwind's dark mode variant picks it up.
Theme tokens are defined as CSS custom properties in src/index.css:
@theme {
--color-arcade-bg: #f5f0e8; /* Light mode background */
--color-arcade-surface: #ffffff;
--color-arcade-accent: #f59e0b; /* Amber accent */
--color-arcade-text: #1c1917;
/* ... etc */
}
.dark {
--color-arcade-bg: #0f0f0f; /* Dark mode overrides */
--color-arcade-surface: #1a1a1a;
--color-arcade-text: #e7e5e4;
/* ... etc */
}Use text-arcade-text, bg-arcade-surface, etc. in components. They automatically switch with the theme.
Adding a new theme token: Add it to both the @theme block (light default) and .dark block (dark override) in index.css, then use it as text-arcade-yourtoken or bg-arcade-yourtoken.
On first launch (no spa_users in localStorage), the store loads demo data from src/data/seedData.ts:
- 16 users with fun names, emojis, and colors
- 3 teams: Engineering (8 members), Design & UX (6), Platform Ops (6)
- 8 challenges across teams (mix of depleting and fullRandom modes)
- ~120 game runs spanning 3 weeks, generated with deterministic pseudo-random
- 18 quick decisions with humorous workplace questions
To reset to seed data: Settings page → Reset All Data.
# Build image
docker build -t wheel-of-fate .
# Run on port 8080
docker run -p 8080:80 wheel-of-fateThe Dockerfile uses a two-stage build:
node:22-alpine— installs deps, builds with Vitenginx:alpine— serves static files with SPA routing and asset caching
- Add the field to
Usertype insrc/types/index.ts - Update
addUserandupdateUserinsrc/store/useAppStore.ts - Add the field to the edit form in
src/pages/AdminUsersPage.tsx - Update CSV parsing in
src/pages/AdminImportPage.tsxif it should be importable - Update seed data in
src/data/seedData.ts
- Add the key to
GameTypeunion insrc/types/index.ts - Add entry to
GAME_TYPE_INFOinsrc/types/index.ts - Create the component in
src/components/games/YourGame.tsximplementing the standardPropsinterface - Import it in
src/pages/PlayPage.tsxand add to the game selector + render switch - Add game-specific celebration emojis in
src/components/ui/Celebration.tsx
- Create
src/pages/YourPage.tsx - Add a route in
src/App.tsx - Add a nav link in
src/components/layout/Sidebar.tsx
Edit src/index.css. Modify values in the @theme block (light mode) and .dark block (dark mode). All components using arcade-* classes update automatically.
| Layer | Technology | Version |
|---|---|---|
| Framework | React | 19.2 |
| Language | TypeScript | 5.9 |
| Build | Vite | 7.3 |
| Styling | Tailwind CSS | 4.2 |
| State | Zustand | 5.0 |
| Animation | Framer Motion | 12.35 |
| Routing | React Router | 7.13 |
| Emoji Picker | emoji-picker-react | 4.18 |
| IDs | uuid | 13.0 |
| Server | nginx (production) | alpine |