- 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>
10 KiB
10 KiB
V3-05: Final Turn Urgency
Overview
When a player reveals all their cards, the round enters "final turn" phase - each other player gets one last turn. This is a tense moment in physical games. Currently, only a small badge shows "Final Turn" which lacks urgency.
Dependencies: None Dependents: None
Goals
- Create visual tension when final turn begins
- Show who triggered final turn (the knocker)
- Indicate how many players still need to act
- Make each remaining turn feel consequential
- Countdown feeling as players take their last turns
Current State
From app.js:
// Final turn badge exists but is minimal
if (isFinalTurn) {
this.finalTurnBadge.classList.remove('hidden');
} else {
this.finalTurnBadge.classList.add('hidden');
}
The badge just shows "FINAL TURN" text - no countdown, no urgency indicator.
Design
Visual Elements
- Pulsing Border - Game area gets subtle pulsing red/orange border
- Enhanced Badge - Larger badge with countdown
- Knocker Indicator - Show who triggered final turn
- Turn Counter - "2 players remaining" style indicator
Badge Enhancement
Current: [FINAL TURN]
Enhanced: [⚠️ FINAL TURN]
[Player 2 of 3]
Or more dramatic:
[🔔 LAST CHANCE!]
[2 turns left]
Color Scheme
- Normal play: Green felt background
- Final turn: Subtle warm/orange tint or border pulse
- Not overwhelming, but noticeable shift
Implementation
Enhanced Final Turn Badge
<!-- Enhanced badge structure -->
<div id="final-turn-badge" class="hidden">
<div class="final-turn-icon">⚡</div>
<div class="final-turn-text">FINAL TURN</div>
<div class="final-turn-remaining">2 turns left</div>
</div>
CSS Enhancements
/* Enhanced final turn badge */
#final-turn-badge {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
color: white;
padding: 12px 24px;
border-radius: 12px;
text-align: center;
z-index: 100;
box-shadow: 0 4px 20px rgba(214, 48, 49, 0.4);
animation: final-turn-pulse 1.5s ease-in-out infinite;
}
#final-turn-badge.hidden {
display: none;
}
.final-turn-icon {
font-size: 1.5em;
margin-bottom: 4px;
}
.final-turn-text {
font-weight: bold;
font-size: 1.2em;
letter-spacing: 0.1em;
}
.final-turn-remaining {
font-size: 0.9em;
opacity: 0.9;
margin-top: 4px;
}
@keyframes final-turn-pulse {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
box-shadow: 0 4px 20px rgba(214, 48, 49, 0.4);
}
50% {
transform: translate(-50%, -50%) scale(1.02);
box-shadow: 0 4px 30px rgba(214, 48, 49, 0.6);
}
}
/* Game area border pulse during final turn */
#game-screen.final-turn-active {
animation: game-area-urgency 2s ease-in-out infinite;
}
@keyframes game-area-urgency {
0%, 100% {
box-shadow: inset 0 0 0 0 rgba(255, 107, 53, 0);
}
50% {
box-shadow: inset 0 0 30px 0 rgba(255, 107, 53, 0.15);
}
}
/* Knocker highlight */
.player-area.is-knocker,
.opponent-area.is-knocker {
border: 2px solid #ff6b35;
}
.knocker-badge {
position: absolute;
top: -10px;
right: -10px;
background: #ff6b35;
color: white;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.7em;
font-weight: bold;
}
JavaScript Updates
// In renderGame() or dedicated method
updateFinalTurnDisplay() {
const isFinalTurn = this.gameState?.phase === 'final_turn';
const finisherId = this.gameState?.finisher_id;
// Toggle game area class
this.gameScreen.classList.toggle('final-turn-active', isFinalTurn);
if (isFinalTurn) {
// Calculate remaining turns
const remaining = this.countRemainingTurns();
// Update badge content
this.finalTurnBadge.querySelector('.final-turn-remaining').textContent =
remaining === 1 ? '1 turn left' : `${remaining} turns left`;
// Show badge with entrance animation
this.finalTurnBadge.classList.remove('hidden');
this.finalTurnBadge.classList.add('entering');
setTimeout(() => {
this.finalTurnBadge.classList.remove('entering');
}, 300);
// Mark knocker
this.markKnocker(finisherId);
// Play alert sound on first appearance
if (!this.finalTurnAnnounced) {
this.playSound('alert');
this.finalTurnAnnounced = true;
}
} else {
this.finalTurnBadge.classList.add('hidden');
this.gameScreen.classList.remove('final-turn-active');
this.finalTurnAnnounced = false;
this.clearKnockerMark();
}
}
countRemainingTurns() {
if (!this.gameState || this.gameState.phase !== 'final_turn') return 0;
const finisherId = this.gameState.finisher_id;
const currentIdx = this.gameState.players.findIndex(
p => p.id === this.gameState.current_player_id
);
const finisherIdx = this.gameState.players.findIndex(
p => p.id === finisherId
);
if (currentIdx === -1 || finisherIdx === -1) return 0;
// Count players between current and finisher (not including finisher)
let count = 0;
let idx = currentIdx;
const numPlayers = this.gameState.players.length;
while (idx !== finisherIdx) {
count++;
idx = (idx + 1) % numPlayers;
}
return count;
}
markKnocker(knockerId) {
// Add knocker badge to the player who triggered final turn
this.clearKnockerMark();
if (!knockerId) return;
if (knockerId === this.playerId) {
this.playerArea.classList.add('is-knocker');
// Add badge element
const badge = document.createElement('div');
badge.className = 'knocker-badge';
badge.textContent = 'OUT';
this.playerArea.appendChild(badge);
} else {
const area = this.opponentsRow.querySelector(
`.opponent-area[data-player-id="${knockerId}"]`
);
if (area) {
area.classList.add('is-knocker');
const badge = document.createElement('div');
badge.className = 'knocker-badge';
badge.textContent = 'OUT';
area.appendChild(badge);
}
}
}
clearKnockerMark() {
// Remove all knocker indicators
document.querySelectorAll('.is-knocker').forEach(el => {
el.classList.remove('is-knocker');
});
document.querySelectorAll('.knocker-badge').forEach(el => {
el.remove();
});
}
Alert Sound
// In playSound() method
} else if (type === 'alert') {
// Attention-getting sound for final turn
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'triangle';
osc.frequency.setValueAtTime(523, ctx.currentTime); // C5
osc.frequency.setValueAtTime(659, ctx.currentTime + 0.1); // E5
osc.frequency.setValueAtTime(784, ctx.currentTime + 0.2); // G5
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.4);
}
Entrance Animation
When final turn starts, badge should appear dramatically:
#final-turn-badge.entering {
animation: badge-entrance 0.3s ease-out;
}
@keyframes badge-entrance {
0% {
transform: translate(-50%, -50%) scale(0.5);
opacity: 0;
}
70% {
transform: translate(-50%, -50%) scale(1.1);
}
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
}
Turn Countdown Update
Each time a player takes their final turn, update the counter:
// In state change detection
if (newState.phase === 'final_turn') {
const oldRemaining = this.lastRemainingTurns;
const newRemaining = this.countRemainingTurns();
if (oldRemaining !== newRemaining) {
this.updateFinalTurnCounter(newRemaining);
this.lastRemainingTurns = newRemaining;
// Pulse the badge on update
this.finalTurnBadge.classList.add('counter-updated');
setTimeout(() => {
this.finalTurnBadge.classList.remove('counter-updated');
}, 200);
}
}
#final-turn-badge.counter-updated {
animation: counter-pulse 0.2s ease-out;
}
@keyframes counter-pulse {
0% { transform: translate(-50%, -50%) scale(1); }
50% { transform: translate(-50%, -50%) scale(1.05); }
100% { transform: translate(-50%, -50%) scale(1); }
}
Test Scenarios
- Enter final turn - Badge appears with animation, sound plays
- Turn counter decrements - Shows "2 turns left" → "1 turn left"
- Last turn - Shows "1 turn left", extra urgency
- Round ends - Badge disappears, border pulse stops
- Knocker marked - OUT badge on player who triggered
- Multiple rounds - Badge resets between rounds
Acceptance Criteria
- Final turn badge appears when phase is
final_turn - Badge shows remaining turns count
- Count updates as players take turns
- Game area has subtle urgency visual
- Knocker is marked with badge
- Alert sound plays when final turn starts
- Badge has entrance animation
- All visuals reset when round ends
- Not overwhelming - tension without annoyance
Implementation Order
- Update HTML structure for enhanced badge
- Add CSS for badge, urgency border, knocker indicator
- Implement
countRemainingTurns()method - Implement
updateFinalTurnDisplay()method - Implement
markKnocker()andclearKnockerMark() - Add alert sound to
playSound() - Integrate into
renderGame()or state change handler - Add entrance animation
- Add counter update pulse
- Test all scenarios
Notes for Agent
- The urgency should enhance tension, not frustrate players
- Keep the pulsing subtle - not distracting during play
- The knocker badge helps players understand game state
- Consider mobile: badge should fit on small screens
- The remaining turns count helps players plan their last move
- Reset all state between rounds (finalTurnAnnounced flag)