golfgame/docs/v3/V3_03_ROUND_END_REVEAL.md
adlee-was-taken 9fc6b83bba v3.0.0: V3 features, server refactoring, and documentation overhaul
- Extract WebSocket handlers from main.py into handlers.py
- Add V3 feature docs (dealer rotation, dealing animation, round end reveal,
  column pair celebration, final turn urgency, opponent thinking, score tallying,
  card hover/selection, knock early drama, column pair indicator, swap animation
  improvements, draw source distinction, card value tooltips, active rules context,
  discard pile history, realistic card sounds)
- Add V3 refactoring docs (ai.py, main.py/game.py, misc improvements)
- Add installation guide with Docker, systemd, and nginx setup
- Add helper scripts (install.sh, dev-server.sh, docker-build.sh)
- Add animation flow diagrams documentation
- Add test files for handlers, rooms, and V3 features
- Add e2e test specs for V3 features
- Update README with complete project structure and current tech stack
- Update CLAUDE.md with full architecture tree and server layer descriptions
- Update .env.example to reflect PostgreSQL (remove SQLite references)
- Update .gitignore to exclude virtualenv files, .claude/, and .db files
- Remove tracked virtualenv files (bin/, lib64, pyvenv.cfg)
- Remove obsolete game_log.py (SQLite) and games.db

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:03:45 -05:00

15 KiB

V3-03: Round End Dramatic Reveal

Overview

When a round ends, all face-down cards must be revealed for scoring. In physical games, this is a dramatic moment - each player flips their hidden cards one at a time while others watch. Currently, all cards flip simultaneously which lacks drama.

Dependencies: None Dependents: V3_07 (Score Tallying can follow the reveal)


Goals

  1. Reveal cards sequentially, one player at a time
  2. Within each player, reveal cards with slight stagger
  3. Pause briefly between players for dramatic effect
  4. Start with the player who triggered final turn (the "knocker")
  5. End with visible score tally moment
  6. Play flip sounds for each reveal

Current State

When round ends, the server sends a round_over message and clients receive a game_state update where all cards are now face_up: true. The state differ detects the changes but doesn't sequence the animations - they happen together.

From showScoreboard() in app.js:

showScoreboard(scores, isFinal, rankings) {
    // Cards are already revealed by state update
    // Scoreboard appears immediately
}

Design

Reveal Sequence

1. Round ends - "Hole Complete!" message
2. VOLUNTARY FLIP WINDOW (4 seconds):
   - Players can tap their own face-down cards to peek/flip
   - Countdown timer shows remaining time
   - "Tap to reveal your cards" prompt
3. AUTO-REVEAL (after timeout or all flipped):
   - Knocker's cards reveal first (they went out)
   - For each other player (clockwise from knocker):
     a. Player area highlights
     b. Face-down cards flip with stagger (100ms between)
     c. Brief pause to see the reveal (400ms)
4. Score tallying animation (see V3_07)
5. Scoreboard appears

Voluntary Flip Window

Before the dramatic reveal sequence, players get a chance to flip their own hidden cards:

  • Duration: 4 seconds (configurable)
  • Purpose: Let players see their own cards before everyone else does
  • UI: Countdown timer, "Tap your cards to reveal" message
  • Skip: If all players flip their cards, proceed immediately

Visual Flow

Timeline:
0ms     - Round ends, pause
500ms   - Knocker highlight, first card flips
600ms   - Knocker second card flips (if any)
700ms   - Knocker third card flips (if any)
1100ms  - Pause to see knocker's hand
1500ms  - Player 2 highlight
1600ms  - Player 2 cards flip...
...continue for all players...
Final   - Scoreboard appears

Timing Configuration

// In timing-config.js
reveal: {
    voluntaryWindow: 4000,  // Time for players to flip their own cards
    initialPause: 500,      // Pause before auto-reveals start
    cardStagger: 100,       // Between cards in same hand
    playerPause: 400,       // Pause after each player's reveal
    highlightDuration: 200, // Player area highlight fade-in
}

Implementation

Approach: Intercept State Update

Instead of letting renderGame() show all cards instantly, intercept the round_over state and run a reveal sequence.

// In handleMessage, game_state case:

case 'game_state':
    const oldState = this.gameState;
    const newState = data.game_state;

    // Check for round end transition
    const roundJustEnded = oldState?.phase !== 'round_over' &&
                           newState.phase === 'round_over';

    if (roundJustEnded) {
        // Don't update state yet - run reveal animation first
        this.runRoundEndReveal(oldState, newState, () => {
            this.gameState = newState;
            this.renderGame();
        });
        return;
    }

    // Normal state update
    this.gameState = newState;
    this.renderGame();
    break;

Voluntary Flip Window Implementation

async runVoluntaryFlipWindow(oldState, newState) {
    const T = window.TIMING?.reveal || {};
    const windowDuration = T.voluntaryWindow || 4000;

    // Find which of MY cards need flipping
    const myOldCards = oldState?.players?.find(p => p.id === this.playerId)?.cards || [];
    const myNewCards = newState?.players?.find(p => p.id === this.playerId)?.cards || [];
    const myHiddenPositions = [];

    for (let i = 0; i < 6; i++) {
        if (!myOldCards[i]?.face_up && myNewCards[i]?.face_up) {
            myHiddenPositions.push(i);
        }
    }

    // If I have no hidden cards, skip window
    if (myHiddenPositions.length === 0) {
        return;
    }

    // Show prompt and countdown
    this.showRevealPrompt(windowDuration);

    // Enable clicking on my hidden cards
    this.voluntaryFlipMode = true;
    this.voluntaryFlipPositions = new Set(myHiddenPositions);
    this.renderGame(); // Re-render to make cards clickable

    // Wait for timeout or all cards flipped
    return new Promise(resolve => {
        const checkComplete = () => {
            if (this.voluntaryFlipPositions.size === 0) {
                this.hideRevealPrompt();
                this.voluntaryFlipMode = false;
                resolve();
            }
        };

        // Set up interval to check completion
        const checkInterval = setInterval(checkComplete, 100);

        // Timeout after window duration
        setTimeout(() => {
            clearInterval(checkInterval);
            this.hideRevealPrompt();
            this.voluntaryFlipMode = false;
            resolve();
        }, windowDuration);
    });
}

showRevealPrompt(duration) {
    // Create countdown overlay
    const overlay = document.createElement('div');
    overlay.id = 'reveal-prompt';
    overlay.className = 'reveal-prompt';
    overlay.innerHTML = `
        <div class="reveal-prompt-text">Tap your cards to reveal</div>
        <div class="reveal-prompt-countdown">${Math.ceil(duration / 1000)}</div>
    `;
    document.body.appendChild(overlay);

    // Countdown timer
    const countdownEl = overlay.querySelector('.reveal-prompt-countdown');
    let remaining = duration;
    this.countdownInterval = setInterval(() => {
        remaining -= 100;
        countdownEl.textContent = Math.ceil(remaining / 1000);
        if (remaining <= 0) {
            clearInterval(this.countdownInterval);
        }
    }, 100);
}

hideRevealPrompt() {
    clearInterval(this.countdownInterval);
    const overlay = document.getElementById('reveal-prompt');
    if (overlay) {
        overlay.classList.add('fading');
        setTimeout(() => overlay.remove(), 300);
    }
}

// Modify handleCardClick to handle voluntary flips
handleCardClick(position) {
    // ... existing code ...

    // Voluntary flip during reveal window
    if (this.voluntaryFlipMode && this.voluntaryFlipPositions?.has(position)) {
        const myData = this.getMyPlayerData();
        const card = myData?.cards[position];
        if (card) {
            this.playSound('flip');
            this.fireLocalFlipAnimation(position, card);
            this.voluntaryFlipPositions.delete(position);
            // Update local state to show card flipped
            this.locallyFlippedCards.add(position);
            this.renderGame();
        }
        return;
    }

    // ... rest of existing code ...
}

Reveal Animation Method

async runRoundEndReveal(oldState, newState, onComplete) {
    const T = window.TIMING?.reveal || {};

    // STEP 1: Voluntary flip window - let players peek at their own cards
    this.setStatus('Reveal your hidden cards!', 'reveal-window');
    await this.runVoluntaryFlipWindow(oldState, newState);

    // STEP 2: Auto-reveal remaining hidden cards
    // Recalculate what needs flipping (some may have been voluntarily revealed)
    const revealsByPlayer = this.getCardsToReveal(oldState, newState);

    // Get reveal order: knocker first, then clockwise
    const knockerId = newState.finisher_id;
    const revealOrder = this.getRevealOrder(newState.players, knockerId);

    // Initial dramatic pause before auto-reveals
    this.setStatus('Revealing cards...', 'reveal');
    await this.delay(T.initialPause || 500);

    // Reveal each player's cards
    for (const player of revealOrder) {
        const cardsToFlip = revealsByPlayer.get(player.id) || [];
        if (cardsToFlip.length === 0) continue;

        // Highlight player area
        this.highlightPlayerArea(player.id, true);
        await this.delay(T.highlightDuration || 200);

        // Flip each card with stagger
        for (const { position, card } of cardsToFlip) {
            this.animateRevealFlip(player.id, position, card);
            await this.delay(T.cardStagger || 100);
        }

        // Wait for last flip to complete + pause
        await this.delay(400 + (T.playerPause || 400));

        // Remove highlight
        this.highlightPlayerArea(player.id, false);
    }

    // All revealed
    onComplete();
}

getCardsToReveal(oldState, newState) {
    const reveals = new Map();

    for (const newPlayer of newState.players) {
        const oldPlayer = oldState.players.find(p => p.id === newPlayer.id);
        if (!oldPlayer) continue;

        const cardsToFlip = [];
        for (let i = 0; i < 6; i++) {
            const wasHidden = !oldPlayer.cards[i]?.face_up;
            const nowVisible = newPlayer.cards[i]?.face_up;

            if (wasHidden && nowVisible) {
                cardsToFlip.push({
                    position: i,
                    card: newPlayer.cards[i]
                });
            }
        }

        if (cardsToFlip.length > 0) {
            reveals.set(newPlayer.id, cardsToFlip);
        }
    }

    return reveals;
}

getRevealOrder(players, knockerId) {
    // Knocker first
    const knocker = players.find(p => p.id === knockerId);
    const others = players.filter(p => p.id !== knockerId);

    // Others in clockwise order (already sorted by player_order)
    if (knocker) {
        return [knocker, ...others];
    }
    return others;
}

highlightPlayerArea(playerId, highlight) {
    if (playerId === this.playerId) {
        this.playerArea.classList.toggle('revealing', highlight);
    } else {
        const area = this.opponentsRow.querySelector(
            `.opponent-area[data-player-id="${playerId}"]`
        );
        if (area) {
            area.classList.toggle('revealing', highlight);
        }
    }
}

animateRevealFlip(playerId, position, cardData) {
    // Reuse existing flip animation
    if (playerId === this.playerId) {
        this.fireLocalFlipAnimation(position, cardData);
    } else {
        this.fireFlipAnimation(playerId, position, cardData);
    }
}

CSS for Reveal Prompt and Highlights

