diff --git a/src/components/frontend/minigames/poker/PokerPopups.tsx b/src/components/frontend/minigames/poker/PokerPopups.tsx
new file mode 100644
index 0000000..72ad490
--- /dev/null
+++ b/src/components/frontend/minigames/poker/PokerPopups.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import type { ActivePokerPopup } from "./usePokerPopups";
+
+export function PokerPopupBubble({ popup }: { readonly popup: ActivePokerPopup }) {
+ return (
+
+ {popup.text}
+
+ );
+}
diff --git a/src/components/frontend/minigames/poker/pokerPopups.ts b/src/components/frontend/minigames/poker/pokerPopups.ts
new file mode 100644
index 0000000..6376801
--- /dev/null
+++ b/src/components/frontend/minigames/poker/pokerPopups.ts
@@ -0,0 +1,175 @@
+import type { PokerActionKind, PublicPlayerView } from "@/lib/minigames/poker";
+import { BLIND_STAGGER_MS, type PokerSoundSnapshot } from "./pokerSoundEvents";
+
+export type PokerPopupTone = "action" | "wager" | "win" | "fold" | "leave";
+
+export interface PokerPopupEvent {
+ readonly key: string;
+ readonly seatId: number;
+ readonly text: string;
+ readonly tone: PokerPopupTone;
+ /** ms to wait before this popup appears (staggered blind sequence), default 0. */
+ readonly delay?: number;
+}
+
+function playerBySeat(
+ players: readonly PublicPlayerView[],
+ seatId: number,
+): PublicPlayerView | null {
+ return players.find((player) => player.seatId === seatId) ?? null;
+}
+
+function wagerAction(
+ previousBet: number,
+ nextBet: number,
+): Extract {
+ if (previousBet === 0 && nextBet > 0) return "bet";
+ if (nextBet > previousBet) return "raise";
+ return "call";
+}
+
+function wagerLabel(
+ action: Extract,
+ amount: number,
+ allIn: boolean,
+): string {
+ const formatted = `+${amount.toLocaleString()}`;
+ if (allIn) return `All-in ${formatted}`;
+ if (action === "bet") return `Bet ${formatted}`;
+ if (action === "raise") return `Raise ${formatted}`;
+ return `Call ${formatted}`;
+}
+
+export function popupEventsForTransition(
+ previous: PokerSoundSnapshot | null,
+ next: PokerSoundSnapshot,
+): readonly PokerPopupEvent[] {
+ if (!previous || previous.version === next.version) return [];
+
+ const previousView = previous.you;
+ const nextView = next.you;
+ if (!nextView) return [];
+ const events: PokerPopupEvent[] = [];
+ const transitionKey = `${next.version}:${nextView.handNumber}:${nextView.phase}:${nextView.street}`;
+
+ const sameHandAndStreet =
+ previousView !== null &&
+ previousView.handNumber === nextView.handNumber &&
+ previousView.street === nextView.street;
+
+ // New hand: present the blind posts as a staggered mini-sequence (small
+ // blind, then big blind) so the hand opens with a readable flow before the
+ // first real turn. The previous view may be the waiting room (no seat view
+ // yet) when this is the game's first dealt hand.
+ if (
+ nextView.phase === "betting" &&
+ (previousView === null || nextView.handNumber > previousView.handNumber)
+ ) {
+ events.push({
+ key: `${transitionKey}:popup:blind-sb`,
+ seatId: nextView.smallBlindSeat,
+ text: `Small blind +${nextView.config.smallBlind.toLocaleString()}`,
+ tone: "wager",
+ delay: 0,
+ });
+ events.push({
+ key: `${transitionKey}:popup:blind-bb`,
+ seatId: nextView.bigBlindSeat,
+ text: `Big blind +${nextView.config.bigBlind.toLocaleString()}`,
+ tone: "wager",
+ delay: BLIND_STAGGER_MS,
+ });
+ return events;
+ }
+
+ if (!previousView) return events;
+
+ if (sameHandAndStreet && previousView.phase === "betting" && nextView.phase === "betting") {
+ for (const nextPlayer of nextView.players) {
+ const previousPlayer = playerBySeat(previousView.players, nextPlayer.seatId);
+ if (!previousPlayer) continue;
+
+ if (!previousPlayer.folded && nextPlayer.folded) {
+ events.push({
+ key: `${transitionKey}:popup:fold:${nextPlayer.seatId}`,
+ seatId: nextPlayer.seatId,
+ text: "Fold",
+ tone: "fold",
+ });
+ continue;
+ }
+
+ if (nextPlayer.folded) continue;
+
+ // Same committed-growth rule as the sound mapper: blinds post on a new
+ // hand (handNumber differs), so any mid-street increase is the player's
+ // own wager, even when they already acted earlier in the street.
+ const committedDelta = nextPlayer.totalCommitted - previousPlayer.totalCommitted;
+ if (committedDelta > 0) {
+ events.push({
+ key: `${transitionKey}:popup:wager:${nextPlayer.seatId}:${committedDelta}`,
+ seatId: nextPlayer.seatId,
+ text: wagerLabel(
+ wagerAction(previousView.currentBet, nextView.currentBet),
+ committedDelta,
+ nextPlayer.allIn,
+ ),
+ tone: "wager",
+ });
+ } else if (!previousPlayer.hasActed && nextPlayer.hasActed) {
+ events.push({
+ key: `${transitionKey}:popup:check:${nextPlayer.seatId}`,
+ seatId: nextPlayer.seatId,
+ text: "Check",
+ tone: "action",
+ });
+ }
+ }
+ }
+
+ // Leaving is any of: a vanished seat (removed between hands), a mid-game
+ // leaver whose view flips the left flag immediately, or the later eliminated
+ // forfeit. Busted stacks are never eliminated or left, so they never read
+ // as a leave; the left||eliminated pair fires exactly once per player.
+ for (const previousPlayer of previousView.players) {
+ const nextPlayer = playerBySeat(nextView.players, previousPlayer.seatId);
+ if (!nextPlayer) {
+ events.push({
+ key: `${transitionKey}:popup:leave:${previousPlayer.seatId}`,
+ seatId: previousPlayer.seatId,
+ text: `${previousPlayer.name} left the table`,
+ tone: "leave",
+ });
+ continue;
+ }
+ const leftBefore = previousPlayer.left || previousPlayer.eliminated;
+ const leftNow = nextPlayer.left || nextPlayer.eliminated;
+ if (leftNow && !leftBefore) {
+ events.push({
+ key: `${transitionKey}:popup:leave:${previousPlayer.seatId}`,
+ seatId: previousPlayer.seatId,
+ text: `${previousPlayer.name} left the table`,
+ tone: "leave",
+ });
+ }
+ }
+
+ // A hand ends in exactly one of showdown or hand-complete, and both phases
+ // carry showdown results, so every winner gets one "+N" popup, never twice.
+ const handEnded =
+ (nextView.phase === "hand-complete" && previousView.phase !== "hand-complete") ||
+ (nextView.phase === "showdown" && previousView.phase !== "showdown");
+ if (handEnded && nextView.showdown) {
+ for (const result of nextView.showdown.results) {
+ if (result.chipsWon <= 0) continue;
+ events.push({
+ key: `${transitionKey}:popup:win:${result.seatId}:${result.chipsWon}`,
+ seatId: result.seatId,
+ text: `+${result.chipsWon.toLocaleString()}`,
+ tone: "win",
+ });
+ }
+ }
+
+ return events;
+}
diff --git a/src/components/frontend/minigames/poker/usePokerPopups.ts b/src/components/frontend/minigames/poker/usePokerPopups.ts
new file mode 100644
index 0000000..5a0c734
--- /dev/null
+++ b/src/components/frontend/minigames/poker/usePokerPopups.ts
@@ -0,0 +1,86 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
+import { popupEventsForTransition, type PokerPopupEvent } from "./pokerPopups";
+import type { PokerSoundSnapshot } from "./pokerSoundEvents";
+
+export type ActivePokerPopup = PokerPopupEvent;
+
+const POPUP_LIFETIME_MS = 1500;
+const LEAVE_POPUP_LIFETIME_MS = 3000;
+const MAX_ACTIVE_POPUPS = 12;
+const MAX_SEEN_KEYS = 256;
+
+const POPUP_LIFETIMES_MS: Record = {
+ action: POPUP_LIFETIME_MS,
+ wager: POPUP_LIFETIME_MS,
+ win: POPUP_LIFETIME_MS,
+ fold: POPUP_LIFETIME_MS,
+ leave: LEAVE_POPUP_LIFETIME_MS,
+};
+
+export function usePokerPopups(snapshot: PokerSnapshot | null): readonly ActivePokerPopup[] {
+ const [popups, setPopups] = useState([]);
+ const previousSnapshotRef = useRef(null);
+ const seenKeysRef = useRef>(new Set());
+ const timersRef = useRef[]>([]);
+
+ useEffect(() => {
+ if (!snapshot) {
+ previousSnapshotRef.current = null;
+ return;
+ }
+ const previousSnapshot = previousSnapshotRef.current;
+ if (previousSnapshot) {
+ const fresh = popupEventsForTransition(previousSnapshot, snapshot).filter((event) => {
+ if (seenKeysRef.current.has(event.key)) return false;
+ seenKeysRef.current.add(event.key);
+ return true;
+ });
+ while (seenKeysRef.current.size > MAX_SEEN_KEYS) {
+ const oldest = seenKeysRef.current.values().next().value;
+ if (typeof oldest !== "string") break;
+ seenKeysRef.current.delete(oldest);
+ }
+ if (fresh.length > 0) {
+ const immediate: ActivePokerPopup[] = [];
+ for (const event of fresh) {
+ const delay = event.delay ?? 0;
+ if (delay > 0) {
+ timersRef.current.push(
+ setTimeout(() => {
+ setPopups((current) => [...current, event].slice(-MAX_ACTIVE_POPUPS));
+ timersRef.current.push(
+ setTimeout(() => {
+ setPopups((current) => current.filter((popup) => popup.key !== event.key));
+ }, POPUP_LIFETIMES_MS[event.tone]),
+ );
+ }, delay),
+ );
+ } else {
+ immediate.push(event);
+ timersRef.current.push(
+ setTimeout(() => {
+ setPopups((current) => current.filter((popup) => popup.key !== event.key));
+ }, POPUP_LIFETIMES_MS[event.tone]),
+ );
+ }
+ }
+ if (immediate.length > 0) {
+ setPopups((current) => [...current, ...immediate].slice(-MAX_ACTIVE_POPUPS));
+ }
+ }
+ }
+ previousSnapshotRef.current = snapshot;
+ }, [snapshot]);
+
+ useEffect(() => {
+ return () => {
+ for (const timer of timersRef.current) clearTimeout(timer);
+ timersRef.current = [];
+ };
+ }, []);
+
+ return popups;
+}
diff --git a/tests/int/poker-popups.int.spec.ts b/tests/int/poker-popups.int.spec.ts
new file mode 100644
index 0000000..3280936
--- /dev/null
+++ b/tests/int/poker-popups.int.spec.ts
@@ -0,0 +1,330 @@
+import { describe, expect, it } from "vitest";
+import type { PublicPlayerView, ShowdownOutcome } from "@/lib/minigames/poker";
+import {
+ popupEventsForTransition,
+ type PokerPopupEvent,
+} from "@/components/frontend/minigames/poker/pokerPopups";
+import {
+ isTopSeatPosition,
+ seatPosition,
+} from "@/components/frontend/minigames/poker/seatPosition";
+import type { PokerSoundSnapshot } from "@/components/frontend/minigames/poker/pokerSoundEvents";
+
+function player(seatId: number, overrides: Partial = {}): PublicPlayerView {
+ return {
+ seatId,
+ name: `Player ${seatId + 1}`,
+ stack: 1000,
+ committed: 0,
+ totalCommitted: 0,
+ folded: false,
+ allIn: false,
+ hasActed: false,
+ eliminated: false,
+ left: false,
+ holeCards: null,
+ cardCount: 2,
+ ...overrides,
+ };
+}
+
+function view(overrides: Partial = {}): PokerSoundSnapshot["you"] {
+ return {
+ communityCards: [],
+ config: { startingStack: 1000, smallBlind: 10, bigBlind: 20, turnTimeoutMs: 30000 },
+ currentBet: 0,
+ smallBlindSeat: 0,
+ bigBlindSeat: 1,
+ finalPlacements: null,
+ handNumber: 1,
+ phase: "betting",
+ players: [player(0), player(1)],
+ showdown: null,
+ street: "preflop",
+ toActSeat: 0,
+ viewerSeat: 0,
+ yourTurn: false,
+ ...overrides,
+ };
+}
+
+function snapshot(
+ version: number,
+ nextView: PokerSoundSnapshot["you"] = view(),
+): PokerSoundSnapshot {
+ return { version, you: nextView };
+}
+
+function showdown(results: Array<{ seatId: number; chipsWon: number }>): ShowdownOutcome {
+ return {
+ pots: [],
+ results: results.map((item) => ({
+ seatId: item.seatId,
+ handRank: null,
+ bestHand: null,
+ chipsWon: item.chipsWon,
+ netChange: item.chipsWon,
+ placement: 1,
+ })),
+ };
+}
+
+function bySeat(events: readonly PokerPopupEvent[], seatId: number): PokerPopupEvent | undefined {
+ return events.find((event) => event.seatId === seatId);
+}
+
+describe("poker popup transition events", () => {
+ it("labels folds, checks, and wagers with amounts over the acting seat", () => {
+ const events = popupEventsForTransition(
+ snapshot(1, view({ players: [player(0), player(1)] })),
+ snapshot(
+ 2,
+ view({
+ currentBet: 20,
+ players: [
+ player(0, { committed: 20, totalCommitted: 20, hasActed: true }),
+ player(1, { folded: true, hasActed: true }),
+ ],
+ }),
+ ),
+ );
+ const checks = popupEventsForTransition(
+ snapshot(1, view({ players: [player(0), player(1)] })),
+ snapshot(3, view({ players: [player(0), player(1, { hasActed: true })] })),
+ );
+
+ expect(bySeat(events, 0)).toEqual(
+ expect.objectContaining({ text: "Bet +20", tone: "wager", seatId: 0 }),
+ );
+ expect(bySeat(events, 1)).toEqual(
+ expect.objectContaining({ text: "Fold", tone: "fold", seatId: 1 }),
+ );
+ expect(checks).toEqual([expect.objectContaining({ text: "Check", tone: "action", seatId: 1 })]);
+ });
+
+ it("distinguishes calls from raises and calls out all-ins", () => {
+ const raise = popupEventsForTransition(
+ snapshot(
+ 4,
+ view({
+ currentBet: 20,
+ players: [player(0, { committed: 20, totalCommitted: 20, hasActed: true }), player(1)],
+ }),
+ ),
+ snapshot(
+ 5,
+ view({
+ currentBet: 60,
+ players: [
+ player(0, { committed: 20, totalCommitted: 20, hasActed: true }),
+ player(1, { committed: 60, totalCommitted: 60, hasActed: true }),
+ ],
+ }),
+ ),
+ );
+ const call = popupEventsForTransition(
+ snapshot(
+ 6,
+ view({
+ currentBet: 60,
+ players: [player(0, { committed: 20, totalCommitted: 20, hasActed: true }), player(1)],
+ }),
+ ),
+ snapshot(
+ 7,
+ view({
+ currentBet: 60,
+ players: [
+ player(0, { committed: 20, totalCommitted: 20, hasActed: true }),
+ player(1, { committed: 60, totalCommitted: 60, hasActed: true }),
+ ],
+ }),
+ ),
+ );
+ const allIn = popupEventsForTransition(
+ snapshot(8, view({ players: [player(0), player(1)] })),
+ snapshot(
+ 9,
+ view({
+ currentBet: 1000,
+ players: [
+ player(0),
+ player(1, { committed: 1000, totalCommitted: 1000, hasActed: true, allIn: true }),
+ ],
+ }),
+ ),
+ );
+
+ expect(bySeat(raise, 1)?.text).toBe("Raise +60");
+ expect(bySeat(call, 1)?.text).toBe("Call +60");
+ expect(bySeat(allIn, 1)?.text).toBe("All-in +1,000");
+ });
+
+ it("pops winnings over every winning seat on showdown and hand-complete", () => {
+ const multiWinner = showdown([
+ { seatId: 0, chipsWon: 120 },
+ { seatId: 1, chipsWon: 0 },
+ { seatId: 2, chipsWon: 340 },
+ ]);
+ const showdownEvents = popupEventsForTransition(
+ snapshot(1, view({ players: [player(0), player(1), player(2)] })),
+ snapshot(
+ 2,
+ view({
+ phase: "showdown",
+ players: [player(0), player(1), player(2)],
+ showdown: multiWinner,
+ }),
+ ),
+ );
+ const foldOutEvents = popupEventsForTransition(
+ snapshot(3, view({ players: [player(0), player(1)] })),
+ snapshot(
+ 4,
+ view({
+ phase: "hand-complete",
+ players: [player(0), player(1, { folded: true })],
+ showdown: showdown([{ seatId: 0, chipsWon: 45 }]),
+ }),
+ ),
+ );
+
+ expect(showdownEvents).toEqual([
+ expect.objectContaining({ text: "+120", tone: "win", seatId: 0 }),
+ expect.objectContaining({ text: "+340", tone: "win", seatId: 2 }),
+ ]);
+ expect(foldOutEvents).toEqual([
+ expect.objectContaining({ text: "+45", tone: "win", seatId: 0 }),
+ ]);
+ });
+
+ it("stays silent on identical versions and plays the blind sequence at a new hand", () => {
+ const current = snapshot(7, view({ yourTurn: true }));
+ expect(popupEventsForTransition(current, current)).toEqual([]);
+
+ const blinds = popupEventsForTransition(
+ snapshot(5),
+ snapshot(
+ 6,
+ view({
+ handNumber: 2,
+ currentBet: 20,
+ players: [
+ player(0, { committed: 10, totalCommitted: 10 }),
+ player(1, { committed: 20, totalCommitted: 20 }),
+ ],
+ }),
+ ),
+ );
+
+ expect(blinds).toEqual([
+ expect.objectContaining({
+ text: "Small blind +10",
+ tone: "wager",
+ seatId: 0,
+ delay: 0,
+ }),
+ expect.objectContaining({
+ text: "Big blind +20",
+ tone: "wager",
+ seatId: 1,
+ delay: 700,
+ }),
+ ]);
+ });
+
+ it("plays the blind sequence for the game's first hand out of the waiting room", () => {
+ const waitingRoom: PokerSoundSnapshot = { version: 1, you: null };
+ const events = popupEventsForTransition(waitingRoom, snapshot(2, view({ handNumber: 1 })));
+
+ expect(events).toEqual([
+ expect.objectContaining({ text: "Small blind +10", seatId: 0, delay: 0 }),
+ expect.objectContaining({ text: "Big blind +20", seatId: 1, delay: 700 }),
+ ]);
+ });
+});
+
+describe("player leave detection", () => {
+ it("announces players who vanish from the table between transitions", () => {
+ const events = popupEventsForTransition(
+ snapshot(1, view({ players: [player(0), player(1), player(2)] })),
+ snapshot(2, view({ players: [player(0), player(1)] })),
+ );
+
+ expect(events).toEqual([
+ expect.objectContaining({
+ text: "Player 3 left the table",
+ tone: "leave",
+ seatId: 2,
+ }),
+ ]);
+ });
+
+ it("announces a mid-game leaver the moment the left flag flips", () => {
+ const events = popupEventsForTransition(
+ snapshot(3, view({ players: [player(0), player(1)] })),
+ snapshot(4, view({ players: [player(0), player(1, { left: true })] })),
+ );
+
+ expect(events).toEqual([
+ expect.objectContaining({
+ text: "Player 2 left the table",
+ tone: "leave",
+ seatId: 1,
+ }),
+ ]);
+ });
+
+ it("announces the eliminated forfeit only when the player was not already marked left", () => {
+ const forfeitOnly = popupEventsForTransition(
+ snapshot(5, view({ players: [player(0), player(1)] })),
+ snapshot(6, view({ players: [player(0), player(1, { eliminated: true })] })),
+ );
+ const alreadyLeft = popupEventsForTransition(
+ snapshot(7, view({ players: [player(0), player(1, { left: true })] })),
+ snapshot(8, view({ players: [player(0), player(1, { left: true, eliminated: true })] })),
+ );
+
+ expect(forfeitOnly).toEqual([
+ expect.objectContaining({ text: "Player 2 left the table", tone: "leave", seatId: 1 }),
+ ]);
+ expect(alreadyLeft).toEqual([]);
+ });
+
+ it("never mistakes a busted stack for a leaver", () => {
+ const events = popupEventsForTransition(
+ snapshot(5, view({ players: [player(0), player(1)] })),
+ snapshot(6, view({ players: [player(0), player(1, { stack: 0 })] })),
+ );
+
+ expect(events).toEqual([]);
+ });
+});
+
+describe("isTopSeatPosition", () => {
+ it("marks top-row arc classes as top seats", () => {
+ expect(isTopSeatPosition("poker-seat-arc-center")).toBe(true);
+ expect(isTopSeatPosition("poker-seat-arc-center-left")).toBe(true);
+ expect(isTopSeatPosition("poker-seat-arc-center-right")).toBe(true);
+ });
+
+ it("marks side and corner arc classes as non-top seats", () => {
+ expect(isTopSeatPosition("poker-seat-arc-left")).toBe(false);
+ expect(isTopSeatPosition("poker-seat-arc-right")).toBe(false);
+ expect(isTopSeatPosition("poker-seat-arc-far-left")).toBe(false);
+ expect(isTopSeatPosition("poker-seat-arc-far-right")).toBe(false);
+ });
+
+ it("matches real seat layouts: one and three opponents put a seat on top, two do not", () => {
+ expect(isTopSeatPosition(seatPosition(0, 1))).toBe(true);
+ expect(isTopSeatPosition(seatPosition(0, 2))).toBe(false);
+ expect(isTopSeatPosition(seatPosition(1, 2))).toBe(false);
+ expect(isTopSeatPosition(seatPosition(1, 3))).toBe(true);
+ expect(isTopSeatPosition(seatPosition(0, 3))).toBe(false);
+ expect(isTopSeatPosition(seatPosition(2, 3))).toBe(false);
+ expect(isTopSeatPosition(seatPosition(1, 4))).toBe(true);
+ expect(isTopSeatPosition(seatPosition(2, 4))).toBe(true);
+ expect(isTopSeatPosition(seatPosition(0, 4))).toBe(false);
+ expect(isTopSeatPosition(seatPosition(3, 4))).toBe(false);
+ });
+});