Make tic-tac-toe epic: per-player camps, 3 stages, containerized

Each player picks ANY avatar from a combined 16-emoji roster (8 sparkle +
8 shred); their color, move sound, and particle FX follow that avatar's CAMP,
so one nibling can rock unicorns/rainbows while the other brings skulls/metal.
A 3-way STAGE selector (Sparkle / Shred / Mixed) sets the global backdrop,
fonts, and winner vibe. Camps and stages are orthogonal, driven by CSS custom
properties (.camp-* and [data-stage]).

- Combined avatar grids for both players with a same-avatar guard
- Per-camp Web Audio SFX (sine chimes vs sawtooth power chords) + mute toggle
- Per-player glow on displays, scoreboard, and board pieces; winner screen
  adopts the winner's camp; screen shake on shred moves; themed celebration
- Persist names/avatars/stage/scores/mute via localStorage; reset-scores button
- Fix nested-scroll / clipped-top bug (center with margin:auto, not flex +
  max-height); no horizontal overflow 320px-desktop; SVG favicon
- Keyboard-navigable board, ARIA roles, live status, prefers-reduced-motion
- Containerize for OrbStack: Dockerfile (nginx:alpine), nginx.conf (gzip +
  cache/security headers), docker-compose.yml — one command on :8080

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-05-31 18:20:39 -06:00
parent 4b7d42dc13
commit 0fc1d32099
9 changed files with 1231 additions and 992 deletions

15
.dockerignore Normal file
View file

@ -0,0 +1,15 @@
# Keep the build context tiny — only the static assets + configs are needed.
.git
.gitignore
.dockerignore
Dockerfile
docker-compose.yml
README.md
test/
.playwright-mcp/
screenshots/
node_modules/
*.log
.DS_Store
.idea/
.vscode/

7
.gitignore vendored
View file

@ -7,4 +7,11 @@ Thumbs.db
# Test / tooling artifacts # Test / tooling artifacts
/screenshots/ /screenshots/
.playwright-mcp/
node_modules/
*.log *.log
# Local Claude config + tooling artifacts
.claude/
*.png
*.jpeg

17
Dockerfile Normal file
View file

@ -0,0 +1,17 @@
# Ultimate Tic-Tac-Toe — static site served by nginx.
# No build step: copy the static assets into the nginx web root.
FROM nginx:1.27-alpine
# Tuned server config (gzip, cache + security headers, SPA-safe fallback).
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Static assets.
COPY index.html style.css script.js /usr/share/nginx/html/
EXPOSE 80
# Liveness probe for `docker ps` / orchestration.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q -O /dev/null http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

View file

@ -1,7 +1,68 @@
# Kara's ultimate tic tac toe # Ultimate Tic-Tac-Toe
## Objective A two-player, hot-seat tic-tac-toe game for the browser where **each player brings their own flavor**.
Design an ultimate browser based tic tac toe game with the following features: Every player picks **any** avatar from a combined roster — sparkle *or* shred — and that choice sets their personal color, move sound, and particle effects. One player can rock 🦄 unicorns and rainbows while the other brings 💀 skulls and metal. A separate **stage** selector sets the global vibe.
## Camps & stages
**Camps** (per player, set by the chosen avatar):
- **✨ Sparkle** — ⭐🦄🌸🎀🦋🌈🎨🍭 · pink glow, bright sine chimes, sparkle particles.
- **💀 Shred** — 💀☠️🎸🤘🔥⚡🏴‍☠️🐉 · red glow, sawtooth power-chords, fire/lightning particles, board shake.
**Stages** (global, pick on the setup screen):
- **✨ Sparkle** — pastel gradient, glassmorphism cards, twinkling stars.
- **💀 Shred** — black & red, rising embers, lightning flashes, metal lettering.
- **⚔️ Mixed** — neutral dark arena where both camps' colors pop; both backdrops play. Great when the two players don't agree.
## Features
- **Free-for-all avatars** — each player's grid shows all 16; a guard stops both choosing the *same* one.
- **Per-player identity** carries through the whole game: side panels, scoreboard, and each player's pieces on the board glow in their camp color, with camp-specific move sounds and particle bursts.
- **Three stages** driven entirely by CSS custom properties (`[data-stage]`); the winner screen adopts the winner's camp.
- **Web Audio** SFX (no asset files); mute toggle, top-right.
- **Persistent** names, avatars, stage, scores, and mute via `localStorage`.
- **Accessible** — keyboard-navigable board (arrow keys + Enter/Space), ARIA roles and live status, visible focus rings, `prefers-reduced-motion`.
- **Responsive & scroll-safe** — no horizontal overflow or clipped content from 320 px to desktop (the old nested-scroll / clipped-top bug is fixed: the layout now centers with `margin:auto` instead of fragile flex centering + `max-height`).
## Run it
Static site, no build step.
**Local web server** (so `localStorage` + audio behave like production)
```bash
python3 -m http.server 8000
# → http://localhost:8000
```
**Docker / OrbStack** (nginx, production-style headers)
```bash
docker compose up -d --build
# → http://localhost:8080
docker compose down
```
## Test
End-to-end tests drive real Chromium against the running app via `playwright-core` and print a plain-text PASS/FAIL report (avatar roster, mixed-camp play, stage switching, win/draw flows, persistence, responsive overflow at five widths, keyboard a11y, console cleanliness). Screenshots land in `screenshots/`.
```bash
# with a server (or the container) running on :8080
PW_CORE="$(node -e "console.log(require.resolve('playwright-core'))")" \
node test/e2e.mjs http://localhost:8080
# optional: PW_CHROMIUM=/path/to/Chromium to pin a browser build
```
## Project structure
```
index.html markup; stage selector + three screens (setup / game / winner)
style.css stages ([data-stage]) + per-player camps (.camp-sparkle/.camp-shred)
script.js engine: avatar roster, camps, stages, audio, FX, persistence, a11y
Dockerfile nginx:alpine static image
nginx.conf gzip, cache + security headers
docker-compose.yml one-command run on :8080 via OrbStack
test/e2e.mjs Playwright end-to-end suite
```