/* Voluntary reveal prompt */
.reveal-prompt {
    position: fixed;
    top: 20%;
    left: 50%;
    transform: translateX(-50%);
    background: linear-gradient(135deg, #f4a460 0%, #d4845a 100%);
    color: white;
    padding: 15px 30px;
    border-radius: 12px;
    text-align: center;
    z-index: 200;
    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
    animation: prompt-entrance 0.3s ease-out;
}

.reveal-prompt.fading {
    animation: prompt-fade 0.3s ease-out forwards;
}

@keyframes prompt-entrance {
    0% { transform: translateX(-50%) translateY(-20px); opacity: 0; }
    100% { transform: translateX(-50%) translateY(0); opacity: 1; }
}

@keyframes prompt-fade {
    0% { opacity: 1; }
    100% { opacity: 0; }
}

.reveal-prompt-text {
    font-size: 1.1em;
    margin-bottom: 8px;
}

.reveal-prompt-countdown {
    font-size: 2em;
    font-weight: bold;
}

/* Cards clickable during voluntary reveal */
.player-area.voluntary-flip .card.can-flip {
    cursor: pointer;
    animation: flip-hint 0.8s ease-in-out infinite;
}

@keyframes flip-hint {
    0%, 100% { transform: scale(1); }
    50% { transform: scale(1.03); }
}

/* Player area highlight during reveal */
.player-area.revealing,
.opponent-area.revealing {
    animation: reveal-highlight 0.3s ease-out;
}

@keyframes reveal-highlight {
    0% {
        box-shadow: 0 0 0 0 rgba(244, 164, 96, 0);
    }
    50% {
        box-shadow: 0 0 20px 10px rgba(244, 164, 96, 0.4);
    }
    100% {
        box-shadow: 0 0 10px 5px rgba(244, 164, 96, 0.2);
    }
}

/* Keep highlight while revealing */
.player-area.revealing,
.opponent-area.revealing {
    box-shadow: 0 0 10px 5px rgba(244, 164, 96, 0.2);
}

Special Cases

All Cards Already Face-Up

If a player has no face-down cards (they knocked or flipped everything):

  • Skip their reveal in the sequence
  • Don't highlight their area

Player Disconnected

If a player left before round end:

  • Their cards still need to reveal for scoring
  • Handle missing player areas gracefully

Single Player (Debug/Test)

If only one player remains:

  • Still do the reveal animation for their cards
  • Feels consistent

Quick Mode (Future)

Consider a setting to skip reveal animation:

if (this.settings.quickMode) {
    this.gameState = newState;
    this.renderGame();
    return;
}

Timing Tuning

The reveal should feel dramatic but not tedious:

Scenario Cards to Reveal Approximate Duration
2 players, 2 hidden each 4 cards ~2 seconds
4 players, 3 hidden each 12 cards ~4 seconds
6 players, 4 hidden each 24 cards ~7 seconds

If too slow, reduce:

  • cardStagger: 100ms → 60ms
  • playerPause: 400ms → 250ms

Test Scenarios

  1. Normal round end - Knocker reveals first, others follow
  2. Knocker has no hidden cards - Skip knocker, start with next player
  3. All players have hidden cards - Full reveal sequence
  4. Some players have no hidden cards - Skip them gracefully
  5. Player disconnected - Handle gracefully
  6. 2-player game - Both players reveal in order
  7. Quick succession - Multiple round ends don't overlap

Acceptance Criteria

  • Voluntary flip window: 4-second window for players to flip their own cards
  • Countdown timer shows remaining time
  • Players can tap their face-down cards to reveal early
  • Auto-reveal starts after timeout (or if all cards flipped)
  • Cards reveal sequentially during auto-reveal, not all at once
  • Knocker (finisher) reveals first
  • Other players reveal clockwise after knocker
  • Cards within a hand have slight stagger
  • Pause between players for drama
  • Player area highlights during their reveal
  • Flip sound plays for each card
  • Reveal completes before scoreboard appears
  • Handles players with no hidden cards
  • Animation can be interrupted if needed

Implementation Order

  1. Add reveal timing to timing-config.js
  2. Add data-player-id to opponent areas (if not done in V3_02)
  3. Implement getCardsToReveal() method
  4. Implement getRevealOrder() method
  5. Implement highlightPlayerArea() method
  6. Implement runRoundEndReveal() method
  7. Intercept round_over state transition
  8. Add reveal highlight CSS
  9. Test with various player counts and card states
  10. Tune timing for best dramatic effect

Notes for Agent

  • Use window.cardAnimations.animateFlip() or animateOpponentFlip() for reveals
  • The existing CardAnimations class has all flip animation methods ready
  • Don't forget to set finisher_id in game state (server may already do this)
  • The reveal order should match the physical clockwise order
  • Consider: Add a "drum roll" sound before reveals? (Nice to have)
  • The scoreboard should NOT appear until all reveals complete
  • State update is deferred until animation completes - ensure no race conditions
  • All animations use anime.js timelines internally - no CSS keyframes needed