Undercover — Project Summary

A two-device Codenames-style word game built as a vanilla-JS PWA.
Board on a tablet · Spymaster map on a phone · no server, no accounts, no build step.


How the Game Is Played

Undercover is a two-team word guessing game inspired by Codenames, played across two devices.

Devices & Roles

| Device | Screen | Who sees it | |—|—|—| | Board (tablet) | Grid of face-down word cards | All players | | Spymaster (phone) | Same grid with colors revealed | Spymasters only |

Setup

  1. One device opens the app and picks Board mode; the other picks Spymaster mode.
  2. They pair over WebRTC using a two-scan QR flow (Board generates a QR → Spy scans → Spy shows answer QR → Board scans).
  3. The Board rolls a new game. The Spymaster receives the full color map instantly.

Objective

Each team has a set of secret words on the board (red or blue cards). Spymasters look at their phone map and give one-word clues. Players tap cards on the tablet to reveal them. First team to reveal all their words wins. If anyone reveals the single black death card, that team instantly loses.

Gameplay Loop

Board Sizes

| Mode | Grid | Starter | Other team | Civilian (yellow) | Death (black) | |—|—|—|—|—|—| | Classic | 5×5 | 9 | 8 | 7 | 1 | | Quick | 4×4 | 6 | 5 | 4 | 1 |

The starting team is chosen randomly each game. They always get one extra card (so 9 vs 8 in 5×5).


Features

Languages

Sound

Grid Sizes

Turn Timer

Undo

Card Swap

WebRTC Peer-to-Peer

Word Pool & Distribution

Card Color Layout Constraint

Background Textures

Display Fonts

Pass (Turn Handoff)

New Map (Spymaster)

Settings Modal

Three tabs:

Offline / PWA


Codebase

Stack

No-build, no bundler, no framework. Plain <script> and <link> tags. Runs directly in the browser.

File Structure

undercover/
├── index.html          # App shell, all modal markup, asset loading, SW registration
├── sw.js               # Service worker — cache name undercover-vNNN
├── CLAUDE.md           # Project rules for AI assistant
├── js/
│   ├── constants.js    # LANGS, KEYS (localStorage), MSG (WebRTC), BG_BASE
│   ├── state.js        # State singleton — all runtime game state
│   ├── storage.js      # Store — load/save State to localStorage
│   ├── i18n.js         # I18n — strings (tr + en), t(), applyAll()
│   ├── utils.js        # shuffle(), colorLayoutOk(), squircleAll(), etc.
│   ├── game.js         # Game — rollNewBoard(), revealCard(), undo(), swapCard()
│   ├── peer.js         # Peer — WebRTC DataChannel, QR pairing state machine
│   ├── qr.js           # QR — qrcode.min.js wrapper + jsQR camera scan
│   ├── sound.js        # Sound — Web Audio API playback
│   ├── timer.js        # Timer — countdown, pause/resume, head-start, idle detection
│   ├── ui.js           # UI — all DOM rendering (grid, cards, status, modals)
│   ├── app.js          # App — game flow, modal orchestration, event handlers
│   ├── words.common.js # SEED_WORDS_COMMON — language-independent proper nouns
│   ├── words.tr.js     # SEED_WORDS_TR — Turkish word groups
│   └── words.en.js     # SEED_WORDS_EN — English word groups
├── css/
│   ├── base.css        # CSS variables, body/html reset, font stack
│   ├── elements.css    # Reusable components: iconbtn, toggle, timer, score box, banner
│   ├── screens.css     # Screen layouts: mode select, topbar, board area, status strip
│   ├── board.css       # Grid, card SVG styles, flip/enter/exit animations
│   └── modal.css       # Modal backdrop, modal box, all modal-specific styles
├── lib/
│   ├── tasktimer.min.js  # Countdown timer library
│   ├── qrcode.min.js     # QR code generator
│   └── jsQR.min.js       # QR code reader (camera)
├── audio/              # Web Audio source files (mp3)
│   └── *.mp3
└── img/
    └── icon.svg

Key Globals

All modules are plain IIFE-style objects/functions on the window scope:

Global Purpose
State Single source of truth for all runtime game state
Store Persists State fields to localStorage
I18n Translation strings + t(key) + applyAll()
UI All DOM rendering — grid, cards, status, scores
Timer Turn countdown, pause/resume, idle detection
Sound Web Audio API — plays all sound effects
App Game flow, modal open/close, event wiring
Peer WebRTC DataChannel management
QR QR generate + camera scan via jsQR
Game Pure game logic — board roll, reveal, undo, swap
Words Word pool access — Words.pool(), Words.groups()

