- 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>
26 KiB
Animation Flow Reference
Complete reference for how card animations are triggered, sequenced, and cleaned up.
All animations use anime.js via the CardAnimations class (client/card-animations.js).
Timing is configured in client/timing-config.js.
Table of Contents
- Architecture Overview
- Animation Flags
- Flow 1: Local Player Draws from Deck
- Flow 2: Local Player Draws from Discard
- Flow 3: Local Player Swaps
- Flow 4: Local Player Discards
- Flow 5: Opponent Draws from Deck then Swaps
- Flow 6: Opponent Draws from Deck then Discards
- Flow 7: Opponent Draws from Discard then Swaps
- Flow 8: Initial Card Flip
- Flow 9: Deal Animation
- Flow 10: Round End Reveal
- Flag Lifecycle Summary
- Safety Clears
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ app.js │
│ │
│ User Click / WebSocket ──► triggerAnimationsForStateChange │
│ │ │ │
│ ▼ ▼ │
│ Set flags ──────────────► CardAnimations method │
│ │ │ │
│ ▼ ▼ │
│ renderGame() skips anime.js timeline runs │
│ flagged elements │ │
│ │ ▼ │
│ │ Callback fires │
│ │ │ │
│ ▼ ▼ │
│ Flags cleared ◄──────── renderGame() called │
│ │ │
│ ▼ │
│ Normal rendering resumes │
└─────────────────────────────────────────────────────────────┘
Key principle: Flags block renderGame() from updating the DOM while animations are in flight. The animation callback clears flags and triggers a fresh render.
Animation Flags
Flags in app.js that prevent renderGame() from updating the discard pile or held card during animations:
| Flag | Type | Blocks | Purpose |
|---|---|---|---|
isDrawAnimating |
bool | Discard pile, held card | Draw animation in progress |
localDiscardAnimating |
bool | Discard pile | Local player discarding drawn card |
opponentDiscardAnimating |
bool | Discard pile | Opponent discarding without swap |
opponentSwapAnimation |
object/null | Discard pile, turn indicator | Opponent swap { playerId, position } |
dealAnimationInProgress |
bool | Flip prompts | Deal animation running |
swapAnimationInProgress |
bool | Game state application | Local swap — defers incoming state |
renderGame() skip logic:
if (localDiscardAnimating OR opponentSwapAnimation OR
opponentDiscardAnimating OR isDrawAnimating):
→ skip discard pile update
Flow 1: Local Player Draws from Deck
Trigger: User clicks deck
User clicks deck
│
▼
drawFromDeck()
├─ Validate: isMyTurn(), no drawnCard
└─ Send: { type: 'draw', source: 'deck' }
│
▼
Server responds: 'card_drawn'
├─ Store drawnCard, drawnFromDiscard=false
├─ Clear stale flags (opponentSwap, opponentDiscard)
├─ SET isDrawAnimating = true
└─ hideDrawnCard()
│
▼
cardAnimations.animateDrawDeck(card, callback)
│
├─ Pulse deck (gold ring)
├─ Wait pulseDelay (200ms)
│
▼
_animateDrawDeckCard() timeline:
┌─────────────────────────────────────────┐
│ 1. Lift off deck (120ms, lift ease) │
│ translateY: -15, rotate wobble │
│ │
│ 2. Move to hold pos (250ms, move ease) │
│ left/top to holdingRect │
│ │
│ 3. Brief pause (80ms) │
│ │
│ 4. Flip to reveal (320ms, flip ease) │
│ rotateY: 180→0, play flip sound │
│ │
│ 5. View pause (120ms) │
└─────────────────────────────────────────┘
│
▼
Callback:
├─ CLEAR isDrawAnimating = false
├─ displayHeldCard(card) with popIn
├─ renderGame()
└─ Show toast: "Swap with a card or discard"
Total animation time: ~200 + 120 + 250 + 80 + 320 + 120 = ~1090ms
Flow 2: Local Player Draws from Discard
Trigger: User clicks discard pile
User clicks discard
│
▼
drawFromDiscard()
├─ Validate: isMyTurn(), no drawnCard, discard_top exists
└─ Send: { type: 'draw', source: 'discard' }
│
▼
Server responds: 'card_drawn'
├─ Store drawnCard, drawnFromDiscard=true
├─ Clear stale flags
├─ SET isDrawAnimating = true
└─ hideDrawnCard()
│
▼
cardAnimations.animateDrawDiscard(card, callback)
│
├─ Pulse discard (gold ring)
├─ Wait pulseDelay (200ms)
│
▼
_animateDrawDiscardCard() timeline:
┌─────────────────────────────────────────┐
│ Hide actual discard pile (opacity: 0) │
│ │
│ 1. Quick lift (80ms, lift ease) │
│ translateY: -12, scale: 1.05 │
│ │
│ 2. Move to hold pos (200ms, move ease) │
│ left/top to holdingRect │
│ │
│ 3. Brief settle (60ms) │
└─────────────────────────────────────────┘
│
▼
Callback:
├─ Restore discard pile opacity
├─ CLEAR isDrawAnimating = false
├─ displayHeldCard(card) with popIn
├─ renderGame()
└─ Show toast: "Swap with a card or discard"
Total animation time: ~200 + 80 + 200 + 60 = ~540ms
Flow 3: Local Player Swaps
Trigger: User clicks hand card while holding a drawn card
User clicks hand card (position N)
│
▼
handleCardClick(position)
└─ drawnCard exists → animateSwap(position)
│
▼
animateSwap(position)
├─ SET swapAnimationInProgress = true
├─ Hide originals (swap-out class, visibility:hidden)
├─ Store drawnCard, clear this.drawnCard
├─ SET skipNextDiscardFlip = true
└─ Send: { type: 'swap', position }
│
├──────────────────────────────────┐
│ Face-up card? │ Face-down card?
▼ ▼
Card data known Store pendingSwapData
immediately Wait for server response
│ │
│ ▼
│ Server: 'game_state'
│ ├─ Detect swapAnimationInProgress
│ ├─ Store pendingGameState
│ └─ updateSwapAnimation(discard_top)
│ │
▼──────────────────────────────────▼
cardAnimations.animateUnifiedSwap()
│
▼
_doArcSwap() timeline:
┌───────────────────────────────────────────┐
│ (If face-down: flip first, 320ms) │
│ │
│ 1. Lift both cards (100ms, lift ease) │
│ translateY: -10, scale: 1.03 │
│ │
│ 2a. Hand card arcs (320ms, arc ease) │
│ → discard pile │
│ │
│ 2b. Held card arcs (320ms, arc ease) │ ← parallel
│ → hand slot │ with 2a
│ │
│ 3. Settle (100ms, settle ease)│
│ scale: 1.02→1 (gentle overshoot) │
└───────────────────────────────────────────┘
│
▼
Callback → completeSwapAnimation()
├─ Clean up animation state, remove classes
├─ CLEAR swapAnimationInProgress = false
├─ Apply pendingGameState if exists
└─ renderGame()
Total animation time: ~100 + 320 + 100 = ~520ms (face-up), ~840ms (face-down)
Flow 4: Local Player Discards
Trigger: User clicks discard button while holding a drawn card
User clicks discard button
│
▼
discardDrawn()
├─ Store discardedCard
├─ Send: { type: 'discard' }
├─ Clear drawnCard, hide toast/button
├─ Get heldRect (position of floating card)
├─ Hide floating held card
├─ SET skipNextDiscardFlip = true
└─ SET localDiscardAnimating = true
│
▼
cardAnimations.animateHeldToDiscard(card, heldRect, callback)
│
▼
Timeline:
┌───────────────────────────────────────────┐
│ 1. Lift (100ms, lift ease) │
│ translateY: -8, scale: 1.02 │
│ │
│ 2. Arc to discard (320ms, arc ease) │
│ left/top with arc peak above │
│ │
│ 3. Settle (100ms, settle ease)│
│ scale: 1.02→1 │
└───────────────────────────────────────────┘
│
▼
Callback:
├─ updateDiscardPileDisplay(card)
├─ pulseDiscardLand()
├─ SET skipNextDiscardFlip = true
└─ CLEAR localDiscardAnimating = false
Total animation time: ~100 + 320 + 100 = ~520ms
Flow 5: Opponent Draws from Deck then Swaps
Trigger: State change detected via WebSocket game_state update
Server sends game_state (opponent drew + swapped)
│
▼
triggerAnimationsForStateChange(old, new)
│
├─── STEP 1: Draw Detection ───────────────────────┐
│ drawn_card: null → something │
│ drawn_player_id != local player │
│ Discard unchanged → drew from DECK │
│ │
│ ├─ Clear stale opponent flags │
│ ├─ SET isDrawAnimating = true │
│ └─ animateDrawDeck(null, callback) │
│ │ │
│ └─ Callback: CLEAR isDrawAnimating │
│ │
├─── STEP 2: Swap Detection ───────────────────────┐
│ discard_top changed │
│ Previous player's hand has different card │
│ NOT justDetectedDraw (skip guard) │
│ │
│ └─ fireSwapAnimation(playerId, card, pos) │
│ │ │
│ ▼ │
│ SET opponentSwapAnimation = { playerId, pos } │
│ Hide source card (swap-out) │
│ │ │
│ ▼ │
│ animateUnifiedSwap() → _doArcSwap() │
│ (same timeline as Flow 3) │
│ │ │
│ ▼ │
│ Callback: │
│ ├─ Restore source card │
│ ├─ CLEAR opponentSwapAnimation = null │
│ └─ renderGame() │
└───────────────────────────────────────────────────┘
Note: STEP 1 and STEP 2 are detected in the same triggerAnimationsForStateChange call. The draw animation fires first; the swap animation fires after (may overlap slightly depending on timing).
Flow 6: Opponent Draws from Deck then Discards
Trigger: State change — opponent drew from deck but didn't swap (discarded drawn card)
Server sends game_state (opponent drew + discarded)
│
▼
triggerAnimationsForStateChange(old, new)
│
├─── STEP 1: Draw Detection ──────────────────┐
│ (Same as Flow 5 — draw from deck) │
│ SET isDrawAnimating = true │
│ animateDrawDeck(null, callback) │
│ │
├─── STEP 2: Discard Detection ────────────────┐
│ discard_top changed │
│ No hand position changed (no swap) │
│ │
│ └─ fireDiscardAnimation(card, playerId) │
│ │ │
│ ▼ │
│ SET opponentDiscardAnimating = true │
│ SET skipNextDiscardFlip = true │
│ │ │
│ ▼ │
│ animateOpponentDiscard(card, callback) │
│ │
│ Timeline: │
│ ┌────────────────────────────────────────┐ │
│ │ (Wait for draw overlay to clear) │ │
│ │ │ │
│ │ 1. Lift (100ms, lift ease) │ │
│ │ 2. Arc→discard (320ms, arc ease) │ │
│ │ 3. Settle (100ms, settle ease) │ │
│ └────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Callback: │
│ ├─ CLEAR opponentDiscardAnimating = false │
│ ├─ updateDiscardPileDisplay(card) │
│ └─ pulseDiscardLand() │
└───────────────────────────────────────────────┘
Flow 7: Opponent Draws from Discard then Swaps
Trigger: State change — opponent took from discard pile and swapped
Server sends game_state (opponent drew from discard + swapped)
│
▼
triggerAnimationsForStateChange(old, new)
│
├─── STEP 1: Draw Detection ──────────────────┐
│ drawn_card: null → something │
│ Discard top CHANGED → drew from DISCARD │
│ │
│ ├─ Clear stale opponent flags │
│ ├─ SET isDrawAnimating = true │
│ └─ animateDrawDiscard(card, callback) │
│ │
├─── STEP 2: Skip Guard ───────────────────────┐
│ justDetectedDraw AND discard changed? │
│ YES → SKIP STEP 2 │
│ │
│ The discard change was from REMOVING a │
│ card (draw), not ADDING one (discard). │
│ The swap detection comes from a LATER │
│ state update when the turn completes. │
└───────────────────────────────────────────────┘
│
▼
(Next state update detects the swap via STEP 2)
└─ fireSwapAnimation() — same as Flow 5
Critical: The skip guard (!justDetectedDraw) prevents double-animating when an opponent draws from the discard pile. Without it, the discard change would trigger both a draw animation AND a discard animation.
Flow 8: Initial Card Flip
Trigger: User clicks face-down card during the initial flip phase (start of round)
User clicks face-down card (position N)
│
▼
handleCardClick(position)
├─ Check: waiting_for_initial_flip
├─ Validate: card is face-down, not already tracked
├─ Add to locallyFlippedCards set
├─ Add to selectedCards array
└─ fireLocalFlipAnimation(position, card)
│
▼
fireLocalFlipAnimation()
├─ Add to animatingPositions set (prevent overlap)
└─ cardAnimations.animateInitialFlip(cardEl, card, callback)
│
▼
Timeline:
┌──────────────────────────────────┐
│ Create overlay at card position │
│ Hide original (opacity: 0) │
│ │
│ 1. Flip (320ms, flip) │
│ rotateY: 180→0 │
│ Play flip sound │
└──────────────────────────────────┘
│
▼
Callback:
├─ Remove overlay, restore original
└─ Remove from animatingPositions
│
▼
renderGame() (called after click)
└─ Shows flipped state immediately (optimistic)
│
▼
(If all required flips selected)
└─ Send: { type: 'flip_cards', positions: [...] }
│
▼
Server confirms → clear locallyFlippedCards
Flow 9: Deal Animation
Trigger: game_started or round_started WebSocket message
Server: 'game_started' / 'round_started'
│
▼
Reset all state, cancel animations
SET dealAnimationInProgress = true
renderGame() — layout card slots
Hide player/opponent cards (visibility: hidden)
│
▼
cardAnimations.animateDealing(gameState, getPlayerRect, callback)
│
▼
┌─────────────────────────────────────────────────┐
│ Shuffle pause (400ms) │
│ │
│ For each deal round (6 total): │
│ For each player (dealer's left first): │
│ ┌─────────────────────────────────────┐ │
│ │ Create overlay at deck position │ │
│ │ Fly to player card slot (150ms) │ │
│ │ Play card sound │ │
│ │ Stagger delay (80ms) │ │
│ └─────────────────────────────────────┘ │
│ Round pause (50ms) │
│ │
│ Wait for last cards to land │
│ Flip discard card (200ms delay + flip sound) │
│ Clean up all overlays │
└─────────────────────────────────────────────────┘
│
▼
Callback:
├─ CLEAR dealAnimationInProgress = false
├─ Show real cards (visibility: visible)
├─ renderGame()
└─ animateOpponentInitialFlips()
│
▼
┌─────────────────────────────────────────────────┐
│ For each opponent: │
│ Random delay (500-2500ms window) │
│ For each face-up card: │
│ Temporarily show as face-down │
│ animateOpponentFlip() (320ms) │
│ Stagger (400ms between cards) │
└─────────────────────────────────────────────────┘
Total deal time: ~400 + (6 rounds x players x 230ms) + 350ms flip
Flow 10: Round End Reveal
Trigger: round_over WebSocket message after round ends
Server: 'game_state' (phase → 'round_over')
├─ Detect roundJustEnded
├─ Save pre/post reveal states
└─ Update gameState but DON'T render
│
▼
Server: 'round_over' (scores, rankings)
│
▼
runRoundEndReveal(scores, rankings)
├─ SET revealAnimationInProgress = true
├─ renderGame() — show current layout
├─ Compute cardsToReveal (face-down → face-up)
└─ Get reveal order (knocker first, then clockwise)
│
▼
┌──────────────────────────────────────────┐
│ For each player (in reveal order): │
│ Highlight player area │
│ Pause (200ms) │
│ │
│ For each face-down card: │
│ animateRevealFlip(id, pos, card) │
│ ├─ Local: animateInitialFlip (320ms) │
│ └─ Opponent: animateOpponentFlip │
│ Stagger (100ms) │
│ │
│ Wait for last flip + pause │
│ Remove highlight │
└──────────────────────────────────────────┘
│
▼
CLEAR revealAnimationInProgress = false
renderGame()
│
▼
Run score tally animation
Show scoreboard
Flag Lifecycle Summary
Every flag follows the same pattern: SET before animation, CLEAR in callback.
SET flag ──► Animation runs ──► Callback fires ──► CLEAR flag
│
▼
renderGame()
Where each flag is cleared
| Flag | Normal Clear | Safety Clears |
|---|---|---|
isDrawAnimating |
Draw animation callback | — |
localDiscardAnimating |
Discard animation callback | Fallback path |
opponentDiscardAnimating |
Opponent discard callback | your_turn, card_drawn, before opponent draw |
opponentSwapAnimation |
Swap animation callback | your_turn, card_drawn, before opponent draw, new round |
dealAnimationInProgress |
Deal complete callback | — |
swapAnimationInProgress |
completeSwapAnimation() |
— |
Safety Clears
Stale flags can freeze the UI. Multiple locations clear opponent flags as a safety net:
| Location | Clears | When |
|---|---|---|
your_turn message handler |
opponentSwapAnimation, opponentDiscardAnimating |
Player's turn starts |
card_drawn handler (deck) |
opponentSwapAnimation, opponentDiscardAnimating |
Local player draws |
card_drawn handler (discard) |
opponentSwapAnimation, opponentDiscardAnimating |
Local player draws |
| Before opponent draw animation | opponentSwapAnimation, opponentDiscardAnimating |
New opponent animation starts |
game_started/round_started |
All flags | New round resets everything |
Rule: If you add a new animation flag, add safety clears in the your_turn handler and at round start.