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:
| Layer | Technology | Why |
|---|---|---|
| Framework | React 19 + TypeScript | What I know best. Typed props, hooks, zero surprises |
| Build | Vite | Instant HMR, clean config, fast builds |
| Styling | HeroUI v3 + Tailwind CSS v4 | Beautiful defaults, dark mode out of the box |
| State | Zustand | Tiny, fast, no boilerplate — perfect for this scale |
| Routing | React Router v6 | File-ish routing with useParams and useNavigate |
| Charts | Recharts | Drop-in vote distribution charts |
| Encryption | Web Crypto API | Browser-native, hardware-accelerated |
| Real-time | Upstash Redis + SSE | Pub/sub streaming through a Cloudflare Worker |
| Worker | Hono on Cloudflare Workers | Tiny, fast, great DX for edge-first APIs |
| Tests | Vitest + Testing Library | Fast, 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.