12
docker-compose.yml Normal file
View file

@ -0,0 +1,12 @@
# Run the game locally with OrbStack's Docker engine:
# docker compose up -d --build
# open http://localhost:8080
# docker compose down
services:
tictactoe:
build: .
image: tictactoe:latest
container_name: tictactoe
ports:
- "8080:80"
restart: unless-stopped

View file

@ -1,130 +1,149 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-stage="mixed">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>✨ Ultimate Tic-Tac-Toe ✨</title> <title>Ultimate Tic-Tac-Toe</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%23%EF%B8%8F%E2%83%A3%3C/text%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Fredoka:wght@500;600;700&family=Metal+Mania&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
<!-- Animated backdrop: stars (sparkle) + embers/lightning (shred); both show on mixed -->
<div class="backdrop" aria-hidden="true">
<div class="stars"></div> <div class="stars"></div>
<div class="embers"></div>
<div class="lightning"></div>
</div>
<button id="muteBtn" class="icon-btn" type="button" aria-pressed="false" title="Toggle sound" aria-label="Toggle sound">
<span class="icon-on">🔊</span><span class="icon-off">🔇</span>
</button>
<div class="container"> <div class="container">
<!-- Player Name Input Screen --> <!-- Player Setup Screen -->
<div id="nameScreen" class="screen active"> <div id="nameScreen" class="screen active">
<div class="name-card"> <div class="card name-card">
<h1 class="title">✨ Ultimate Tic-Tac-Toe ✨</h1> <h1 class="title" id="setupTitle">Ultimate Tic-Tac-Toe</h1>
<div class="sparkle-divider"></div> <div class="sparkle-divider"></div>
<!-- Stage (global vibe) selector -->
<div class="stage-selector">
<span class="stage-label">Choose your arena</span>
<div class="stage-options" role="radiogroup" aria-label="Choose arena / stage">
<button class="stage-option" data-stage="sparkle" role="radio" aria-checked="false" type="button">
<span class="stage-emoji"></span>
<span class="stage-name">Sparkle</span>
<span class="stage-desc">Rainbows &amp; unicorns</span>
</button>
<button class="stage-option" data-stage="mixed" role="radio" aria-checked="true" type="button">
<span class="stage-emoji">⚔️</span>
<span class="stage-name">Mixed</span>
<span class="stage-desc">Anything goes</span>
</button>
<button class="stage-option" data-stage="shred" role="radio" aria-checked="false" type="button">
<span class="stage-emoji">💀</span>
<span class="stage-name">Shred</span>
<span class="stage-desc">Skulls &amp; metal</span>
</button>
</div>
</div>
<p class="setup-hint">Each player picks <strong>any</strong> avatar — sparkle or shred, your call. 🌈🤘</p>
<div class="players-setup">
<div class="player-setup" id="p1Setup">
<div class="name-input-group"> <div class="name-input-group">
<label for="player1Name"> <label for="player1Name">
<span class="player-icon">🌟</span> <span class="player-icon p1-icon">🌟</span>
Player 1 Name Player 1
</label> </label>
<input type="text" id="player1Name" placeholder="Enter your name..." maxlength="15"> <input type="text" id="player1Name" placeholder="Enter name..." maxlength="15" autocomplete="off">
</div>
<div class="player-avatar-group">
<span class="avatar-label">Pick an avatar</span>
<div class="avatar-options" id="player1Avatars" role="radiogroup" aria-label="Player 1 avatar"></div>
</div>
</div> </div>
<div class="player-setup" id="p2Setup">
<div class="name-input-group"> <div class="name-input-group">
<label for="player2Name"> <label for="player2Name">
<span class="player-icon">💫</span> <span class="player-icon p2-icon">💫</span>
Player 2 Name Player 2
</label> </label>
<input type="text" id="player2Name" placeholder="Enter your name..." maxlength="15"> <input type="text" id="player2Name" placeholder="Enter name..." maxlength="15" autocomplete="off">
</div>
<div class="avatar-section">
<h3 class="avatar-title">Choose Your Avatars!</h3>
<div class="avatar-selection">
<div class="player-avatar-group">
<label>Player 1</label>
<div class="avatar-options" id="player1Avatars">
<div class="avatar-option selected" data-avatar="⭐"></div>
<div class="avatar-option" data-avatar="🦄">🦄</div>
<div class="avatar-option" data-avatar="🌸">🌸</div>
<div class="avatar-option" data-avatar="🎀">🎀</div>
<div class="avatar-option" data-avatar="🦋">🦋</div>
<div class="avatar-option" data-avatar="🌈">🌈</div>
<div class="avatar-option" data-avatar="🎨">🎨</div>
<div class="avatar-option" data-avatar="🎪">🎪</div>
</div>
</div> </div>
<div class="player-avatar-group"> <div class="player-avatar-group">
<label>Player 2</label> <span class="avatar-label">Pick an avatar</span>
<div class="avatar-options" id="player2Avatars"> <div class="avatar-options" id="player2Avatars" role="radiogroup" aria-label="Player 2 avatar"></div>
<div class="avatar-option" data-avatar="⭐"></div>
<div class="avatar-option" data-avatar="🦄">🦄</div>
<div class="avatar-option selected" data-avatar="🌸">🌸</div>
<div class="avatar-option" data-avatar="🎀">🎀</div>
<div class="avatar-option" data-avatar="🦋">🦋</div>
<div class="avatar-option" data-avatar="🌈">🌈</div>
<div class="avatar-option" data-avatar="🎨">🎨</div>
<div class="avatar-option" data-avatar="🎪">🎪</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<button id="startBtn" class="start-btn"> <button id="startBtn" class="btn btn-primary" type="button">
Start Playing! 🎮 <span class="btn-label">Start Battle!</span>
</button> </button>
</div> </div>
</div> </div>
<!-- Game Screen --> <!-- Game Screen -->
<div id="gameScreen" class="screen"> <div id="gameScreen" class="screen">
<div class="game-card"> <div class="card game-card">
<h1 class="game-title">Tic-Tac-Toe</h1> <h1 class="game-title" id="gameTitle">Tic-Tac-Toe</h1>
<div class="game-layout"> <div class="game-layout">
<!-- Left Side: Game Area -->
<div class="game-area"> <div class="game-area">
<div class="player-info"> <div class="player-info">
<div class="player-display player1-display active"> <div class="player-display player1-display active">
<span class="player-symbol" id="player1Symbol">X</span> <span class="player-symbol" id="player1Symbol"></span>
<span class="player-name" id="player1Display"></span> <span class="player-name" id="player1Display">Player 1</span>
<div class="player-glow"></div> <div class="player-glow"></div>
</div> </div>
<div class="vs">VS</div> <div class="vs" id="vsLabel">VS</div>
<div class="player-display player2-display"> <div class="player-display player2-display">
<span class="player-symbol" id="player2Symbol">O</span> <span class="player-symbol" id="player2Symbol">💀</span>
<span class="player-name" id="player2Display"></span> <span class="player-name" id="player2Display">Player 2</span>
<div class="player-glow"></div> <div class="player-glow"></div>
</div> </div>
</div> </div>
<div class="game-board" id="gameBoard"> <div class="game-status" id="gameStatus" aria-live="polite"></div>
<div class="cell" data-cell></div>
<div class="cell" data-cell></div>
<div class="cell" data-cell></div>
<div class="cell" data-cell></div>
<div class="cell" data-cell></div>
<div class="cell" data-cell></div>
<div class="cell" data-cell></div>
<div class="cell" data-cell></div>
<div class="cell" data-cell></div>
</div>
<div class="game-status" id="gameStatus"></div> <div class="game-board" id="gameBoard" role="grid" aria-label="Tic-tac-toe board">
<button class="cell" data-cell data-index="0" role="gridcell" aria-label="Cell 1, empty"></button>
<button class="cell" data-cell data-index="1" role="gridcell" aria-label="Cell 2, empty"></button>
<button class="cell" data-cell data-index="2" role="gridcell" aria-label="Cell 3, empty"></button>
<button class="cell" data-cell data-index="3" role="gridcell" aria-label="Cell 4, empty"></button>
<button class="cell" data-cell data-index="4" role="gridcell" aria-label="Cell 5, empty"></button>
<button class="cell" data-cell data-index="5" role="gridcell" aria-label="Cell 6, empty"></button>
<button class="cell" data-cell data-index="6" role="gridcell" aria-label="Cell 7, empty"></button>
<button class="cell" data-cell data-index="7" role="gridcell" aria-label="Cell 8, empty"></button>
<button class="cell" data-cell data-index="8" role="gridcell" aria-label="Cell 9, empty"></button>
</div>
<div class="button-group"> <div class="button-group">
<button id="resetBtn" class="game-btn">🔄 New Game</button> <button id="resetBtn" class="btn btn-ghost" type="button">🔄 New Round</button>
<button id="changeNamesBtn" class="game-btn">👥 Change Names</button> <button id="changeNamesBtn" class="btn btn-ghost" type="button">👥 Change Players</button>
</div> </div>
</div> </div>
<!-- Right Side: Score Board -->
<div class="score-sidebar"> <div class="score-sidebar">
<h2 class="score-title">Scoreboard</h2> <h2 class="score-title" id="scoreTitle">Scoreboard</h2>
<div class="score-item player1-score"> <div class="score-item player1-score">
<span class="score-avatar" id="score1Avatar"></span> <span class="score-avatar" id="score1Avatar"></span>
<span class="score-name" id="score1Name">Player 1</span> <span class="score-name" id="score1Name">Player 1</span>
<span class="score-value" id="score1">0</span> <span class="score-value" id="score1">0</span>
</div> </div>
<div class="score-divider">🏆</div> <div class="score-divider" id="scoreDivider">🏆</div>
<div class="score-item player2-score"> <div class="score-item player2-score">
<span class="score-avatar" id="score2Avatar">🌸</span> <span class="score-avatar" id="score2Avatar">💀</span>
<span class="score-name" id="score2Name">Player 2</span> <span class="score-name" id="score2Name">Player 2</span>
<span class="score-value" id="score2">0</span> <span class="score-value" id="score2">0</span>
</div> </div>
<button id="resetScoresBtn" class="btn btn-tiny" type="button">Reset Scores</button>
</div> </div>
</div> </div>
</div> </div>
@ -132,18 +151,16 @@
<!-- Winner Screen --> <!-- Winner Screen -->
<div id="winnerScreen" class="screen"> <div id="winnerScreen" class="screen">
<div class="winner-card"> <div class="card winner-card">
<div class="confetti"></div> <div class="celebration" id="celebration" aria-hidden="true"></div>
<h1 class="winner-title">🎉 Winner! 🎉</h1> <h1 class="winner-title" id="winnerTitle">Winner!</h1>
<div class="trophy" id="winnerAvatar">🏆</div> <div class="trophy" id="winnerAvatar">🏆</div>
<p class="winner-name" id="winnerName"></p> <p class="winner-name" id="winnerName"></p>
<div class="fireworks"> <p class="winner-sub" id="winnerSub"></p>
<div class="firework"></div> <div class="winner-buttons">
<div class="firework"></div> <button id="playAgainBtn" class="btn btn-primary" type="button">Play Again!</button>
<div class="firework"></div> <button id="changeNamesBtn2" class="btn btn-ghost" type="button">Change Players</button>
</div> </div>
<button id="playAgainBtn" class="start-btn">Play Again! 🎮</button>
<button id="changeNamesBtn2" class="game-btn">Change Names</button>
</div> </div>
</div> </div>
</div> </div>