Versioning

Two values must stay in sync on every commit:

Patch bumps (v3.6.N) are autonomous on every commit. Minor/major require explicit approval.

i18n System

Service Worker

WebRTC Message Types (constants.js MSG)

board_state, request_state, request_reveal, reveal, reveal_done, new_game, request_new_game, undo_request, ping, pong, timer_tick

Timer Architecture (timer.js)

Key internal flags:

pauseForModal() always sets _modalPaused = true before any early-return guard, so settings changes to duration don’t accidentally start the timer.


UI & Styling

Design Language

Superellipse / Squircle Borders

Every button, chip, and control uses a custom SVG clip-path for a squircle shape (superellipse with n=2.8 — between a circle and a rectangle, same formula as Apple’s icon corners).

Implementation: squircleAll() in utils.js injects a hidden <svg> as the first child of every .iconbtn, .mode-btn, etc. The stroke color reads from the element’s --sq-stroke CSS variable.

Critical rule: never call element.textContent = '...' on a squircle button — it destroys the SVG child. Always update a <span> inside the button instead.

squircleAll() runs on: initial page load, every window resize, and every .modal-bg activation (observed via MutationObserver in app.js).

Color Palette (CSS variables)

--bg:          #1a1a1a   /* near-black base */
--bg-elev:     #232323   /* slightly elevated surface */
--bg-card:     #2d2d2d   /* card/input backgrounds */
--ink:         #ebe5d8   /* primary text — warm off-white */
--ink-dim:     #8a8478   /* secondary text */
--accent:      #d4a574   /* amber/gold — CTAs, highlights */
--line:        #3a3a3a   /* dividers and borders */
--red:         #c84545   /* red team */
--blue:        #3a7bc8   /* blue team */
--yellow:      #c4920a   /* civilian cards */
--black:       #1a1a1a   /* death card */
--red-soft:    #5a2a2a   /* red team backgrounds */
--blue-soft:   #2a3f5a   /* blue team backgrounds */
--yellow-soft: #5a4a2a   /* yellow backgrounds */

Typography

Board Grid

Card Rendering

Cards are rendered as inline SVG elements (not HTML divs). Each card SVG has:

Unrevealed card: warm tan (#b8955a body, #f0ece2 word bg, #1a1208 text).
Revealed cards use color-specific fills (e.g. red: var(--red) body, #7a1a1a word bg).

The Spymaster grid uses the same SVG structure but shows all card colors upfront. Revealed cards are faded to 35% opacity.

Card Animations

All card animations use CSS @keyframes with animation-fill-mode: forwards or both:

Class Animation Trigger
.flipping card-flip-out 200ms ease-in Before reveal (hide)
.flip-in card-flip-in 200ms ease-out After reveal (show with color)
.unflipping card-unflip-out 200ms ease-in Before undo (hide)
.unflip-in card-unflip-in 200ms ease-out After undo (show unrevealed)
.anim-exit card-exit 280ms ease-in New game — cards exit upward
.anim-enter card-enter 280ms ease-out New game — cards enter from below
.waiting pulse opacity loop Pending reveal on Spymaster

Cards stagger their animationDelay by 25ms per index on enter (cascading waterfall effect). After the stagger animation completes, an imperceptible opacity nudge (0.9999 → '') is applied to the grid to force Safari to repaint SVG compositing layers (prevents missing card bug).

Topbar Layout

Board screen: single-row CSS grid 1fr auto [auto] 1fr (4th column auto only visible when timer is enabled):

Spymaster screen: two-row topbar (row 1: title/conn/buttons; row 2: turn indicator + pass button).

Score Box

Located in the center of the board topbar. Style: rounded pill (10px border-radius, amber border, blurred backdrop).

  RED    7/9   |   5/8   BLUE

Format: <score-val>/<score-tot> — values are font-weight: 700, the / is white at 40% opacity, totals are respective team colors at 60% opacity. Rendered in B612 Mono at font-size: 30px.

Status Strip (Board)

Thin bar below the topbar showing: score (left), turn indicator pill (center), Pass button (right). Border-bottom separates it from the grid.

Turn indicator: colored pill (--red-soft/--blue-soft background, 30px height, min-width 150px, Bungee font). Animates with turn-pop scale bounce on turn change.

Responsiveness

Modals

Backgrounds

Background textures are loaded as CSS background-image on <html> and <body>, covering the full viewport (background-size: 100vw 100vh). The dark base color (#1a1a1a) shows while the image loads or if offline.