diff --git a/.env.example b/.env.example
index 0d9181c..a7ae7af 100644
--- a/.env.example
+++ b/.env.example
@@ -55,7 +55,12 @@ ROOM_CODE_LENGTH=4
SECRET_KEY=
# Enable invite-only mode (requires invitation to register)
-INVITE_ONLY=false
+INVITE_ONLY=true
+
+# Bootstrap admin account (for first-time setup with INVITE_ONLY=true)
+# Remove these after first login!
+# BOOTSTRAP_ADMIN_USERNAME=admin
+# BOOTSTRAP_ADMIN_PASSWORD=changeme12345
# Comma-separated list of admin email addresses
ADMIN_EMAILS=
@@ -104,5 +109,13 @@ CARD_LUCKY_SWING_JOKER=-5 # Joker value when lucky_swing enabled
# Enable rate limiting (recommended for production)
# RATE_LIMIT_ENABLED=true
+# Redis URL (required for matchmaking and rate limiting)
+# REDIS_URL=redis://localhost:6379
+
# Base URL for email links
# BASE_URL=https://your-domain.com
+
+# Matchmaking (skill-based public games)
+MATCHMAKING_ENABLED=true
+MATCHMAKING_MIN_PLAYERS=2
+MATCHMAKING_MAX_PLAYERS=4
diff --git a/client/app.js b/client/app.js
index 9769e2d..37b68f9 100644
--- a/client/app.js
+++ b/client/app.js
@@ -79,8 +79,7 @@ class GolfGame {
const roomCode = params.get('room');
if (roomCode) {
this.roomCodeInput.value = roomCode.toUpperCase();
- // Focus name input so user can quickly enter name and join
- this.playerNameInput.focus();
+ this.roomCodeInput.focus();
// Clean up URL without reloading
window.history.replaceState({}, '', window.location.pathname);
}
@@ -367,16 +366,24 @@ class GolfGame {
initElements() {
// Screens
this.lobbyScreen = document.getElementById('lobby-screen');
+ this.matchmakingScreen = document.getElementById('matchmaking-screen');
this.waitingScreen = document.getElementById('waiting-screen');
this.gameScreen = document.getElementById('game-screen');
// Lobby elements
- this.playerNameInput = document.getElementById('player-name');
this.roomCodeInput = document.getElementById('room-code');
+ this.findGameBtn = document.getElementById('find-game-btn');
this.createRoomBtn = document.getElementById('create-room-btn');
this.joinRoomBtn = document.getElementById('join-room-btn');
this.lobbyError = document.getElementById('lobby-error');
+ // Matchmaking elements
+ this.matchmakingStatus = document.getElementById('matchmaking-status');
+ this.matchmakingTime = document.getElementById('matchmaking-time');
+ this.matchmakingQueueInfo = document.getElementById('matchmaking-queue-info');
+ this.cancelMatchmakingBtn = document.getElementById('cancel-matchmaking-btn');
+ this.matchmakingTimer = null;
+
// Waiting room elements
this.displayRoomCode = document.getElementById('display-room-code');
this.copyRoomCodeBtn = document.getElementById('copy-room-code');
@@ -470,6 +477,8 @@ class GolfGame {
}
bindEvents() {
+ this.findGameBtn?.addEventListener('click', () => { this.playSound('click'); this.findGame(); });
+ this.cancelMatchmakingBtn?.addEventListener('click', () => { this.playSound('click'); this.cancelMatchmaking(); });
this.createRoomBtn.addEventListener('click', () => { this.playSound('click'); this.createRoom(); });
this.joinRoomBtn.addEventListener('click', () => { this.playSound('click'); this.joinRoom(); });
this.startGameBtn.addEventListener('click', () => { this.playSound('success'); this.startGame(); });
@@ -502,9 +511,6 @@ class GolfGame {
});
// Enter key handlers
- this.playerNameInput.addEventListener('keypress', (e) => {
- if (e.key === 'Enter') this.createRoomBtn.click();
- });
this.roomCodeInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.joinRoomBtn.click();
});
@@ -612,7 +618,13 @@ class GolfGame {
connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host || 'localhost:8000';
- const wsUrl = `${protocol}//${host}/ws`;
+ let wsUrl = `${protocol}//${host}/ws`;
+
+ // Attach auth token if available
+ const token = this.authManager?.token;
+ if (token) {
+ wsUrl += `?token=${encodeURIComponent(token)}`;
+ }
this.ws = new WebSocket(wsUrl);
@@ -636,6 +648,14 @@ class GolfGame {
};
}
+ reconnect() {
+ if (this.ws) {
+ this.ws.onclose = null; // Prevent error message on intentional close
+ this.ws.close();
+ }
+ this.connect();
+ }
+
send(message) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
@@ -682,6 +702,7 @@ class GolfGame {
case 'round_started':
// Clear any countdown from previous hole
this.clearNextHoleCountdown();
+ this.dismissScoresheetModal();
this.nextRoundBtn.classList.remove('waiting');
// Clear round winner highlights
this.roundWinnerNames = new Set();
@@ -867,15 +888,80 @@ class GolfGame {
}
break;
+ case 'queue_joined':
+ this.showScreen('matchmaking');
+ this.startMatchmakingTimer();
+ this.updateMatchmakingStatus(data);
+ break;
+
+ case 'queue_status':
+ this.updateMatchmakingStatus(data);
+ break;
+
+ case 'queue_matched':
+ this.stopMatchmakingTimer();
+ if (this.matchmakingStatus) {
+ this.matchmakingStatus.textContent = 'Match found!';
+ }
+ break;
+
+ case 'queue_left':
+ this.stopMatchmakingTimer();
+ this.showScreen('lobby');
+ break;
+
case 'error':
this.showError(data.message);
break;
}
}
+ // Matchmaking
+ findGame() {
+ this.connect();
+ this.ws.onopen = () => {
+ this.send({ type: 'queue_join' });
+ };
+ }
+
+ cancelMatchmaking() {
+ this.send({ type: 'queue_leave' });
+ this.stopMatchmakingTimer();
+ this.showScreen('lobby');
+ }
+
+ startMatchmakingTimer() {
+ this.matchmakingStartTime = Date.now();
+ this.stopMatchmakingTimer();
+ this.matchmakingTimer = setInterval(() => {
+ const elapsed = Math.floor((Date.now() - this.matchmakingStartTime) / 1000);
+ const mins = Math.floor(elapsed / 60);
+ const secs = elapsed % 60;
+ if (this.matchmakingTime) {
+ this.matchmakingTime.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
+ }
+ }, 1000);
+ }
+
+ stopMatchmakingTimer() {
+ if (this.matchmakingTimer) {
+ clearInterval(this.matchmakingTimer);
+ this.matchmakingTimer = null;
+ }
+ }
+
+ updateMatchmakingStatus(data) {
+ if (this.matchmakingQueueInfo) {
+ const parts = [];
+ if (data.queue_size) parts.push(`${data.queue_size} player${data.queue_size !== 1 ? 's' : ''} in queue`);
+ if (data.position) parts.push(`Position: #${data.position}`);
+ this.matchmakingQueueInfo.textContent = parts.join(' \u2022 ');
+ }
+ }
+
// Room Actions
createRoom() {
- const name = this.playerNameInput.value.trim() || 'Player';
+ const name = this.authManager?.user?.username || 'Player';
this.connect();
this.ws.onopen = () => {
this.send({ type: 'create_room', player_name: name });
@@ -883,7 +969,7 @@ class GolfGame {
}
joinRoom() {
- const name = this.playerNameInput.value.trim() || 'Player';
+ const name = this.authManager?.user?.username || 'Player';
const code = this.roomCodeInput.value.trim().toUpperCase();
if (code.length !== 4) {
@@ -1509,20 +1595,260 @@ class GolfGame {
}
showKnockBanner(playerName) {
- const banner = document.createElement('div');
- banner.className = 'knock-banner';
- banner.innerHTML = `${playerName ? playerName + ' knocked!' : 'KNOCK!'}`;
- document.body.appendChild(banner);
+ const duration = window.TIMING?.knock?.statusDuration || 2500;
+ const message = playerName ? `${playerName} KNOCKED!` : 'KNOCK!';
+ this.setStatus(message, 'knock');
document.body.classList.add('screen-shake');
setTimeout(() => {
- banner.classList.add('fading');
document.body.classList.remove('screen-shake');
- }, 800);
- setTimeout(() => {
- banner.remove();
- }, 1100);
+ }, 300);
+
+ // Restore normal status after duration
+ this._knockStatusTimeout = setTimeout(() => {
+ // Only clear if still showing knock status
+ if (this.statusMessage.classList.contains('knock')) {
+ this.setStatus('Final turn!', 'opponent-turn');
+ }
+ }, duration);
+ }
+
+ // --- V3_17: Scoresheet Modal ---
+
+ showScoresheetModal(scores, gameState, rankings) {
+ // Remove existing modal if any
+ const existing = document.getElementById('scoresheet-modal');
+ if (existing) existing.remove();
+
+ // Also update side panel data (silently)
+ this.showScoreboard(scores, false, rankings);
+
+ const cardValues = gameState?.card_values || this.getDefaultCardValues();
+ const scoringRules = gameState?.scoring_rules || {};
+ const knockerId = gameState?.finisher_id;
+ const currentRound = gameState?.current_round || '?';
+ const totalRounds = gameState?.total_rounds || '?';
+
+ // Find round winner(s)
+ const roundScores = scores.map(s => s.score);
+ const minRoundScore = Math.min(...roundScores);
+
+ // Build player rows - knocker first, then others
+ const ordered = [...gameState.players].sort((a, b) => {
+ if (a.id === knockerId) return -1;
+ if (b.id === knockerId) return 1;
+ return 0;
+ });
+
+ const playerRowsHtml = ordered.map(player => {
+ const scoreData = scores.find(s => s.name === player.name) || {};
+ const result = this.calculateColumnScores(player.cards, cardValues, scoringRules, false);
+ const isKnocker = player.id === knockerId;
+ const isLowScore = scoreData.score === minRoundScore;
+ const colIndices = [[0, 3], [1, 4], [2, 5]];
+
+ // Badge
+ let badge = '';
+ if (isKnocker) badge = 'KNOCKED';
+ else if (isLowScore) badge = 'LOW SCORE';
+
+ // Build columns
+ const columnsHtml = colIndices.map((indices, c) => {
+ const col = result.columns[c];
+ const topCard = player.cards[indices[0]];
+ const bottomCard = player.cards[indices[1]];
+ const isPair = col.isPair;
+
+ const topMini = this.renderMiniCard(topCard, isPair);
+ const bottomMini = this.renderMiniCard(bottomCard, isPair);
+
+ let colScore;
+ if (isPair) {
+ const pairLabel = col.pairValue !== 0 ? `PAIR ${col.pairValue}` : 'PAIR 0';
+ colScore = `
${pairLabel}
`;
+ } else {
+ const val = col.topValue + col.bottomValue;
+ const cls = val < 0 ? 'ss-negative' : '';
+ colScore = `${val >= 0 ? '+' + val : val}
`;
+ }
+
+ return `
+ ${topMini}${bottomMini}
+ ${colScore}
+
`;
+ }).join('');
+
+ // Bonuses
+ let bonusHtml = '';
+ if (result.bonuses.length > 0) {
+ bonusHtml = result.bonuses.map(b => {
+ const label = b.type === 'wolfpack' ? 'WOLFPACK' : 'FOUR OF A KIND';
+ return `${label} ${b.value}`;
+ }).join(' ');
+ }
+
+ const roundScore = scoreData.score !== undefined ? scoreData.score : '-';
+ const totalScore = scoreData.total !== undefined ? scoreData.total : '-';
+
+ return `
+
+
${columnsHtml}
+ ${bonusHtml ? `
${bonusHtml}
` : ''}
+
+ Hole: ${roundScore}
+ Total: ${totalScore}
+
+
`;
+ }).join('');
+
+ // Create modal
+ const modal = document.createElement('div');
+ modal.id = 'scoresheet-modal';
+ modal.className = 'scoresheet-modal';
+ modal.innerHTML = `
+
+
+
${playerRowsHtml}
+
+
+ `;
+
+ document.body.appendChild(modal);
+ this.setStatus('Hole complete');
+
+ // Bind next button
+ const nextBtn = document.getElementById('ss-next-btn');
+ nextBtn.addEventListener('click', () => {
+ this.playSound('click');
+ this.dismissScoresheetModal();
+ this.nextRound();
+ });
+
+ // Start countdown
+ this.startScoresheetCountdown(nextBtn);
+
+ // Animate entrance
+ if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ this.animateScoresheetEntrance(modal);
+ }
+ }
+
+ renderMiniCard(card, isPaired) {
+ if (!card || !card.rank) return '';
+
+ const suit = card.suit;
+ const isRed = suit === 'hearts' || suit === 'diamonds';
+ const symbol = this.getSuitSymbol(suit);
+ const rank = card.rank === '★' ? '★' : card.rank;
+ const classes = [
+ 'ss-mini-card',
+ isRed ? 'ss-red' : 'ss-black',
+ isPaired ? 'ss-mini-paired' : ''
+ ].filter(Boolean).join(' ');
+
+ return `${rank}${symbol}
`;
+ }
+
+ animateScoresheetEntrance(modal) {
+ const T = window.TIMING?.scoresheet || {};
+ const playerRows = modal.querySelectorAll('.ss-player-row');
+ const nextBtn = modal.querySelector('.ss-next-btn');
+
+ // Start everything hidden
+ playerRows.forEach(row => {
+ row.style.opacity = '0';
+ row.style.transform = 'translateY(10px)';
+ });
+ if (nextBtn) {
+ nextBtn.style.opacity = '0';
+ }
+
+ // Stagger player rows in
+ if (window.anime) {
+ anime({
+ targets: Array.from(playerRows),
+ opacity: [0, 1],
+ translateY: [10, 0],
+ delay: anime.stagger(T.playerStagger || 150),
+ duration: 300,
+ easing: 'easeOutCubic',
+ complete: () => {
+ // Animate paired columns glow
+ setTimeout(() => {
+ modal.querySelectorAll('.ss-column-paired').forEach(col => {
+ col.classList.add('ss-pair-glow');
+ });
+ }, T.pairGlowDelay || 200);
+ }
+ });
+
+ // Fade in button after rows
+ const totalRowDelay = (playerRows.length - 1) * (T.playerStagger || 150) + 300;
+ anime({
+ targets: nextBtn,
+ opacity: [0, 1],
+ delay: totalRowDelay,
+ duration: 200,
+ easing: 'easeOutCubic',
+ });
+ } else {
+ // No anime.js - show immediately
+ playerRows.forEach(row => {
+ row.style.opacity = '1';
+ row.style.transform = '';
+ });
+ if (nextBtn) nextBtn.style.opacity = '1';
+ }
+ }
+
+ startScoresheetCountdown(btn) {
+ this.clearScoresheetCountdown();
+ const COUNTDOWN_SECONDS = 15;
+ let remaining = COUNTDOWN_SECONDS;
+
+ const update = () => {
+ if (this.isHost) {
+ btn.textContent = `Next Hole (${remaining}s)`;
+ btn.disabled = false;
+ } else {
+ btn.textContent = `Next hole in ${remaining}s...`;
+ btn.disabled = true;
+ }
+ };
+
+ update();
+
+ this.scoresheetCountdownInterval = setInterval(() => {
+ remaining--;
+ if (remaining <= 0) {
+ this.clearScoresheetCountdown();
+ if (this.isHost) {
+ this.dismissScoresheetModal();
+ this.nextRound();
+ } else {
+ btn.textContent = 'Waiting for host...';
+ }
+ } else {
+ update();
+ }
+ }, 1000);
+ }
+
+ clearScoresheetCountdown() {
+ if (this.scoresheetCountdownInterval) {
+ clearInterval(this.scoresheetCountdownInterval);
+ this.scoresheetCountdownInterval = null;
+ }
+ }
+
+ dismissScoresheetModal() {
+ this.clearScoresheetCountdown();
+ const modal = document.getElementById('scoresheet-modal');
+ if (modal) modal.remove();
}
// --- V3_02: Dealing Animation ---
@@ -1641,8 +1967,8 @@ class GolfGame {
const newState = this.postRevealState || this.gameState;
if (!oldState || !newState) {
- // Fallback: show scoreboard immediately
- this.showScoreboard(scores, false, rankings);
+ // Fallback: show scoresheet immediately
+ this.showScoresheetModal(scores, this.gameState, rankings);
return;
}
@@ -1687,18 +2013,14 @@ class GolfGame {
this.highlightPlayerArea(player.id, false);
}
- // All revealed - run score tally before showing scoreboard
+ // All revealed - show scoresheet modal
this.revealAnimationInProgress = false;
this.preRevealState = null;
this.postRevealState = null;
this.renderGame();
- // V3_07: Animated score tallying
- if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
- await this.runScoreTally(newState.players, knockerId);
- }
-
- this.showScoreboard(scores, false, rankings);
+ // V3_17: Scoresheet modal replaces tally + side panel scoreboard
+ this.showScoresheetModal(scores, newState, rankings);
}
getCardsToReveal(oldState, newState) {
@@ -2558,6 +2880,7 @@ class GolfGame {
nextRound() {
this.clearNextHoleCountdown();
+ this.clearScoresheetCountdown();
this.send({ type: 'next_round' });
this.gameButtons.classList.add('hidden');
this.nextRoundBtn.classList.remove('waiting');
@@ -2585,7 +2908,19 @@ class GolfGame {
// UI Helpers
showScreen(screen) {
+ // Accept string names or DOM elements
+ if (typeof screen === 'string') {
+ const screenMap = {
+ 'lobby': this.lobbyScreen,
+ 'matchmaking': this.matchmakingScreen,
+ 'waiting': this.waitingScreen,
+ 'game': this.gameScreen,
+ };
+ screen = screenMap[screen] || screen;
+ }
+
this.lobbyScreen.classList.remove('active');
+ this.matchmakingScreen?.classList.remove('active');
this.waitingScreen.classList.remove('active');
this.gameScreen.classList.remove('active');
if (this.rulesScreen) {
@@ -3873,13 +4208,8 @@ class GolfGame {
return;
}
- // Show game buttons
- this.gameButtons.classList.remove('hidden');
- this.newGameBtn.classList.add('hidden');
- this.nextRoundBtn.classList.remove('hidden');
-
- // Start countdown for next hole
- this.startNextHoleCountdown();
+ // V3_17: Scoresheet modal handles Next Hole button/countdown now
+ // Side panel just updates data silently
}
startNextHoleCountdown() {
@@ -4144,13 +4474,32 @@ class AuthManager {
this.initElements();
this.bindEvents();
this.updateUI();
+
+ // Validate stored token on load
+ if (this.token) {
+ this.validateToken();
+ }
+ }
+
+ async validateToken() {
+ try {
+ const response = await fetch('/api/auth/me', {
+ headers: { 'Authorization': `Bearer ${this.token}` },
+ });
+ if (!response.ok) {
+ this.logout();
+ }
+ } catch {
+ // Network error - keep token, will fail on next action
+ }
}
initElements() {
this.authBar = document.getElementById('auth-bar');
this.authUsername = document.getElementById('auth-username');
this.logoutBtn = document.getElementById('auth-logout-btn');
- this.authButtons = document.getElementById('auth-buttons');
+ this.authPrompt = document.getElementById('auth-prompt');
+ this.lobbyGameControls = document.getElementById('lobby-game-controls');
this.loginBtn = document.getElementById('login-btn');
this.signupBtn = document.getElementById('signup-btn');
this.modal = document.getElementById('auth-modal');
@@ -4162,6 +4511,7 @@ class AuthManager {
this.loginError = document.getElementById('login-error');
this.signupFormContainer = document.getElementById('signup-form-container');
this.signupForm = document.getElementById('signup-form');
+ this.signupInviteCode = document.getElementById('signup-invite-code');
this.signupUsername = document.getElementById('signup-username');
this.signupEmail = document.getElementById('signup-email');
this.signupPassword = document.getElementById('signup-password');
@@ -4247,10 +4597,6 @@ class AuthManager {
this.setAuth(data.token, data.user);
this.hideModal();
-
- if (data.user.username && this.game.playerNameInput) {
- this.game.playerNameInput.value = data.user.username;
- }
} catch (err) {
this.loginError.textContent = 'Connection error';
}
@@ -4260,6 +4606,7 @@ class AuthManager {
e.preventDefault();
this.clearErrors();
+ const invite_code = this.signupInviteCode?.value.trim() || null;
const username = this.signupUsername.value.trim();
const email = this.signupEmail.value.trim() || null;
const password = this.signupPassword.value;
@@ -4268,7 +4615,7 @@ class AuthManager {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ username, email, password }),
+ body: JSON.stringify({ invite_code, username, email, password }),
});
const data = await response.json();
@@ -4280,10 +4627,6 @@ class AuthManager {
this.setAuth(data.token, data.user);
this.hideModal();
-
- if (data.user.username && this.game.playerNameInput) {
- this.game.playerNameInput.value = data.user.username;
- }
} catch (err) {
this.signupError.textContent = 'Connection error';
}
@@ -4308,16 +4651,15 @@ class AuthManager {
updateUI() {
if (this.user) {
this.authBar?.classList.remove('hidden');
- this.authButtons?.classList.add('hidden');
+ this.authPrompt?.classList.add('hidden');
+ this.lobbyGameControls?.classList.remove('hidden');
if (this.authUsername) {
this.authUsername.textContent = this.user.username;
}
- if (this.game.playerNameInput && !this.game.playerNameInput.value) {
- this.game.playerNameInput.value = this.user.username;
- }
} else {
this.authBar?.classList.add('hidden');
- this.authButtons?.classList.remove('hidden');
+ this.authPrompt?.classList.remove('hidden');
+ this.lobbyGameControls?.classList.add('hidden');
}
}
}
diff --git a/client/index.html b/client/index.html
index 40f50f9..68cfce4 100644
--- a/client/index.html
+++ b/client/index.html
@@ -19,35 +19,52 @@
🏌️⚪ Golf
6-Card Golf Card Game
-
-