golfgame/docs/ANIMATION-FLOWS.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

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

  1. Architecture Overview
  2. Animation Flags
  3. Flow 1: Local Player Draws from Deck
  4. Flow 2: Local Player Draws from Discard
  5. Flow 3: Local Player Swaps
  6. Flow 4: Local Player Discards
  7. Flow 5: Opponent Draws from Deck then Swaps
  8. Flow 6: Opponent Draws from Deck then Discards
  9. Flow 7: Opponent Draws from Discard then Swaps
  10. Flow 8: Initial Card Flip
  11. Flow 9: Deal Animation
  12. Flow 10: Round End Reveal
  13. Flag Lifecycle Summary
  14. 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.