|

Building a Real-Time Planning Poker App — From Zero to Estimating (Part 1/6)

Sprint planning. You know the ritual — the team gathers, someone shares a Jira ticket, everyone silently guesses how long it’ll take, and then you all flip your cards at the same time. If you’re lucky, there’s consensus. If not, there’s a healthy debate.

The problem? Most planning poker tools out there are either clunky enterprise software that makes you sign up, install plugins, and sell your firstborn — or they’re so basic they don’t even save your session history.

So I built one. A free, real-time, end-to-end encrypted planning poker app. No accounts. No sign-up. No cost. Just open the URL and start estimating. Let me tell you how.

What exactly are we building?

Here’s the feature list I ended up with:

  • 🃏 Multiple card decks — Fibonacci, T-Shirt, Powers of 2, or build your own custom deck
  • Real-time multiplayer — SSE streaming via Cloudflare Worker, instant updates, zero polling
  • 🔐 End-to-end encrypted — AES-GCM-256. The key never touches any server
  • 👁 Anonymous voting — votes stay hidden until the facilitator reveals
  • 🎭 Roles — Facilitator, Voter, Observer (watch-only)
  • Countdown timer — auto-reveals votes when it expires
  • 📜 Session history — every revealed round archived with estimates and individual votes
  • 🔗 One-click sharing — encryption key embedded in the share URL automatically
  • 🌙 Dark mode — because of course

The tech stack

Every tool earns its place. Here’s what I picked and why:

LayerTechnologyWhy
FrameworkReact 19 + TypeScriptWhat I know best. Typed props, hooks, zero surprises
BuildViteInstant HMR, clean config, fast builds
StylingHeroUI v3 + Tailwind CSS v4Beautiful defaults, dark mode out of the box
StateZustandTiny, fast, no boilerplate — perfect for this scale
RoutingReact Router v6File-ish routing with useParams and useNavigate
ChartsRechartsDrop-in vote distribution charts
EncryptionWeb Crypto APIBrowser-native, hardware-accelerated
Real-timeUpstash Redis + SSEPub/sub streaming through a Cloudflare Worker
WorkerHono on Cloudflare WorkersTiny, fast, great DX for edge-first APIs
TestsVitest + Testing LibraryFast, modern, built for Vite

That’s React 19 on the front, a Cloudflare Worker as a thin proxy in the middle, and Upstash Redis as the persistence layer. The browser does all the heavy lifting — encryption, state management, business logic. The server just shuffles encrypted bytes around.

Project setup

Nothing fancy — Vite’s React TypeScript template with pnpm:

pnpm create vite@latest planning-poker -- --template react-ts
cd planning-poker
pnpm install

The project structure I landed on:

src/
├── adapters/         # Upstash REST communication
├── components/
│   ├── AppIcon/      # SVG logo
│   ├── Layout/       # App shell + dark-mode toggle
│   ├── PlayerCard/   # Voter tile
│   ├── Timer/        # Countdown ring with auto-reveal
│   ├── VotingDeck/   # Card selection grid
│   └── ResultsChart/ # Vote distribution bar chart
├── config/
│   └── decks.ts      # Built-in deck definitions + timer presets
├── hooks/
│   └── usePokerStore.ts  # Zustand store — the brain
├── pages/
│   ├── Landing.tsx   # Marketing page
│   ├── Home.tsx      # Create / join form
│   ├── Room.tsx      # Live room
│   └── NotFound.tsx  # 404
├── types/
│   └── index.ts      # Room, Player, Story, RoomAdapter interfaces
└── utils/
    ├── crypto.ts     # AES-GCM-256 helpers
    └── logger.ts     # Feature-flagged logging

Every folder has a clear purpose. Components are focused and reusable. The adapters/ directory abstracts away the backend so you could theoretically swap Upstash for anything that implements the RoomAdapter interface.

Defining the domain types

Before writing a single component, I typed out the domain. This saves you from the “what shape is this object again?” debugging later:

export type CardValue = string;

export interface Player {
  id: string;
  name: string;
  role: "facilitator" | "voter" | "observer";
  vote: CardValue | null;
  isOnline: boolean;
  joinedAt: number;
  emoji?: string;
}

export interface Room {
  id: string;
  name: string;
  deckType: DeckType;
  customCards?: CardValue[];
  status: "voting" | "revealed" | "idle";
  players: Record<string, Player>;
  currentStory: Story | null;
  stories: Story[];
  timerStart: number | null;
  timerDuration: number | null;
  createdAt: number;
  facilitatorId: string;
  cmdCount?: number;
}

A few design decisions worth noting:

players is a Record<string, Player>, not an array. O(1) lookups by ID. When you’re updating one player’s vote in a room of 15, you don’t want to iterate.

facilitatorId is a separate field. The facilitator role is also set on the player object, but having it at the room level makes checking “am I the facilitator?” a one-liner instead of a search.

cmdCount for rate limiting. Each write to Redis increments this counter. When it hits the cap, the room goes read-only. More on this in Part 3.

The deck system

I wanted to support multiple estimation methods without overcomplicating things. Here’s the deck config:

export const DECKS: Record<string, Deck> = {
  fibonacci: {
    id: "fibonacci",
    name: "Fibonacci",
    cards: ["1", "2", "3", "5", "8", "13", "21", "34", "?", "☕"],
  },
  tshirt: {
    id: "tshirt",
    name: "T-Shirt",
    cards: ["XS", "S", "M", "L", "XL", "XXL", "?", "☕"],
  },
  powers2: {
    id: "powers2",
    name: "Powers of 2",
    cards: ["1", "2", "4", "8", "16", "32", "64", "?", "☕"],
  },
  custom: {
    id: "custom",
    name: "Custom",
    cards: ["1", "2", "3", "5", "8", "13", "?"],
  },
};

export const PLAYER_EMOJIS = [
  "🦊", "🐺", "🐻", "🐼", "🦁", "🐯", "🐨", "🐸",
  "🦉", "🐙", "🦋", "🦄", "🐲", "🦖", "🐳", "🦈",
  "🐝", "🦩", "🦚", "🦜", "🐬", "🦦", "🦥", "🐧",
];

export const TIMER_PRESETS = [30, 60, 120, 180, 300];

Every deck includes ? (don’t know) and (I need a break). Because honestly, those are the most important cards.

The emoji list is used for player avatars — when you join a room, you get a unique emoji that’s not already taken. No two 🦊s in the same room.

What’s next

In this first part I’ve laid the groundwork — the domain model, the project structure, and the tech choices. The foundation is boring on purpose. When things get interesting (real-time sync, encryption, facilitator handoff), you want your types and architecture to just work.

In Part 2, we’ll build the actual room experience — the Zustand store, the voting flow, session history, and how the facilitator controls work.

Keep pushing forward and savor every step of your coding journey.