27
nginx.conf Normal file
View file

@ -0,0 +1,27 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/css application/javascript image/svg+xml;
gzip_min_length 256;
# Static assets: cache for a week (filenames are stable for this app).
location ~* \.(css|js|svg|png|ico)$ {
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Cache-Control "public, max-age=604800";
}
# HTML entry point: always revalidate so redeploys show up immediately.
location / {
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Cache-Control "no-cache" always;
try_files $uri $uri/ /index.html;
}
}

730
script.js
View file

@ -1,15 +1,112 @@
// Game State /* =========================================================================
ULTIMATE TIC-TAC-TOE game engine
- Per-player CAMPS: each player picks any avatar; their color/sound/FX
follow that avatar's camp (sparkle or shred). Players can mix freely.
- Global STAGE: sparkle / shred / mixed backdrop, fonts, winner vibe.
- Hot-seat two-player, accessible, sound + particle FX, persistence.
========================================================================= */
'use strict';
/* ---------- Avatar roster (each tagged with its camp) ---------- */
const AVATARS = [
// Sparkle camp
{ emoji: '⭐', camp: 'sparkle' },
{ emoji: '🦄', camp: 'sparkle' },
{ emoji: '🌸', camp: 'sparkle' },
{ emoji: '🎀', camp: 'sparkle' },
{ emoji: '🦋', camp: 'sparkle' },
{ emoji: '🌈', camp: 'sparkle' },
{ emoji: '🎨', camp: 'sparkle' },
{ emoji: '🍭', camp: 'sparkle' },
// Shred camp
{ emoji: '💀', camp: 'shred' },
{ emoji: '☠️', camp: 'shred' },
{ emoji: '🎸', camp: 'shred' },
{ emoji: '🤘', camp: 'shred' },
{ emoji: '🔥', camp: 'shred' },
{ emoji: '⚡', camp: 'shred' },
{ emoji: '🏴‍☠️', camp: 'shred' },
{ emoji: '🐉', camp: 'shred' },
];
const campOf = (emoji) => (AVATARS.find((a) => a.emoji === emoji) || AVATARS[0]).camp;
/* ---------- Per-camp flavor (color is in CSS; here: sound + particles) ---------- */
const CAMPS = {
sparkle: {
particles: ['✨', '💫', '⭐', '🌟'],
sound: { wave: 'sine', move: 587.33, gain: 0.26, powerChord: false }, // D5 chime
win: [523.25, 659.25, 783.99, 1046.5], // C E G C — bright arpeggio
},
shred: {
particles: ['🔥', '💥', '⚡', '💀'],
sound: { wave: 'sawtooth', move: 98.0, gain: 0.2, powerChord: true }, // G2 chug
win: [82.41, 110.0, 164.81, 220.0], // E A E A — power-chord climb
},
};
/* ---------- Stages (global vibe) ---------- */
const STAGES = {
sparkle: {
startLabel: 'Start Playing!',
winnerTitle: '🎉 Winner! 🎉',
winnerSub: 'A magical victory!',
drawText: "It's a Draw! 🤝",
celebrate: ['🎉', '✨', '🌟', '💫', '🎊', '⭐', '🌈', '🦄'],
},
shred: {
startLabel: 'Start Battle!',
winnerTitle: '🤘 WINNER 🤘',
winnerSub: 'TOTAL DOMINATION!',
drawText: 'STALEMATE! ⚔️',
celebrate: ['🔥', '💀', '⚡', '🤘', '💥', '🎸', '☠️', '🐉'],
},
mixed: {
startLabel: 'Start Battle!',
winnerTitle: '🏆 WINNER 🏆',
winnerSub: 'Champion of the arena!',
drawText: 'DRAW! 🤝',
celebrate: ['🎉', '🔥', '✨', '⚡', '🌟', '💥', '🌈', '💀', '🦄', '🤘'],
},
};
/* ---------- Persistence ---------- */
const STORE_KEY = 'ttt_state_v2';
function loadState() {
try { return JSON.parse(localStorage.getItem(STORE_KEY)) || {}; }
catch (_) { return {}; }
}
function saveState() {
try {
localStorage.setItem(STORE_KEY, JSON.stringify({
stage: currentStage,
p1Name: player1Input.value,
p2Name: player2Input.value,
p1Avatar: player1Avatar,
p2Avatar: player2Avatar,
scores: { p1: player1Score, p2: player2Score },
soundOn,
}));
} catch (_) { /* storage unavailable */ }
}
const persisted = loadState();
/* ---------- Game state ---------- */
let currentStage = STAGES[persisted.stage] ? persisted.stage : 'mixed';
let currentPlayer = 'X'; let currentPlayer = 'X';
let gameBoard = ['', '', '', '', '', '', '', '', '']; let gameBoard = ['', '', '', '', '', '', '', '', ''];
let gameActive = true; let gameActive = true;
let player1Name = ''; let player1Name = '';
let player2Name = ''; let player2Name = '';
let player1Avatar = '⭐'; let player1Avatar = AVATARS.some((a) => a.emoji === persisted.p1Avatar) ? persisted.p1Avatar : '⭐';
let player2Avatar = '🌸'; let player2Avatar = AVATARS.some((a) => a.emoji === persisted.p2Avatar) ? persisted.p2Avatar : '💀';
let player1Score = 0; let player1Score = persisted.scores ? persisted.scores.p1 : 0;
let player2Score = 0; let player2Score = persisted.scores ? persisted.scores.p2 : 0;
let soundOn = persisted.soundOn !== false;
// DOM Elements /* ---------- DOM ---------- */
const root = document.documentElement;
const nameScreen = document.getElementById('nameScreen'); const nameScreen = document.getElementById('nameScreen');
const gameScreen = document.getElementById('gameScreen'); const gameScreen = document.getElementById('gameScreen');
const winnerScreen = document.getElementById('winnerScreen'); const winnerScreen = document.getElementById('winnerScreen');
@ -17,112 +114,329 @@ const winnerScreen = document.getElementById('winnerScreen');
const player1Input = document.getElementById('player1Name'); const player1Input = document.getElementById('player1Name');
const player2Input = document.getElementById('player2Name'); const player2Input = document.getElementById('player2Name');
const startBtn = document.getElementById('startBtn'); const startBtn = document.getElementById('startBtn');
const startLabel = startBtn.querySelector('.btn-label');
const player1Display = document.getElementById('player1Display'); const player1Display = document.getElementById('player1Display');
const player2Display = document.getElementById('player2Display'); const player2Display = document.getElementById('player2Display');
const player1DisplayBox = document.querySelector('.player1-display');
const player2DisplayBox = document.querySelector('.player2-display');
const gameStatus = document.getElementById('gameStatus'); const gameStatus = document.getElementById('gameStatus');
const gameBoardElement = document.getElementById('gameBoard'); const gameBoardElement = document.getElementById('gameBoard');
const cells = document.querySelectorAll('[data-cell]'); const cells = Array.from(document.querySelectorAll('[data-cell]'));
const resetBtn = document.getElementById('resetBtn'); const resetBtn = document.getElementById('resetBtn');
const changeNamesBtn = document.getElementById('changeNamesBtn'); const changeNamesBtn = document.getElementById('changeNamesBtn');
const changeNamesBtn2 = document.getElementById('changeNamesBtn2'); const changeNamesBtn2 = document.getElementById('changeNamesBtn2');
const playAgainBtn = document.getElementById('playAgainBtn'); const playAgainBtn = document.getElementById('playAgainBtn');
const resetScoresBtn = document.getElementById('resetScoresBtn');
const muteBtn = document.getElementById('muteBtn');
const winnerName = document.getElementById('winnerName'); const winnerName = document.getElementById('winnerName');
const winnerAvatar = document.getElementById('winnerAvatar'); const winnerAvatar = document.getElementById('winnerAvatar');
const winnerTitle = document.getElementById('winnerTitle');
const winnerSub = document.getElementById('winnerSub');
const winnerCard = winnerScreen.querySelector('.winner-card');
const celebration = document.getElementById('celebration');
// Avatar elements
const player1AvatarOptions = document.getElementById('player1Avatars'); const player1AvatarOptions = document.getElementById('player1Avatars');
const player2AvatarOptions = document.getElementById('player2Avatars'); const player2AvatarOptions = document.getElementById('player2Avatars');
// Score elements
const score1Element = document.getElementById('score1'); const score1Element = document.getElementById('score1');
const score2Element = document.getElementById('score2'); const score2Element = document.getElementById('score2');
const score1Name = document.getElementById('score1Name'); const score1Name = document.getElementById('score1Name');
const score2Name = document.getElementById('score2Name'); const score2Name = document.getElementById('score2Name');
const score1Avatar = document.getElementById('score1Avatar'); const score1Avatar = document.getElementById('score1Avatar');
const score2Avatar = document.getElementById('score2Avatar'); const score2Avatar = document.getElementById('score2Avatar');
const score1Item = document.querySelector('.player1-score');
const score2Item = document.querySelector('.player2-score');
// Symbol elements
const player1SymbolElement = document.getElementById('player1Symbol'); const player1SymbolElement = document.getElementById('player1Symbol');
const player2SymbolElement = document.getElementById('player2Symbol'); const player2SymbolElement = document.getElementById('player2Symbol');
// Winning Combinations const stageOptions = Array.from(document.querySelectorAll('.stage-option'));
const winningCombinations = [ const winningCombinations = [
[0, 1, 2], [0, 1, 2], [3, 4, 5], [6, 7, 8],
[3, 4, 5], [0, 3, 6], [1, 4, 7], [2, 5, 8],
[6, 7, 8], [0, 4, 8], [2, 4, 6],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6]
]; ];
// Event Listeners /* ---------- Audio ---------- */
let audioCtx = null;
function getAudioCtx() {
if (!audioCtx) {
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
catch (_) { audioCtx = null; }
}
return audioCtx;
}
function tone(freq, startOffset, duration, wave, gain) {
const ctx = getAudioCtx();
if (!ctx) return;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.connect(g);
g.connect(ctx.destination);
osc.type = wave;
osc.frequency.value = freq;
const t0 = ctx.currentTime + startOffset;
const t1 = t0 + duration;
g.gain.setValueAtTime(gain, t0);
g.gain.exponentialRampToValueAtTime(0.001, t1);
osc.start(t0);
osc.stop(t1);
}
function playMoveSound(camp) {
if (!soundOn) return;
const s = CAMPS[camp].sound;
tone(s.move, 0, s.powerChord ? 0.18 : 0.1, s.wave, s.gain);
if (s.powerChord) tone(s.move * 1.5, 0, 0.18, s.wave, s.gain * 0.6); // fifth
}
function playWinSound(camp) {
if (!soundOn) return;
const c = CAMPS[camp];
const step = camp === 'shred' ? 0.12 : 0.15;
c.win.forEach((freq, i) => {
tone(freq, i * step, 0.3, c.sound.wave, c.sound.gain);
if (c.sound.powerChord) tone(freq * 1.5, i * step, 0.3, c.sound.wave, c.sound.gain * 0.5);
});
}
function playDrawSound() {
if (!soundOn) return;
[440, 392, 349.23].forEach((f, i) => tone(f, i * 0.16, 0.25, 'sine', 0.16));
}
/* ---------- Setup rendering ---------- */
function renderAvatarGrid(container, selectedAvatar) {
container.innerHTML = '';
AVATARS.forEach(({ emoji, camp }) => {
const opt = document.createElement('button');
opt.type = 'button';
const selected = emoji === selectedAvatar;
opt.className = `avatar-option camp-${camp}${selected ? ' selected' : ''}`;
opt.dataset.avatar = emoji;
opt.setAttribute('role', 'radio');
opt.setAttribute('aria-checked', selected ? 'true' : 'false');
opt.setAttribute('aria-label', `Avatar ${emoji} (${camp})`);
opt.textContent = emoji;
container.appendChild(opt);
});
}
function applyStage(stage, { persist = true } = {}) {
currentStage = stage;
root.setAttribute('data-stage', stage);
stageOptions.forEach((opt) =>
opt.setAttribute('aria-checked', opt.dataset.stage === stage ? 'true' : 'false'));
startLabel.textContent = STAGES[stage].startLabel;
if (persist) saveState();
}
/* ---------- Setup interactions ---------- */
stageOptions.forEach((opt) => {
opt.addEventListener('click', () => applyStage(opt.dataset.stage));
});
function wireAvatarGroup(container, isP1) {
container.addEventListener('click', (e) => {
const opt = e.target.closest('.avatar-option');
if (!opt) return;
const picked = opt.dataset.avatar;
const other = isP1 ? player2Avatar : player1Avatar;
if (picked === other) {
// Both players can't share the exact same avatar — nudge, don't select.
opt.animate(
[{ transform: 'translateX(0)' }, { transform: 'translateX(-6px)' },
{ transform: 'translateX(6px)' }, { transform: 'translateX(0)' }],
{ duration: 250 }
);
return;
}
container.querySelectorAll('.avatar-option').forEach((o) => {
o.classList.remove('selected');
o.setAttribute('aria-checked', 'false');
});
opt.classList.add('selected');
opt.setAttribute('aria-checked', 'true');
if (isP1) player1Avatar = picked; else player2Avatar = picked;
saveState();
});
}
wireAvatarGroup(player1AvatarOptions, true);
wireAvatarGroup(player2AvatarOptions, false);
startBtn.addEventListener('click', startGame); startBtn.addEventListener('click', startGame);
resetBtn.addEventListener('click', resetGame); resetBtn.addEventListener('click', resetGame);
changeNamesBtn.addEventListener('click', showNameScreen);
changeNamesBtn2.addEventListener('click', showNameScreen);
playAgainBtn.addEventListener('click', resetGame); playAgainBtn.addEventListener('click', resetGame);
changeNamesBtn.addEventListener('click', () => showScreen(nameScreen));
changeNamesBtn2.addEventListener('click', () => showScreen(nameScreen));
resetScoresBtn.addEventListener('click', () => {
player1Score = 0; player2Score = 0;
score1Element.textContent = '0';
score2Element.textContent = '0';
saveState();
});
cells.forEach(cell => { player1Input.addEventListener('keypress', (e) => { if (e.key === 'Enter') player2Input.focus(); });
player2Input.addEventListener('keypress', (e) => { if (e.key === 'Enter') startGame(); });
player1Input.addEventListener('input', saveState);
player2Input.addEventListener('input', saveState);
muteBtn.addEventListener('click', () => {
soundOn = !soundOn;
muteBtn.setAttribute('aria-pressed', soundOn ? 'false' : 'true');
if (soundOn) { getAudioCtx(); playMoveSound(campOf(player1Avatar)); }
saveState();
});
/* ---------- Board interactions (mouse + keyboard) ---------- */
cells.forEach((cell) => {
cell.addEventListener('click', handleCellClick); cell.addEventListener('click', handleCellClick);
cell.addEventListener('keydown', handleCellKeydown);
}); });
function handleCellKeydown(e) {
// Avatar selection listeners const idx = Number(e.currentTarget.dataset.index);
player1AvatarOptions.addEventListener('click', (e) => { let target = null;
if (e.target.classList.contains('avatar-option')) { switch (e.key) {
player1AvatarOptions.querySelectorAll('.avatar-option').forEach(opt => { case 'ArrowRight': target = idx % 3 === 2 ? idx - 2 : idx + 1; break;
opt.classList.remove('selected'); case 'ArrowLeft': target = idx % 3 === 0 ? idx + 2 : idx - 1; break;
}); case 'ArrowDown': target = (idx + 3) % 9; break;
e.target.classList.add('selected'); case 'ArrowUp': target = (idx + 6) % 9; break;
player1Avatar = e.target.dataset.avatar; default: return;
} }
}); e.preventDefault();
cells[target].focus();
player2AvatarOptions.addEventListener('click', (e) => {
if (e.target.classList.contains('avatar-option')) {
player2AvatarOptions.querySelectorAll('.avatar-option').forEach(opt => {
opt.classList.remove('selected');
});
e.target.classList.add('selected');
player2Avatar = e.target.dataset.avatar;
} }
}); function handleCellClick(e) {
const cell = e.currentTarget;
// Allow Enter key to start game const index = Number(cell.dataset.index);
player1Input.addEventListener('keypress', (e) => { if (gameBoard[index] !== '' || !gameActive) return;
if (e.key === 'Enter') { getAudioCtx();
player2Input.focus(); makeMove(cell, index);
checkResult();
} }
});
player2Input.addEventListener('keypress', (e) => { function activeAvatar() { return currentPlayer === 'X' ? player1Avatar : player2Avatar; }
if (e.key === 'Enter') { function activeName() { return currentPlayer === 'X' ? player1Name : player2Name; }
startGame();
function makeMove(cell, index) {
gameBoard[index] = currentPlayer;
const avatar = activeAvatar();
const camp = campOf(avatar);
cell.textContent = avatar;
cell.classList.add('filled', `camp-${camp}`);
cell.setAttribute('aria-label', `Cell ${index + 1}, ${activeName()}`);
createParticleBurst(cell, camp, avatar);
playMoveSound(camp);
if (camp === 'shred') {
gameBoardElement.classList.remove('shake');
void gameBoardElement.offsetWidth;
gameBoardElement.classList.add('shake');
} }
}
function checkResult() {
let winningCombo = null;
for (const combo of winningCombinations) {
const [a, b, c] = combo;
if (gameBoard[a] && gameBoard[a] === gameBoard[b] && gameBoard[a] === gameBoard[c]) {
winningCombo = combo;
break;
}
}
if (winningCombo) {
gameActive = false;
const winnerIsP1 = currentPlayer === 'X';
const camp = campOf(winnerIsP1 ? player1Avatar : player2Avatar);
winningCombo.forEach((i) => {
cells[i].classList.remove('camp-sparkle', 'camp-shred');
cells[i].classList.add('winning', `camp-${camp}`);
}); });
// Functions const name = winnerIsP1 ? player1Name : player2Name;
const avatar = winnerIsP1 ? player1Avatar : player2Avatar;
if (winnerIsP1) { player1Score++; bumpScore(score1Element, player1Score); }
else { player2Score++; bumpScore(score2Element, player2Score); }
saveState();
gameStatus.textContent = `${name} wins!`;
setTimeout(() => {
const stage = STAGES[currentStage];
winnerTitle.textContent = stage.winnerTitle;
winnerName.textContent = name;
winnerAvatar.textContent = avatar;
winnerSub.textContent = stage.winnerSub;
winnerCard.classList.remove('camp-sparkle', 'camp-shred');
winnerCard.classList.add(`camp-${camp}`);
renderCelebration(camp);
showScreen(winnerScreen);
playWinSound(camp);
}, 1100);
return;
}
if (!gameBoard.includes('')) {
gameActive = false;
gameStatus.textContent = STAGES[currentStage].drawText;
gameStatus.classList.add('draw');
playDrawSound();
return;
}
currentPlayer = currentPlayer === 'X' ? 'O' : 'X';
updateStatus();
updatePlayerHighlight();
}
function bumpScore(el, value) {
el.textContent = value;
el.classList.add('score-update');
setTimeout(() => el.classList.remove('score-update'), 500);
}
function updateStatus() {
gameStatus.classList.remove('draw');
gameStatus.textContent = `${activeName()}'s Turn`;
}
function updatePlayerHighlight() {
player1DisplayBox.classList.toggle('active', currentPlayer === 'X');
player2DisplayBox.classList.toggle('active', currentPlayer === 'O');
}
/* ---------- Apply per-player camps to game UI ---------- */
function applyPlayerCamps() {
const c1 = campOf(player1Avatar);
const c2 = campOf(player2Avatar);
player1DisplayBox.classList.remove('camp-sparkle', 'camp-shred');
player2DisplayBox.classList.remove('camp-sparkle', 'camp-shred');
score1Item.classList.remove('camp-sparkle', 'camp-shred');
score2Item.classList.remove('camp-sparkle', 'camp-shred');
player1DisplayBox.classList.add(`camp-${c1}`);
player2DisplayBox.classList.add(`camp-${c2}`);
score1Item.classList.add(`camp-${c1}`);
score2Item.classList.add(`camp-${c2}`);
}
/* ---------- Screens ---------- */
function showScreen(screen) {
[nameScreen, gameScreen, winnerScreen].forEach((s) => s.classList.remove('active'));
screen.classList.add('active');
window.scrollTo({ top: 0, behavior: 'smooth' });
}
/* ---------- Lifecycle ---------- */
function startGame() { function startGame() {
// Get player names or use defaults
player1Name = player1Input.value.trim() || 'Player 1'; player1Name = player1Input.value.trim() || 'Player 1';
player2Name = player2Input.value.trim() || 'Player 2'; player2Name = player2Input.value.trim() || 'Player 2';
// Randomly choose starting player
currentPlayer = Math.random() < 0.5 ? 'X' : 'O';
// Update displays
player1Display.textContent = player1Name; player1Display.textContent = player1Name;
player2Display.textContent = player2Name; player2Display.textContent = player2Name;
// Update symbols with avatars
player1SymbolElement.textContent = player1Avatar; player1SymbolElement.textContent = player1Avatar;
player2SymbolElement.textContent = player2Avatar; player2SymbolElement.textContent = player2Avatar;
// Update score board
score1Name.textContent = player1Name; score1Name.textContent = player1Name;
score2Name.textContent = player2Name; score2Name.textContent = player2Name;
score1Avatar.textContent = player1Avatar; score1Avatar.textContent = player1Avatar;
@ -130,257 +444,81 @@ function startGame() {
score1Element.textContent = player1Score; score1Element.textContent = player1Score;
score2Element.textContent = player2Score; score2Element.textContent = player2Score;
// Switch to game screen applyPlayerCamps();
showGameScreen(); saveState();
updateStatus(); resetBoardState();
updatePlayerHighlight(); showScreen(gameScreen);
} }
function showNameScreen() { function resetBoardState() {
nameScreen.classList.add('active');
gameScreen.classList.remove('active');
winnerScreen.classList.remove('active');
}
function showGameScreen() {
nameScreen.classList.remove('active');
gameScreen.classList.add('active');
winnerScreen.classList.remove('active');
}
function showWinnerScreen() {
nameScreen.classList.remove('active');
gameScreen.classList.remove('active');
winnerScreen.classList.add('active');
}
function handleCellClick(e) {
const cell = e.target;
const cellIndex = Array.from(cells).indexOf(cell);
// Check if cell is already filled or game is not active
if (gameBoard[cellIndex] !== '' || !gameActive) {
return;
}
// Make move
makeMove(cell, cellIndex);
// Check for win or draw
checkResult();
}
function makeMove(cell, index) {
gameBoard[index] = currentPlayer;
// Use avatar instead of X/O
const avatar = currentPlayer === 'X' ? player1Avatar : player2Avatar;
cell.textContent = avatar;
cell.classList.add(currentPlayer.toLowerCase());
// Create particle burst effect
createParticleBurst(cell, avatar);
// Play move sound
playMoveSound(currentPlayer);
}
function checkResult() {
let roundWon = false;
let winningCombo = null;
// Check all winning combinations
for (let i = 0; i < winningCombinations.length; i++) {
const [a, b, c] = winningCombinations[i];
if (gameBoard[a] && gameBoard[a] === gameBoard[b] && gameBoard[a] === gameBoard[c]) {
roundWon = true;
winningCombo = [a, b, c];
break;
}
}
if (roundWon) {
// Highlight winning cells
winningCombo.forEach(index => {
cells[index].classList.add('winning');
});
// Determine winner
const winner = currentPlayer === 'X' ? player1Name : player2Name;
const winnerAvatarSymbol = currentPlayer === 'X' ? player1Avatar : player2Avatar;
gameActive = false;
// Update score
if (currentPlayer === 'X') {
player1Score++;
score1Element.textContent = player1Score;
score1Element.classList.add('score-update');
setTimeout(() => score1Element.classList.remove('score-update'), 500);
} else {
player2Score++;
score2Element.textContent = player2Score;
score2Element.classList.add('score-update');
setTimeout(() => score2Element.classList.remove('score-update'), 500);
}
// Show winner screen after animation
setTimeout(() => {
winnerName.textContent = winner;
winnerAvatar.textContent = winnerAvatarSymbol;
showWinnerScreen();
playWinSound();
}, 1000);
return;
}
// Check for draw
if (!gameBoard.includes('')) {
gameActive = false;
gameStatus.textContent = "It's a Draw! 🤝";
setTimeout(() => {
resetGame();
}, 2000);
return;
}
// Switch player
currentPlayer = currentPlayer === 'X' ? 'O' : 'X';
updateStatus();
updatePlayerHighlight();
}
function updateStatus() {
const currentPlayerName = currentPlayer === 'X' ? player1Name : player2Name;
gameStatus.textContent = `${currentPlayerName}'s Turn (${currentPlayer})`;
}
function updatePlayerHighlight() {
const player1Div = document.querySelector('.player1-display');
const player2Div = document.querySelector('.player2-display');
if (currentPlayer === 'X') {
player1Div.classList.add('active');
player2Div.classList.remove('active');
} else {
player1Div.classList.remove('active');
player2Div.classList.add('active');
}
}
function resetGame() {
// Reset game state
currentPlayer = Math.random() < 0.5 ? 'X' : 'O'; currentPlayer = Math.random() < 0.5 ? 'X' : 'O';
gameBoard = ['', '', '', '', '', '', '', '', '']; gameBoard = ['', '', '', '', '', '', '', '', ''];
gameActive = true; gameActive = true;
cells.forEach((cell, i) => {
// Clear cells
cells.forEach(cell => {
cell.textContent = ''; cell.textContent = '';
cell.classList.remove('x', 'o', 'winning'); cell.classList.remove('winning', 'filled', 'camp-sparkle', 'camp-shred');
cell.setAttribute('aria-label', `Cell ${i + 1}, empty`);
}); });
gameStatus.classList.remove('draw');
// Update display
updateStatus(); updateStatus();
updatePlayerHighlight(); updatePlayerHighlight();
// Show game screen
showGameScreen();
} }
function playWinSound() { function resetGame() {
// Create a simple celebratory sound using Web Audio API resetBoardState();
try { showScreen(gameScreen);
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
// Play a series of ascending notes
const notes = [523.25, 587.33, 659.25, 783.99]; // C, D, E, G
notes.forEach((frequency, index) => {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = frequency;
oscillator.type = 'sine';
const startTime = audioContext.currentTime + (index * 0.15);
const stopTime = startTime + 0.3;
gainNode.gain.setValueAtTime(0.3, startTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, stopTime);
oscillator.start(startTime);
oscillator.stop(stopTime);
});
} catch (error) {
console.log('Audio not supported');
}
} }
function playMoveSound(player) { /* ---------- Effects ---------- */
// Create different sound for each player function createParticleBurst(cell, camp, emoji) {
try {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
// Different frequencies for different players
oscillator.frequency.value = player === 'X' ? 523.25 : 659.25; // C or E
oscillator.type = 'sine';
const startTime = audioContext.currentTime;
const stopTime = startTime + 0.1;
gainNode.gain.setValueAtTime(0.2, startTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, stopTime);
oscillator.start(startTime);
oscillator.stop(stopTime);
} catch (error) {
console.log('Audio not supported');
}
}
function createParticleBurst(cell, emoji) {
// Get cell position
const rect = cell.getBoundingClientRect(); const rect = cell.getBoundingClientRect();
const centerX = rect.left + rect.width / 2; const cx = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2; const cy = rect.top + rect.height / 2;
const set = CAMPS[camp].particles.concat(emoji);
// Create particles const count = 8;
const particleCount = 8; for (let i = 0; i < count; i++) {
const particles = ['✨', '💫', '⭐', '🌟', emoji]; const p = document.createElement('div');
p.className = 'particle';
for (let i = 0; i < particleCount; i++) { p.textContent = set[Math.floor(Math.random() * set.length)];
const particle = document.createElement('div'); const angle = (Math.PI * 2 * i) / count;
particle.className = 'particle'; const dist = 80 + Math.random() * 40;
particle.textContent = particles[Math.floor(Math.random() * particles.length)]; p.style.left = cx + 'px';
p.style.top = cy + 'px';
// Random direction p.style.setProperty('--tx', Math.cos(angle) * dist + 'px');
const angle = (Math.PI * 2 * i) / particleCount; p.style.setProperty('--ty', Math.sin(angle) * dist + 'px');
const distance = 80 + Math.random() * 40; document.body.appendChild(p);
const tx = Math.cos(angle) * distance; setTimeout(() => p.remove(), 800);
const ty = Math.sin(angle) * distance;
particle.style.left = centerX + 'px';
particle.style.top = centerY + 'px';
particle.style.setProperty('--tx', tx + 'px');
particle.style.setProperty('--ty', ty + 'px');
document.body.appendChild(particle);
// Remove particle after animation
setTimeout(() => {
particle.remove();
}, 800);
} }
} }
// Initialize player highlight function renderCelebration(winnerCamp) {
celebration.innerHTML = '';
// Blend the stage's celebration set with the winner's camp particles.
const set = STAGES[currentStage].celebrate.concat(CAMPS[winnerCamp].particles);
const count = 30;
for (let i = 0; i < count; i++) {
const piece = document.createElement('span');
piece.className = 'confetti-piece';
piece.textContent = set[Math.floor(Math.random() * set.length)];
piece.style.left = Math.random() * 100 + '%';
piece.style.animationDuration = 2.5 + Math.random() * 2.5 + 's';
piece.style.animationDelay = Math.random() * 2 + 's';
piece.style.fontSize = 1.2 + Math.random() * 1.4 + 'em';
celebration.appendChild(piece);
}
}
/* ---------- Init ---------- */
function init() {
if (persisted.p1Name) player1Input.value = persisted.p1Name;
if (persisted.p2Name) player2Input.value = persisted.p2Name;
muteBtn.setAttribute('aria-pressed', soundOn ? 'false' : 'true');
score1Element.textContent = player1Score;
score2Element.textContent = player2Score;
renderAvatarGrid(player1AvatarOptions, player1Avatar);
renderAvatarGrid(player2AvatarOptions, player2Avatar);
applyStage(currentStage, { persist: false });
applyPlayerCamps();
updatePlayerHighlight(); updatePlayerHighlight();
}
init();

1163
style.css

File diff suppressed because it is too large Load diff