Skip to content

drlukeangel/fate-games

Repository files navigation

Fate Games 🎰

"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.


Table of Contents


Quick Start

# 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 lint

What Is This?

Fate 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.


Architecture Overview

┌─────────────────────────────────────────────┐
│                   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).


Project Structure

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

Key Concepts

Teams & Challenges

  • A Team (called Group in 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

Game Types

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 ☠️

Quick Decisions

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.

Gamification Titles

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 💀

Data Flow

Team Pick Flow

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

Quick Decision Flow

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)

Pages & Routes

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

Game Components

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
}

SlotMachine.tsx

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.

RaceGame.tsx

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.

ClawMachine.tsx (T-Rex Pit)

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.

MysteryCardFlip.tsx (Tarot Draw)

Phases: dealing (spring animations) → shuffling (random offsets) → picking (user clicks) → revealing (3D CSS flip). Each card back shows a different tarot emoji.

WheelOfDeath.tsx

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.


Store (State Management)

File: src/store/useAppStore.ts

Single Zustand store. Every state change writes to localStorage immediately.

Key Actions

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

Key Selectors

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

localStorage Keys

All prefixed with spa_: spa_users, spa_groups, spa_memberships, spa_gameRuns, spa_cycleState, spa_settings, spa_challenges, spa_quickDecisions


Theming (Light / Dark / System)

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.


Seed Data

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.


Deployment (Docker)

# Build image
docker build -t wheel-of-fate .

# Run on port 8080
docker run -p 8080:80 wheel-of-fate

The Dockerfile uses a two-stage build:

  1. node:22-alpine — installs deps, builds with Vite
  2. nginx:alpine — serves static files with SPA routing and asset caching

Common Tasks

Add a new user field

  1. Add the field to User type in src/types/index.ts
  2. Update addUser and updateUser in src/store/useAppStore.ts
  3. Add the field to the edit form in src/pages/AdminUsersPage.tsx
  4. Update CSV parsing in src/pages/AdminImportPage.tsx if it should be importable
  5. Update seed data in src/data/seedData.ts

Add a new game type

  1. Add the key to GameType union in src/types/index.ts
  2. Add entry to GAME_TYPE_INFO in src/types/index.ts
  3. Create the component in src/components/games/YourGame.tsx implementing the standard Props interface
  4. Import it in src/pages/PlayPage.tsx and add to the game selector + render switch
  5. Add game-specific celebration emojis in src/components/ui/Celebration.tsx

Add a new page

  1. Create src/pages/YourPage.tsx
  2. Add a route in src/App.tsx
  3. Add a nav link in src/components/layout/Sidebar.tsx

Change theme colors

Edit src/index.css. Modify values in the @theme block (light mode) and .dark block (dark mode). All components using arcade-* classes update automatically.


Tech Stack

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

About

Fate Games — No volunteers needed. A gamified team picker arcade with slot machines, races, card flips, and more.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages