|

State, Votes, and Facilitator Powers — The Room Experience (Part 2/6)

In Part 1 we set up the project, defined the domain types, and picked our tech stack. Now comes the fun part — building the room experience. Creating a room, joining it, voting, revealing, and starting new rounds. The stuff that makes this actually useful.

The beating heart of all this is a single Zustand store. Let’s crack it open.

Why Zustand over Redux or Context?

I’ve used Redux Toolkit, MobX, Jotai — they’re all fine. But for this project, Zustand hit the sweet spot:

import { create } from "zustand";
import { persist } from "zustand/middleware";

export const usePokerStore = create<PokerStore>()(
  persist(
    (set, get) => ({
      // ... everything lives here
    }),
    {
      name: "planning-poker:identity",
      partialize: (state) => ({
        playerId: state.playerId,
        playerName: state.playerName,
        playerEmoji: state.playerEmoji,
      }),
    },
  ),
);

Three things to notice:

  1. persist middleware — but only for identity (name, ID, emoji). Room state is never persisted to localStorage. If you close your tab, you leave the room. That’s the correct behavior.
  2. No providers, no context wrappers. Just import usePokerStore anywhere and call it. Zero ceremony.
  3. Everything in one store. Local state (who am I?), remote state (room data), and actions (vote, reveal, etc.) — all in one place. At this scale, splitting stores would be overengineering.

The player identity flow

Before you can create or join a room, you need a name and an emoji. The Home page handles this with a nice little emoji picker:

const [name, setName] = useState(playerName ?? "");
const [selectedEmoji, setSelectedEmoji] = useState(
  playerEmoji ?? PLAYER_EMOJIS[0]
);

When you first visit, the emoji picker is open. Pick your spirit animal 🦊, type your name, and you’re ready. These persist across sessions in localStorage via Zustand’s persist middleware — next time you visit, you’re already “you.”

Creating a room

The create flow is surprisingly dense for how simple it looks:

createRoom: async (name, deckType, facilitatorName, customCards) => {
  const { nanoid } = await import("nanoid");
  
  const playerId = existingId ?? nanoid(8);
  const roomId = nanoid(8);

  // 1. Create the facilitator player
  const facilitator: Player = {
    id: playerId,
    name: facilitatorName,
    role: "facilitator",
    vote: null,
    isOnline: true,
    joinedAt: Date.now(),
    emoji,
  };

  // 2. Build the room
  const room: Room = {
    id: roomId,
    name,
    deckType,
    status: "voting",
    players: { [playerId]: facilitator },
    currentStory: null,
    stories: [],
    timerStart: null,
    timerDuration: null,
    createdAt: Date.now(),
    facilitatorId: playerId,
  };

  // 3. Generate encryption key
  const roomKeyB64 = generateKeyMaterial();
  const cryptoKey = await importKeyMaterial(roomKeyB64);
  setAdapterEncryptionKey(cryptoKey);
  storeKeyInSession(roomId, roomKeyB64);

  // 4. Persist and subscribe
  const adapter = getAdapter();
  await adapter.connect();
  await adapter.createRoom(room);
  const unsub = adapter.subscribeToRoom(roomId, (updated) => 
    get()._setRoom(updated)
  );
},

Step 3 is the magic — a 256-bit AES-GCM key is generated right there in your browser. It never leaves. It never touches any server. It gets embedded in the URL hash so you can share it with teammates. More on the crypto in Part 3.

Joining a room

The join flow is the mirror image of create, but with an important security check:

joinRoom: async (roomId, keyB64?) => {
  // Resolve key: URL param → sessionStorage → error
  const resolvedKey = keyB64 ?? loadKeyFromSession(roomId);
  if (resolvedKey) {
    const cryptoKey = await importKeyMaterial(resolvedKey);
    setAdapterEncryptionKey(cryptoKey);
    storeKeyInSession(roomId, resolvedKey);
  }

  const room = await adapter.getRoom(roomId);
  if (!room) throw new Error(`Room "${roomId}" not found`);

  // Pick an unused emoji
  const usedEmojis = new Map(
    Object.values(room.players).map((p) => [p.emoji, p.id])
  );
  let emoji = playerEmoji;
  if (!emoji || usedEmojis.has(emoji)) {
    emoji = PLAYER_EMOJIS.find((e) => !usedEmojis.has(e)) ?? PLAYER_EMOJIS[0];
  }

  // Preserve existing role and joinedAt on rejoin (page refresh)
  const existing = room.players[id];
  const player: Player = {
    id,
    name: playerName,
    role: existing?.role ?? "voter",
    vote: existing?.vote ?? null,
    isOnline: true,
    joinedAt: existing?.joinedAt ?? Date.now(),
    emoji,
  };

  await adapter.joinRoom(roomId, player);
},

That existing check is critical. If a facilitator refreshes their browser, they should still be the facilitator when they come back — not get demoted to a voter. Same with votes: a mid-round refresh shouldn’t erase your already-cast vote.

