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

533 lines
15 KiB
Markdown

# 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:
```javascript
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
```javascript
// 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.
```javascript
// 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
```javascript
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
```javascript
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
```css
/* 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:
```javascript
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