Optimistic updates — the snappy secret

Every user action updates the UI immediately, then writes to the server:

submitVote: async (value) => {
  const { room, playerId } = get();
  if (!room || !playerId) return;
  
  // UI updates instantly
  set({
    room: {
      ...room,
      players: {
        ...room.players,
        [playerId]: { ...room.players[playerId], vote: value },
      },
    },
  });
  
  // Server write happens in the background
  try {
    await getAdapter().submitVote(room.id, playerId, value);
  } catch (err) {
    if (/command limit/i.test(err.message)) set({ error: err.message });
  }
},

This pattern — “update local, then sync remote” — makes the app feel instant. Your card appears selected the moment you tap it. The SSE subscription then delivers the server’s authoritative state, which confirms or corrects the optimistic update.

Every write action follows this pattern: submitVote, revealVotes, resetVotes, setCurrentStory, setObserverMode, setTimer. Six actions, six optimistic updates.

The facilitator’s toolbox

The facilitator is the one running the show. They get a floating control panel anchored to the bottom-right of the screen:

  • Reveal Votes — only enabled when at least one person has voted. Reveals all cards simultaneously, archives the round with a consensus estimate, and fires off a Recharts bar chart showing the vote distribution.
  • New Round — resets all votes, clears the story title, and optionally starts a countdown timer.
  • Observer Mode — toggles between “I’m voting” and “I’m just watching.” When the facilitator switches to observer, their card disappears from the voting grid.
  • Timer presets30s, 1m, 2m, 3m, 5m, or off. Quick taps, no typing.

How vote reveal and consensus work

When the facilitator hits Reveal, there’s some actual logic behind the scenes:

// Only publish a numeric consensus when ≥80% of voters picked the same card
const tally: Record<string, number> = {};
voters.forEach((p) => {
  tally[p.vote!] = (tally[p.vote!] ?? 0) + 1;
});

const [topVote, topCount] = Object.entries(tally)
  .sort((a, b) => b[1] - a[1])[0];

const hasConsensus = 
  voters.length > 0 && topCount / voters.length >= 0.8;
const consensusEstimate = hasConsensus ? topVote : "?";

80% threshold. If four out of five people vote “5”, the consensus is “5”. If it’s a spread — “3”, “5”, “8”, “13” — the consensus is “?” and the team needs to discuss.

The round gets archived as a Story object with the title, final estimate, and a breakdown of who voted what. This lives in sessionStorage — it’s your session’s memory, and it’s gone when you close the tab.

Facilitator handoff

What happens when the facilitator leaves? Someone has to take over. The rule is simple: the longest-joined voter gets promoted.

if (facilitatorId === playerId) {
  const remaining = Object.values(players)
    .sort((a, b) => a.joinedAt - b.joinedAt);
  const nextFacilitator = remaining.find((p) => p.role !== "observer") 
    ?? remaining[0];
  facilitatorId = nextFacilitator.id;
  players[nextFacilitator.id] = { 
    ...players[nextFacilitator.id], 
    role: "facilitator" 
  };
}

Observers are deprioritized — if someone’s just watching, they probably don’t want to suddenly be running the show. But if no voters remain, an observer gets promoted. Someone has to keep the lights on.

Session history and the results chart

Every revealed round gets stacked in the session sidebar with the story title, consensus estimate, and individual vote breakdown:

Session · abc123
──────────────────
#1  AUTH-142 Login flow    → 5
    Alice 5  Bob 5  Carol 3
#2  AUTH-143 Reset password → 3
    Alice 3  Bob 3  Carol 3  🎉 Unanimous!

When votes are revealed, the ResultsChart component renders a Recharts bar chart showing the vote distribution. Unanimous votes get a 🎉. Consensus votes (80%+) are highlighted. And the average is calculated for numeric decks — useful for T-Shirt sizing where “what’s the average of S, M, L?” doesn’t quite work.

The countdown timer

This was one of the most satisfying components to build — a circular SVG ring that counts down:

const CIRC = 99.9; // circumference of r=15.9

const pct = duration ? (remaining / duration) * 100 : 0;
const dashOffset = CIRC - (pct / 100) * CIRC;
const isUrgent = remaining <= 10 && remaining > 0;
const isDone = remaining === 0;

Under 10 seconds, the ring turns red and pulses. At zero, it auto-reveals the votes (if the facilitator hasn’t already). The onExpire callback fires exactly once thanks to a useRef flag — no double-reveals.

Timer presets appear after a round ends: 30s, 1m, 2m, 3m, 5m, or off. One tap, and the next round starts with a ticking clock. It adds just the right amount of time pressure to keep the team focused.

What’s next

We’ve built the entire voting flow — rooms, players, votes, reveals, rounds, timers, and session history. The store handles optimistic updates, the facilitator has full control, and everything feels snappy.

But here’s the elephant in the room: how does any of this sync between browsers? How do encrypted bytes turn into real-time updates? That’s Part 3 — End-to-end encryption and the real-time engine.

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