# DON'T BLINK — Build Guide

> **How to use this file:** Drop it into an empty folder, open it in
> Claude Code, and say *"Read this file and build everything."*
> Requires: Node 18+, Netlify CLI, a webcam.
> Domain: dontblink.lol

---

# Part 1: The Big Picture

## What This App Does

A real-time multiplayer "stare down" game that runs entirely in the browser.
Two players face each other via webcam — first one to blink, look away, or
leave the frame loses. WebRTC handles peer-to-peer video and audio (chat
roulette style). Blink detection runs locally in each browser using
face-api.js. An AI opponent mode lets you practice solo.

Mobile-first. Design for phones as the primary platform, desktop as secondary.

```
 USER EXPERIENCE
 ═══════════════════════════════════════════════════════════════

 Landing Page (mobile-first, dark glitch samurai aesthetic):
 ┌──────────────────────────────────────────────────────────┐
 │  ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  │
 │                                                          │
 │              👁  DON'T BLINK                             │
 │         (glitched title with chromatic split)             │
 │          "First to blink loses. Ready?"                  │
 │                                                          │
 │     ┌──────────────────────────────────────┐             │
 │     │  Username  (glowing underline input) │             │
 │     │  Email     (glowing underline input) │             │
 │     └──────────────────────────────────────┘             │
 │                                                          │
 │     ╔══════════════════╗  ╔═══════════════════╗         │
 │     ║  🤖 FACE THE AI  ║  ║  ⚔️ CHALLENGE     ║         │
 │     ║                  ║  ║   HUMAN           ║         │
 │     ╚══════════════════╝  ╚═══════════════════╝         │
 │                                                          │
 │     Human sub-options (slide open on click):             │
 │     ┌─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┐            │
 │     ╠══ CREATE ARENA ══╣  (generates code)  │            │
 │     ╠══ FIND OPPONENT ═╣  (random match)    │            │
 │     ╠══ ENTER CODE ════╣  [______]          │            │
 │     └─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┘            │
 │                                                          │
 │     🏆 HALL OF FAME                                      │
 │     ┌──────────────────────────────────────────┐        │
 │     │  ⚡ 1. xXblinkerXx      47 wins          │        │
 │     │  ⚡ 2. steelgaze         31 wins          │        │
 │     │  ⚡ 3. noflinchjoe       28 wins          │        │
 │     └──────────────────────────────────────────┘        │
 │  ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  │
 └──────────────────────────────────────────────────────────┘

 Game Screen (vs Human — mobile: stacked, desktop: side by side):
 ┌──────────────────────────────────────────────────────────┐
 │  ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐   │
 │  │  scan line overlay across both video feeds       │   │
 │  └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘   │
 │                                                          │
 │   ┌───────────────────┐   ┌───────────────────┐         │
 │   │                   │   │                   │         │
 │   │   YOUR WEBCAM     │   │  OPPONENT WEBCAM  │         │
 │   │  (cyan border     │   │  (magenta border  │         │
 │   │   glow)           │   │   glow)           │         │
 │   │                   │   │                   │         │
 │   │   "steelgaze"     │   │   "xXblinkerXx"  │         │
 │   └───────────────────┘   └───────────────────┘         │
 │                                                          │
 │              ⏱  00:42  (monospace, glowing)              │
 │                                                          │
 │       ● STARING ● (pulsing neon dot + text)             │
 │                                                          │
 └──────────────────────────────────────────────────────────┘

 Game Screen (vs AI):
 ┌──────────────────────────────────────────────────────────┐
 │   ┌───────────────────┐   ┌───────────────────┐         │
 │   │                   │   │                   │         │
 │   │   YOUR WEBCAM     │   │  ANIMATED SVG     │         │
 │   │                   │   │  FACE             │         │
 │   │                   │   │  (glitch-styled   │         │
 │   │                   │   │   robotic eye)    │         │
 │   │   "steelgaze"     │   │   "🤖 MACHINE"   │         │
 │   └───────────────────┘   └───────────────────┘         │
 │                                                          │
 │              ⏱  00:18                                   │
 └──────────────────────────────────────────────────────────┘

 Result Screen:
 ┌──────────────────────────────────────────────────────────┐
 │                                                          │
 │        ░▒▓ YOU BLINKED ▓▒░ (glitch text, red)           │
 │            or                                            │
 │        ⚡ VICTORY ⚡ (glitch text, cyan)                 │
 │                                                          │
 │         Time survived: 01:24                             │
 │         Reason: Blink detected                           │
 │                                                          │
 │     ╔══════════════╗  ╔═══════════════════╗             │
 │     ║  REMATCH     ║  ║  BACK TO LOBBY    ║             │
 │     ╚══════════════╝  ╚═══════════════════╝             │
 └──────────────────────────────────────────────────────────┘
```

## System Architecture

```
┌──────────────────────────────────────────────────────────────┐
│                    BROWSER (Player A)                          │
│                                                               │
│   static/index.html — shell + CSS                             │
│   static/js/*.js — modular vanilla JS (no build step)         │
│                                                               │
│   ┌──────────────┐  ┌──────────────┐  ┌────────────────┐    │
│   │ Lobby /      │  │ Game Screen  │  │ Result Screen  │    │
│   │ Auth         │  │              │  │                │    │
│   │              │  │ Local webcam │  │ Win/loss       │    │
│   │ Login        │  │ Remote video │  │ Rematch btn    │    │
│   │ Room mgmt   │  │ Blink detect │  │ Leaderboard    │    │
│   │ Leaderboard │  │ Timer        │  │                │    │
│   └──────┬───────┘  └──────┬───────┘  └───────┬────────┘    │
│          │                 │                   │              │
│   ┌──────┴─────────────────┴───────────────────┴───────────┐ │
│   │                   face-api.js                           │ │
│   │   Loaded from CDN. Runs locally. Never sends video.     │ │
│   │   Models: tinyFaceDetector + faceLandmark68TinyNet      │ │
│   │   Detects: blink (EAR), gaze direction, face presence   │ │
│   └─────────────────────────────────────────────────────────┘ │
│                                                               │
│   ┌─────────────────────────────────────────────────────────┐ │
│   │                   WebRTC (peer-to-peer)                  │ │
│   │   Video + Audio stream directly to opponent              │ │
│   │   Signaling goes through Netlify functions               │ │
│   │   ICE candidates via polling (no WebSocket needed)       │ │
│   └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
          │                           ▲
          │  Netlify Functions        │  Netlify Functions
          │  (signaling + state)      │  (signaling + state)
          ▼                           │
┌──────────────────────────────────────────────────────────────┐
│                    NETLIFY BACKEND                             │
│                                                               │
│  Functions (serverless):                                      │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐   │
│  │ auth.mjs     │  │ room.mjs     │  │ leaderboard.mjs  │   │
│  │              │  │              │  │                   │   │
│  │ Login/       │  │ Create room  │  │ Get top scores    │   │
│  │ register     │  │ Join room    │  │ Record win        │   │
│  │ (email +     │  │ Signal relay │  │                   │   │
│  │  username)   │  │ ICE relay    │  │                   │   │
│  │              │  │ Match queue  │  │                   │   │
│  │              │  │ Game state   │  │                   │   │
│  └──────────────┘  └──────────────┘  └──────────────────┘   │
│                                                               │
│  Blobs (storage):                                             │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐   │
│  │ "users"      │  │ "rooms"      │  │ "leaderboard"    │   │
│  │              │  │              │  │                   │   │
│  │ {email,      │  │ {players,    │  │ {username,        │   │
│  │  username,   │  │  signals,    │  │  wins, losses,    │   │
│  │  wins,       │  │  ice,        │  │  lastPlayed}      │   │
│  │  losses}     │  │  state,      │  │                   │   │
│  │              │  │  matchQueue} │  │  sorted by wins   │   │
│  └──────────────┘  └──────────────┘  └──────────────────┘   │
└──────────────────────────────────────────────────────────────┘
```

## What It Costs

| Item | Cost | Notes |
|------|------|-------|
| Netlify free tier | **$0** | 125k function invocations/mo, 1 GB blobs |
| face-api.js | **$0** | Open source, loaded from CDN |
| Domain | ~$7 | dontblink.lol from Namecheap |
| WebRTC | **$0** | Peer-to-peer, no TURN server needed for most connections |

## What You Need

| Requirement | Notes |
|-------------|-------|
| Node 18+ | For Netlify CLI and local dev |
| Netlify account | Free tier is plenty |
| Netlify CLI | `npm i -g netlify-cli` |
| A webcam | Built-in laptop cam or phone camera |
| A browser | Chrome or Edge recommended (best WebRTC support) |

---

# Part 2: Project Structure

```
dont-blink/
├── netlify.toml            ← Netlify config (publish dir, functions dir)
├── package.json            ← Minimal — just @netlify/blobs
├── .gitignore
├── netlify/
│   └── functions/
│       ├── auth.mjs        ← Login / register
│       ├── room.mjs        ← Create, join, signal, ICE, matchmaking
│       ├── leaderboard.mjs ← Get / update leaderboard
│       └── game-event.mjs  ← Blink/win/loss events between players
└── static/
    ├── index.html          ← Shell + CSS + screen containers
    └── js/
        ├── app.js          ← Screen router + state machine
        ├── auth.js         ← Login / register UI + logic
        ├── webrtc.js       ← Peer connection + signaling
        ├── detection.js    ← face-api.js + blink/gaze (most critical)
        ├── ai-opponent.js  ← SVG face + blink timer
        └── api.js          ← All Netlify function calls
```

## netlify.toml

```toml
[build]
  publish = "static"
  functions = "netlify/functions"

[functions]
  node_bundler = "esbuild"
```

## package.json

```json
{
  "name": "dont-blink",
  "private": true,
  "dependencies": {
    "@netlify/blobs": "^8.1.0"
  }
}
```

## Frontend File Responsibilities

```
FILE BREAKDOWN
═══════════════════════════════════════════════════════════

  index.html
  ├── All CSS (inline <style> block)
  ├── All screen containers as <div>s (hidden/shown by app.js)
  ├── <script type="module"> imports from js/
  └── No logic — just structure and style

  js/app.js (the brain)
  ├── State machine: lobby → cam_setup → playing → finished
  ├── showScreen(name) — hides all screens, shows one
  ├── Orchestrates all other modules
  ├── Timer logic (counting up during game)
  ├── Countdown animation (3-2-1-STARE)
  └── Imports and coordinates all other files

  js/api.js (all server communication)
  ├── api.register(email, username)
  ├── api.login(email)
  ├── api.createRoom(username, email)
  ├── api.joinRoom(username, email, roomCode)
  ├── api.findMatch(username, email)
  ├── api.pollRoom(roomCode, username)
  ├── api.signal(roomCode, username, type, sdp)
  ├── api.ice(roomCode, username, candidate)
  ├── api.ready(roomCode, username)
  ├── api.reportBlink(roomCode, username, reason)
  ├── api.recordResult(roomCode)
  └── api.getLeaderboard()

  js/auth.js
  ├── Renders login/register form
  ├── localStorage get/set for session
  ├── Calls api.register / api.login
  └── Exports: currentUser, initAuth(), logout()

  js/detection.js (MOST CRITICAL FILE)
  ├── Load face-api.js models from CDN
  ├── Start/stop detection loop (150ms interval)
  ├── EAR calculation (Eye Aspect Ratio)
  ├── Blink confirmation (3 consecutive frames)
  ├── Gaze direction check (nose offset)
  ├── Face presence check (10 frame grace)
  ├── Calibration during grace period
  └── Exports: initDetection(), startDetecting(), stopDetecting()
      Fires callback: onViolation(reason)

  js/webrtc.js
  ├── Create RTCPeerConnection with STUN servers
  ├── Add local media stream
  ├── Create/handle offers and answers
  ├── ICE candidate management
  ├── Poll-based signaling (reads from api.pollRoom)
  └── Exports: initWebRTC(), connectToPeer(), disconnect()
      Fires callback: onRemoteStream(stream)

  js/ai-opponent.js
  ├── Render animated SVG face into container
  ├── Pupil micro-movements (random drift every 2-5s)
  ├── Micro-squints (eyelid 10% close, random)
  ├── Blink timer (weighted random 30-90s, avg ~43s)
  ├── Blink animation (300ms close, 200ms open)
  └── Exports: startAI(container), stopAI()
      Fires callback: onAIBlink()
```

---

# Part 3: The Backend (Netlify Functions + Blobs)

All serverless. No persistent server. State lives in Netlify Blobs.
Signaling uses polling (simple, no WebSocket complexity).

## Blob Stores

```
FOUR STORES
═══════════════════════════════════════════════════════════

  Store: "users"
  Key:   email (lowercase, trimmed)
  Value: {
    email: "joe@example.com",
    username: "steelgaze",
    wins: 12,
    losses: 5,
    createdAt: "2026-04-10T..."
  }

  Store: "rooms"
  Key:   roomCode (6 char alphanumeric, uppercase)
  Value: {
    roomCode: "ABC123",
    createdAt: "2026-04-10T...",
    players: [
      { username: "steelgaze", email: "joe@...", ready: false },
      { username: "xXblinkerXx", email: "blink@...", ready: false }
    ],
    state: "waiting" | "countdown" | "playing" | "finished",
    signals: {
      "steelgaze": { offer: null, answer: null, ice: [] },
      "xXblinkerXx": { offer: null, answer: null, ice: [] }
    },
    winner: null,
    loser: null,
    loseReason: null,
    startedAt: null,
    endedAt: null,
    recorded: false
  }

  Store: "matchmaking"
  Key:   "queue"
  Value: {
    waiting: [
      { username: "steelgaze", email: "joe@...", roomCode: "ABC123", joinedAt: "..." }
    ]
  }

  Store: "leaderboard"
  Key:   "global"
  Value: {
    players: [
      { username: "steelgaze", wins: 12, losses: 5 },
      { username: "xXblinkerXx", wins: 47, losses: 3 },
      ...
    ]
  }
  NOTE: Sorted by wins descending on every write.
        Cap at 100 entries. Prune on write.
```

## Auth Function (auth.mjs)

```
POST /.netlify/functions/auth
═══════════════════════════════════════════════════════════

  Body: { action: "login" | "register", email: "...", username: "..." }

  REGISTER:
  ┌──────────────────────────────────────────────────────┐
  │  1. Normalize email (lowercase, trim)                 │
  │  2. Check "users" blob — email already exists?        │
  │     ├── YES → 409 "Email already registered"         │
  │     └── NO  → continue                               │
  │  3. Check username uniqueness:                        │
  │     scan all users (list keys, check each)            │
  │     ├── TAKEN → 409 "Username taken"                 │
  │     └── FREE  → continue                              │
  │  4. Store user in "users" blob                        │
  │  5. Return { username, email, wins: 0, losses: 0 }   │
  └──────────────────────────────────────────────────────┘

  LOGIN:
  ┌──────────────────────────────────────────────────────┐
  │  1. Normalize email                                   │
  │  2. Fetch from "users" blob                           │
  │     ├── NOT FOUND → 404 "No account with this email" │
  │     └── FOUND → return user object                    │
  │  3. No password needed — email IS the auth            │
  │     (this is a game, not a bank)                      │
  └──────────────────────────────────────────────────────┘
```

## Room Function (room.mjs)

```
POST /.netlify/functions/room
═══════════════════════════════════════════════════════════

  Body: { action: "...", ... }

  ACTIONS:

  ── "create" ────────────────────────────────────────────
  │  Input:  { username, email }
  │  1. Generate 6-char room code (uppercase alphanumeric)
  │  2. Create room blob with player 1
  │  3. Return { roomCode }
  └───────────────────────────────────────────────────────

  ── "join" ──────────────────────────────────────────────
  │  Input:  { username, email, roomCode }
  │  1. Fetch room blob
  │  2. Room exists? Has space? Not already playing?
  │  3. Add player 2 to room
  │  4. Return { roomCode, players }
  └───────────────────────────────────────────────────────

  ── "find-match" ────────────────────────────────────────
  │  Input:  { username, email }
  │  1. Fetch matchmaking queue
  │  2. Anyone waiting?
  │     ├── YES → remove them from queue, join their room
  │     │         return { roomCode, matched: true }
  │     └── NO  → create new room, add self to queue
  │               return { roomCode, matched: false }
  │  3. Clean stale entries (> 60s old) on every call
  └───────────────────────────────────────────────────────

  ── "poll" ──────────────────────────────────────────────
  │  Input:  { roomCode, username }
  │  1. Return full room state
  │  2. Player uses this to check:
  │     - Has opponent joined?
  │     - Any new signaling data? (offer/answer/ICE)
  │     - Game state changes?
  │  3. Called every 1s by each player
  └───────────────────────────────────────────────────────

  ── "signal" ────────────────────────────────────────────
  │  Input:  { roomCode, username, type: "offer"|"answer", sdp: "..." }
  │  1. Store SDP in room.signals[username]
  │  2. Opponent picks it up on next poll
  └───────────────────────────────────────────────────────

  ── "ice" ───────────────────────────────────────────────
  │  Input:  { roomCode, username, candidate: {...} }
  │  1. Append ICE candidate to room.signals[username].ice
  │  2. Opponent picks them up on next poll
  └───────────────────────────────────────────────────────

  ── "ready" ─────────────────────────────────────────────
  │  Input:  { roomCode, username }
  │  1. Set player.ready = true
  │  2. Both ready? → set state to "countdown"
  │  3. Countdown handled client-side (3-2-1-STARE)
  └───────────────────────────────────────────────────────
```

## Game Event Function (game-event.mjs)

```
POST /.netlify/functions/game-event
═══════════════════════════════════════════════════════════

  Body: { action: "...", ... }

  ── "blink-detected" ───────────────────────────────────
  │  Input:  { roomCode, username, reason: "blink"|"look_away"|"left_frame" }
  │
  │  ANTI-CHEAT / ANTI-GLITCH LOGIC:
  │  1. Fetch room — is state still "playing"?
  │     └── NO → ignore (game already ended)
  │  2. Has this player already reported a loss?
  │     └── YES → ignore (duplicate)
  │  3. Record: room.loser = username
  │             room.winner = opponent
  │             room.loseReason = reason
  │             room.state = "finished"
  │             room.endedAt = now
  │  4. Return { winner, loser, reason }
  │
  │  IMPORTANT: Only the LOSING player's browser reports
  │  their own loss. You never report the opponent's blink.
  │  This prevents latency-based false reports.
  └───────────────────────────────────────────────────────

  ── "record-result" ────────────────────────────────────
  │  Input:  { roomCode }
  │  1. Fetch room — must be "finished"
  │  2. Check room.recorded — if true, ignore (no double-count)
  │  3. Update winner's user blob: wins++
  │  4. Update loser's user blob: losses++
  │  5. Update leaderboard blob
  │  6. Set room.recorded = true
  └───────────────────────────────────────────────────────
```

## Leaderboard Function (leaderboard.mjs)

```
GET /.netlify/functions/leaderboard
═══════════════════════════════════════════════════════════

  Returns top 50 players sorted by wins descending.
  { players: [{ username, wins, losses }, ...] }
```
```

---

# Part 4: The Frontend

No framework. Vanilla JS split into modules. face-api.js from CDN.
WebRTC built on native RTCPeerConnection. Mobile-first CSS.

## External Dependencies (CDN)

```html
<!-- face-api.js — face detection + landmarks -->
<script src="https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js"></script>

<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700;900&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">

Models loaded at runtime from CDN (face-api.js handles this):
  - tinyFaceDetector     (~190 KB) — fast face detection
  - faceLandmark68TinyNet (~80 KB)  — 68-point face landmarks (needed for EAR)
```

## Design System

```
VISUAL LANGUAGE — "GLITCH SAMURAI"
═══════════════════════════════════════════════════════════

  INSPIRATION:
  Dark, moody, neon-drenched. Think glitch art meets fighting
  game select screen. Chromatic aberration on key elements.
  Scan lines over video feeds. Everything feels like it could
  flicker at any moment. Indie game energy — NOT corporate SaaS.

  60/30/10 COLOR RULE:
  ┌──────────────────────────────────────────────────────────┐
  │                                                          │
  │  60% — DOMINANT (backgrounds, large surfaces)            │
  │  Near-black and very dark purples. The void.             │
  │  --bg:       #050208    (deep void black with purple)    │
  │  --surface:  #0c0a14    (card backgrounds)               │
  │  --surface2: #151222    (elevated surfaces)              │
  │  --surface3: #1e1833    (hover states, active areas)     │
  │                                                          │
  │  30% — SECONDARY (borders, text, structural elements)    │
  │  Muted purples, grays, dim neon traces.                  │
  │  --border:   #2a2440    (subtle borders)                 │
  │  --border2:  #3d3560    (emphasized borders)             │
  │  --text:     #e8e4f0    (primary text, slight lavender)  │
  │  --text2:    #9990b0    (secondary text)                 │
  │  --muted:    #5c5478    (disabled, hints)                │
  │                                                          │
  │  10% — ACCENT (interactive elements, highlights, pops)   │
  │  Electric cyan + hot magenta. The neon.                  │
  │  --cyan:     #00f0ff    (your side, win, primary action) │
  │  --magenta:  #ff00aa    (opponent side, lose, danger)    │
  │  --amber:    #ffaa00    (countdown, warnings)            │
  │  --green:    #00ff88    (connected, success)             │
  │                                                          │
  │  Glow tokens (box-shadow, used sparingly):               │
  │  --glow-cyan:    0 0 20px rgba(0,240,255,0.3),           │
  │                  0 0 60px rgba(0,240,255,0.1)            │
  │  --glow-magenta: 0 0 20px rgba(255,0,170,0.3),           │
  │                  0 0 60px rgba(255,0,170,0.1)            │
  │  --glow-amber:   0 0 20px rgba(255,170,0,0.3)           │
  │                                                          │
  └──────────────────────────────────────────────────────────┘

  TYPOGRAPHY:
  ┌──────────────────────────────────────────────────────────┐
  │  Headings / Title / Timer / Room Code:                   │
  │    font-family: 'Orbitron', monospace                    │
  │    Techy, geometric, slightly futuristic.                │
  │    Used for: DON'T BLINK title, countdown numbers,       │
  │    timer display, room codes, HALL OF FAME header.       │
  │    Weight 900 for the main title.                        │
  │    Letter-spacing: 0.15em on titles.                     │
  │                                                          │
  │  Body / UI / Buttons / Inputs:                           │
  │    font-family: 'Inter', sans-serif                      │
  │    Clean, readable. Used for everything else.            │
  │    Weight 400 body, 600 buttons.                         │
  └──────────────────────────────────────────────────────────┘

  CHROMATIC ABERRATION EFFECT (CSS — the signature look):
  ┌──────────────────────────────────────────────────────────┐
  │                                                          │
  │  Applied to the main title and result screen text.       │
  │  Uses CSS text-shadow with offset colored copies:        │
  │                                                          │
  │  .glitch-text {                                          │
  │    color: var(--text);                                   │
  │    text-shadow:                                          │
  │      2px 0 var(--cyan),                                  │
  │      -2px 0 var(--magenta);                              │
  │  }                                                       │
  │                                                          │
  │  For animated glitch (title screen + result screen):     │
  │  Use a @keyframes that randomly shifts the text-shadow   │
  │  offsets every 100ms for a brief 200ms burst,            │
  │  then holds steady for 3-6 seconds before glitching      │
  │  again. This creates the "unstable signal" feel.         │
  │                                                          │
  │  @keyframes glitch {                                     │
  │    0%, 90%, 100% {                                       │
  │      text-shadow: 2px 0 var(--cyan),                     │
  │                   -2px 0 var(--magenta);                  │
  │    }                                                     │
  │    92% {                                                  │
  │      text-shadow: -3px 1px var(--cyan),                  │
  │                   3px -1px var(--magenta);                │
  │    }                                                     │
  │    94% {                                                  │
  │      text-shadow: 4px -2px var(--cyan),                  │
  │                   -2px 2px var(--magenta);                │
  │    }                                                     │
  │    96% {                                                  │
  │      text-shadow: -1px 3px var(--cyan),                  │
  │                   1px -1px var(--magenta);                │
  │    }                                                     │
  │  }                                                       │
  │                                                          │
  │  Apply with: animation: glitch 4s infinite               │
  └──────────────────────────────────────────────────────────┘

  SCAN LINE OVERLAY (CSS — on video feeds):
  ┌──────────────────────────────────────────────────────────┐
  │                                                          │
  │  A ::after pseudo-element on each video container:       │
  │                                                          │
  │  .video-frame::after {                                   │
  │    content: '';                                          │
  │    position: absolute;                                   │
  │    inset: 0;                                             │
  │    background: repeating-linear-gradient(                │
  │      transparent,                                        │
  │      transparent 2px,                                    │
  │      rgba(0,0,0,0.15) 2px,                              │
  │      rgba(0,0,0,0.15) 4px                               │
  │    );                                                    │
  │    pointer-events: none;                                 │
  │    z-index: 2;                                           │
  │  }                                                       │
  │                                                          │
  │  Subtle — you shouldn't really notice it consciously,    │
  │  but it gives the video feeds that CRT / surveillance    │
  │  camera texture.                                         │
  └──────────────────────────────────────────────────────────┘

  UI ELEMENTS — INDIE GAME STYLE (not boring SaaS):
  ┌──────────────────────────────────────────────────────────┐
  │                                                          │
  │  BUTTONS:                                                │
  │  Not rounded rectangles. Instead:                        │
  │  - Clipped corners (clip-path) for primary actions       │
  │  - Thin 1px border in cyan or magenta                    │
  │  - No background fill by default — just border + text    │
  │  - On hover/tap: fill floods in from left (transition)   │
  │  - Active: slight scale(0.97) + glow                     │
  │                                                          │
  │  .btn-primary {                                          │
  │    clip-path: polygon(                                   │
  │      8px 0, 100% 0, 100% calc(100% - 8px),              │
  │      calc(100% - 8px) 100%, 0 100%, 0 8px               │
  │    );                                                    │
  │    border: 1px solid var(--cyan);                        │
  │    background: transparent;                              │
  │    color: var(--cyan);                                   │
  │    font-family: 'Orbitron', monospace;                   │
  │    font-weight: 600;                                     │
  │    letter-spacing: 0.1em;                                │
  │    text-transform: uppercase;                            │
  │    padding: 14px 28px;                                   │
  │    cursor: pointer;                                      │
  │    transition: background 0.2s, color 0.2s;              │
  │    min-height: 48px; /* mobile tap target */             │
  │  }                                                       │
  │  .btn-primary:hover {                                    │
  │    background: var(--cyan);                              │
  │    color: var(--bg);                                     │
  │    box-shadow: var(--glow-cyan);                         │
  │  }                                                       │
  │                                                          │
  │  .btn-danger uses --magenta instead of --cyan.           │
  │                                                          │
  │  INPUTS:                                                 │
  │  No bordered boxes. Underline-only style:                │
  │  - Transparent background                                │
  │  - Bottom border only, 1px --border                      │
  │  - On focus: bottom border glows cyan, 2px               │
  │  - Placeholder text in --muted                           │
  │  - font-size: 16px (prevents iOS zoom)                   │
  │  - Orbitron font for room code input                     │
  │                                                          │
  │  CARDS / PANELS:                                         │
  │  - background: var(--surface)                            │
  │  - border: 1px solid var(--border)                       │
  │  - No border-radius (sharp corners = edgy)               │
  │  - Subtle top-left corner accent: a small 2px cyan       │
  │    line on the top edge (like a HUD element)             │
  │                                                          │
  │  .card::before {                                         │
  │    content: '';                                          │
  │    position: absolute;                                   │
  │    top: 0; left: 0;                                      │
  │    width: 40px; height: 2px;                             │
  │    background: var(--cyan);                              │
  │  }                                                       │
  │                                                          │
  │  VIDEO FRAMES:                                           │
  │  - Your webcam: 1px border var(--cyan), glow-cyan        │
  │  - Opponent webcam: 1px border var(--magenta),           │
  │    glow-magenta                                          │
  │  - Username label below each, Orbitron font, small       │
  │  - Scan line overlay via ::after                         │
  │  - On blink detection: border flashes red rapidly        │
  │    (3 flashes, 100ms each)                               │
  │                                                          │
  │  ROOM CODE DISPLAY:                                      │
  │  - Orbitron font, weight 700, large (2rem+)              │
  │  - Letter-spacing: 0.3em (spaced out for readability)    │
  │  - Color: var(--amber)                                   │
  │  - Subtle glow-amber shadow                              │
  │  - Click/tap to copy (toast: "Code copied!")             │
  │                                                          │
  │  LEADERBOARD (HALL OF FAME):                             │
  │  - No table — styled as a ranked list                    │
  │  - Each entry: rank number (Orbitron, cyan for top 3,    │
  │    muted for others) + username + win count              │
  │  - Top 3 get a subtle glow on their row                 │
  │  - #1 gets a small ⚡ icon                               │
  │  - Divider lines between entries: 1px --border           │
  │                                                          │
  │  COUNTDOWN (3-2-1-STARE):                                │
  │  - Full-screen overlay, semi-transparent black bg        │
  │  - Number in Orbitron, weight 900, ~20vw size            │
  │  - Color: --amber for 3, 2, 1                            │
  │  - Color: --cyan for "STARE" (with glitch animation)     │
  │  - Each number: scale in from 1.5 → 1.0, fade out       │
  │  - "STARE" holds for 500ms with chromatic split          │
  │  - Subtle screen shake on "STARE" (CSS transform         │
  │    translate jitter for 200ms)                           │
  │                                                          │
  └──────────────────────────────────────────────────────────┘

  ANIMATIONS:
  ├── Glitch text: chromatic aberration bursts (title, results)
  ├── Scan lines: static overlay on video feeds
  ├── Countdown 3-2-1-STARE: scale + fade + glitch on STARE
  ├── Blink detection flash: video border rapid red pulse (3x)
  ├── Win: cyan glow intensifies on your frame, glitch text
  │   "VICTORY" with screen shake
  ├── Lose: magenta vignette overlay fades in, glitch text
  │   "YOU BLINKED" with screen shake
  ├── Timer: subtle pulse glow every 30 seconds
  ├── AI face: smooth eyelid animation, pupil micro-drift
  ├── Button hover: fill floods in from left edge
  ├── Status dot: slow pulse animation (opacity 0.4 → 1.0)
  └── Toast: slide in from bottom (mobile) or right (desktop)

  MOBILE LAYOUT NOTES:
  ┌──────────────────────────────────────────────────────────┐
  │  • Game screen: videos stacked vertically                │
  │    - Your cam on top (~35vh)                             │
  │    - Opponent cam below (~40vh)                          │
  │    - Timer + status between them (compact)               │
  │  • Lobby: single column, full-width buttons              │
  │  • All inputs: font-size 16px+ (prevents iOS zoom)       │
  │  • Room code: large Orbitron font, easy to read aloud    │
  │  • viewport meta: width=device-width, user-scalable=no   │
  │  • All tap targets: min 48px height                      │
  │  • Body: overflow hidden during game (no scroll)         │
  └──────────────────────────────────────────────────────────┘

  DESKTOP LAYOUT (min-width: 768px):
  ┌──────────────────────────────────────────────────────────┐
  │  • Game screen: videos side by side (50/50)              │
  │  • Lobby: centered card, max-width 480px                 │
  │  • Leaderboard: wider, more entries visible              │
  │  • Buttons: auto-width (not full-width)                  │
  └──────────────────────────────────────────────────────────┘

  BACKGROUND TEXTURE (subtle, landing page):
  ┌──────────────────────────────────────────────────────────┐
  │                                                          │
  │  Very faint grid pattern on the body background:         │
  │                                                          │
  │  body {                                                  │
  │    background-color: var(--bg);                          │
  │    background-image:                                     │
  │      linear-gradient(                                    │
  │        rgba(0,240,255,0.03) 1px, transparent 1px         │
  │      ),                                                  │
  │      linear-gradient(                                    │
  │        90deg, rgba(0,240,255,0.03) 1px, transparent 1px  │
  │      );                                                  │
  │    background-size: 40px 40px;                           │
  │  }                                                       │
  │                                                          │
  │  This gives a barely-visible grid/matrix texture.        │
  │  Combined with the dark bg, it feels like a HUD.         │
  └──────────────────────────────────────────────────────────┘
```

## index.html Structure

```html
SCREEN CONTAINERS (all in one HTML file, toggled via display:none)
═══════════════════════════════════════════════════════════

  <div id="screen-loading">
    Loading face detection...
    (spinner with cyan glow + "INITIALIZING..." in Orbitron)
  </div>

  <div id="screen-auth">
    DON'T BLINK title (glitch-text)
    "First to blink loses. Ready?"
    Username + Email underline inputs
    [ENTER] button (btn-primary)
    [I HAVE AN ACCOUNT] toggle link
  </div>

  <div id="screen-lobby">
    Welcome, {username}! [Logout]
    [🤖 FACE THE AI]  [⚔️ CHALLENGE HUMAN]
    ── human options (slide open on click) ──
    [CREATE ARENA]  [FIND OPPONENT]
    [ENTER CODE: ______]
    ── HALL OF FAME ──
    Top 10 ranked list
  </div>

  <div id="screen-waiting">
    Room code: ABC123 (large Orbitron, amber, click to copy)
    "Share this code with your opponent"
    "Waiting for challenger..."
    (pulsing status dot)
    [CANCEL]
  </div>

  <div id="screen-matching">
    "Searching for opponent..."
    (pulsing status dot)
    [CANCEL]
  </div>

  <div id="screen-pregame">
    Rules overlay:
    "❌ Don't blink (~0.5s = loss)"
    "❌ Don't look away (~0.5s = loss)"
    "❌ Don't leave the frame (~1.5s = loss)"
    "✅ 2-second grace period to settle in"
    [I'M READY] (btn-primary, large)
  </div>

  <div id="screen-game">
    <div class="video-container">
      <div class="video-frame you">
        <video id="local-video" autoplay playsinline muted></video>
        <span class="player-label">steelgaze</span>
      </div>
      <div class="video-frame opponent">
        <div id="opponent-container">
          <!-- either <video id="remote-video"> or AI SVG face -->
        </div>
        <span class="player-label">xXblinkerXx</span>
      </div>
    </div>
    <div id="timer">00:00</div>
    <div id="status">● STARING ●</div>
    <div id="countdown-overlay">3</div>
  </div>

  <div id="screen-result">
    Win/Lose glitch text + reason + time survived
    [REMATCH]  [BACK TO LOBBY]
  </div>

  <div id="toast-container"></div>

  IMPORTANT: <video> elements MUST have:
  - autoplay (start immediately)
  - playsinline (prevent iOS fullscreen)
  - muted on local video (prevent feedback)
  - Remote video is NOT muted (you hear opponent)
```

## Screen Flow

```
PAGE LOAD
═══════════════════════════════════════════════════════════

  Load face-api.js models from CDN
           │
           ▼
  ┌─────────────────────────────────────┐
  │  Models loaded?                     │
  │  ├── NO  → show screen-loading      │
  │  │         "INITIALIZING..."        │
  │  └── YES → check localStorage      │
  └─────────────────────────────────────┘
           │
           ▼
  ┌─────────────────────────────────────┐
  │  Has localStorage user?             │
  │  ├── YES → show screen-lobby        │
  │  └── NO  → show screen-auth        │
  └─────────────────────────────────────┘
           │
           ▼
  ┌─────────────────────────────────────┐
  │  LOBBY                              │
  │  Choose mode:                       │
  │  ├── FACE THE AI → screen-game (AI) │
  │  ├── CREATE ARENA → screen-waiting  │
  │  ├── ENTER CODE → screen-pregame   │
  │  └── FIND OPPONENT → screen-matching│
  └─────────────────────────────────────┘
           │
           ▼
  ┌─────────────────────────────────────┐
  │  GAME SCREEN                        │
  │                                     │
  │  1. Request webcam permission       │
  │     facingMode: "user" (front cam)  │
  │  2. Show local video preview        │
  │  3. If vs Human:                    │
  │     a. WebRTC handshake             │
  │     b. Show remote video            │
  │     c. Both click Ready             │
  │  4. If vs AI:                       │
  │     a. Show SVG face                │
  │     b. Auto-ready                   │
  │  5. COUNTDOWN: 3... 2... 1... STARE!│
  │  6. Grace period (2s) + calibration │
  │  7. Blink detection starts          │
  │  8. First to fail → game over       │
  └─────────────────────────────────────┘
           │
           ▼
  ┌─────────────────────────────────────┐
  │  RESULT SCREEN                      │
  │  Win or Lose + reason + time        │
  │  ├── Rematch (vs Human only)        │
  │  └── Back to Lobby                  │
  └─────────────────────────────────────┘
```

## Blink Detection System (CRITICAL — must be solid)

```
BLINK DETECTION — detection.js
═══════════════════════════════════════════════════════════

  Runs locally. Never sends video anywhere.
  face-api.js runs every 150ms on the local video element.
  (NOT every frame — 150ms is plenty and saves CPU,
   especially important on mobile)

  Step 1: Detect face
  ┌──────────────────────────────────────────────────────┐
  │  const detection = await faceapi                     │
  │    .detectSingleFace(video, tinyFaceOptions)         │
  │    .withFaceLandmarks()                              │
  │                                                      │
  │  No face detected?                                   │
  │  ├── Increment noFaceCounter                         │
  │  ├── noFaceCounter >= 10? (1.5 seconds)              │
  │  │   └── LOSE: "left_frame"                          │
  │  └── noFaceCounter < 10? → keep going                │
  │                                                      │
  │  Face detected? → reset noFaceCounter to 0           │
  └──────────────────────────────────────────────────────┘

  Step 2: Calculate Eye Aspect Ratio (EAR)
  ┌──────────────────────────────────────────────────────┐
  │                                                      │
  │  The 68-point landmark model gives us 6 points       │
  │  per eye. EAR measures how "open" the eye is:        │
  │                                                      │
  │       P2 ---- P3                                     │
  │      /          \                                    │
  │  P1               P4                                 │
  │      \          /                                    │
  │       P6 ---- P5                                     │
  │                                                      │
  │  EAR = (|P2-P6| + |P3-P5|) / (2 * |P1-P4|)        │
  │                                                      │
  │  Open eye:   EAR ≈ 0.25–0.35                        │
  │  Closed eye: EAR ≈ 0.05–0.15                        │
  │  Threshold:  EAR < 0.19 → eye is closed             │
  │                                                      │
  │  Use AVERAGE of both eyes:                           │
  │  avgEAR = (leftEAR + rightEAR) / 2                  │
  │                                                      │
  │  face-api.js landmark indices for eyes:              │
  │  Left eye:  points 36-41                             │
  │  Right eye: points 42-47                             │
  │                                                      │
  │  function calculateEAR(eyePoints) {                  │
  │    const v1 = dist(eyePoints[1], eyePoints[5]);     │
  │    const v2 = dist(eyePoints[2], eyePoints[4]);     │
  │    const h  = dist(eyePoints[0], eyePoints[3]);     │
  │    return (v1 + v2) / (2 * h);                      │
  │  }                                                   │
  └──────────────────────────────────────────────────────┘

  Step 3: Blink Confirmation (anti-false-positive)
  ┌──────────────────────────────────────────────────────┐
  │                                                      │
  │  CRITICAL: A single low-EAR frame is NOT a blink.    │
  │  Lighting changes, head tilts, and shadows can       │
  │  cause momentary dips.                               │
  │                                                      │
  │  Rule: EAR must be below threshold for               │
  │  3 CONSECUTIVE detections (≈ 450ms).                 │
  │                                                      │
  │  let blinkFrames = 0;                                │
  │                                                      │
  │  if (avgEAR < adjustedThreshold) {                   │
  │    blinkFrames++;                                    │
  │    if (blinkFrames >= 3) {                           │
  │      → LOSE: "blink"                                 │
  │    }                                                 │
  │  } else {                                            │
  │    blinkFrames = 0;  // reset on any open frame      │
  │  }                                                   │
  │                                                      │
  │  WHY 3 FRAMES:                                       │
  │  A real blink lasts 300–400ms. A false dip from      │
  │  lighting is usually 1 frame. 3 consecutive frames   │
  │  at 150ms each = 450ms, which catches real blinks    │
  │  while filtering nearly all false positives.         │
  └──────────────────────────────────────────────────────┘

  Step 4: Gaze Direction (look away detection)
  ┌──────────────────────────────────────────────────────┐
  │                                                      │
  │  Use nose tip (landmark 30) relative to face         │
  │  bounding box center.                                │
  │                                                      │
  │  const nose = landmarks.positions[30];               │
  │  const box = detection.detection.box;                │
  │  const centerX = box.x + box.width / 2;             │
  │  const centerY = box.y + box.height / 2;            │
  │                                                      │
  │  const offsetX = (nose.x - centerX) / box.width;    │
  │  const offsetY = (nose.y - centerY) / box.height;   │
  │                                                      │
  │  Looking away = |offsetX| > 0.28 or offsetY > 0.20  │
  │                                                      │
  │  Same 3-consecutive-frame rule applies.              │
  │  let lookAwayFrames = 0;                             │
  │  lookAwayFrames >= 3 → LOSE: "look_away"             │
  └──────────────────────────────────────────────────────┘

  Step 5: Grace Period + Calibration
  ┌──────────────────────────────────────────────────────┐
  │                                                      │
  │  After countdown ends and game starts:               │
  │  2 SECOND GRACE PERIOD where nothing is detected.    │
  │                                                      │
  │  WHY: Players often blink reflexively right when     │
  │  the game starts. The countdown should give them     │
  │  time to settle. The grace period is insurance.      │
  │                                                      │
  │  During grace period: CALIBRATE.                     │
  │  Sample the player's resting EAR (average of first   │
  │  ~13 detections at 150ms = ~2 seconds).              │
  │  If their resting EAR is naturally low, adjust:      │
  │                                                      │
  │  adjustedThreshold = Math.min(0.19,                  │
  │                       restingEAR * 0.65)             │
  │                                                      │
  │  This prevents players with naturally narrower       │
  │  eyes from being at a disadvantage.                  │
  │                                                      │
  │  Show visual feedback during calibration:            │
  │  "CALIBRATING..." in Orbitron, amber, pulsing        │
  └──────────────────────────────────────────────────────┘

  detection.js Exports:
  ┌──────────────────────────────────────────────────────┐
  │                                                      │
  │  initDetection()                                     │
  │    → loads face-api models from CDN                  │
  │    → returns Promise (resolve when ready)            │
  │                                                      │
  │  startDetecting(videoElement, onViolation)            │
  │    → begins 150ms detection loop                     │
  │    → runs grace period + calibration first           │
  │    → calls onViolation("blink"|"look_away"|          │
  │                         "left_frame") on loss        │
  │                                                      │
  │  stopDetecting()                                     │
  │    → clears interval, resets all counters            │
  │                                                      │
  │  getDetectionState()                                 │
  │    → returns { isCalibrating, restingEAR,            │
  │               adjustedThreshold, currentEAR }        │
  │    → useful for debug overlay during development     │
  └──────────────────────────────────────────────────────┘
```

## WebRTC Flow (Human vs Human) — webrtc.js

```
WebRTC SIGNALING VIA NETLIFY POLLING
═══════════════════════════════════════════════════════════

  No WebSocket needed. Both players poll room state every 1s.
  Signaling data (SDP offers/answers, ICE candidates) is
  stored in the room blob and picked up on the next poll.

  Player A (room creator)              Player B (joiner)
  ═══════════════════════              ═════════════════════

  1. Create room, get code
  2. Share code with friend
  3. Poll room... waiting...
                                       4. Join room with code
  5. Poll → sees player B joined
  6. Create RTCPeerConnection
  7. Add local stream (video + audio)
  8. Create offer (SDP)
  9. POST signal {type:"offer", sdp}
                                       10. Poll → sees offer
                                       11. Create RTCPeerConnection
                                       12. Add local stream
                                       13. Set remote description (offer)
                                       14. Create answer (SDP)
                                       15. POST signal {type:"answer", sdp}
  16. Poll → sees answer
  17. Set remote description

  ICE CANDIDATES (trickle):
  Both sides:
  - pc.onicecandidate → collect for 200ms, then batch POST
  - On poll → check opponent's ice array for new candidates
  - Track lastIceIndex to only process new ones
  - Add new candidates: pc.addIceCandidate()

  CONNECTION ESTABLISHED:
  - pc.ontrack → set remote video srcObject
  - Both players now see + hear each other
  - Both click "Ready"
  - Both ready → countdown starts (detected via poll)

  STUN SERVER (free, public):
  const pc = new RTCPeerConnection({
    iceServers: [
      { urls: "stun:stun.l.google.com:19302" },
      { urls: "stun:stun1.l.google.com:19302" }
    ]
  });

  getUserMedia CONSTRAINTS (mobile-first):
  {
    video: {
      facingMode: "user",
      width: { ideal: 640 },
      height: { ideal: 480 },
      frameRate: { ideal: 30 }
    },
    audio: true
  }

  NOTE: No TURN server. ~85% of connections will work.
  Show clear error + suggest different network if it fails.
  CONNECTION TIMEOUT: 15 seconds → error toast → lobby.

  webrtc.js Exports:
  ┌──────────────────────────────────────────────────────┐
  │                                                      │
  │  initWebRTC(localStream)                             │
  │    → creates RTCPeerConnection                       │
  │    → adds local tracks                               │
  │    → sets up ICE candidate batching                  │
  │    → returns { pc, createOffer, handleOffer,         │
  │                handleAnswer }                        │
  │                                                      │
  │  onRemoteStream — callback                           │
  │    → fires when opponent's video/audio arrives       │
  │    → set remote <video> srcObject                    │
  │                                                      │
  │  onConnectionStateChange — callback                  │
  │    → fires on connected / disconnected / failed      │
  │                                                      │
  │  disconnect()                                        │
  │    → pc.close(), cleanup                             │
  │                                                      │
  │  getNewIceCandidates()                               │
  │    → returns batched candidates since last call      │
  │    → called by polling loop to POST to server        │
  │                                                      │
  │  addRemoteIceCandidates(candidates)                  │
  │    → adds candidates received from poll              │
  └──────────────────────────────────────────────────────┘
```

## AI Opponent Mode — ai-opponent.js

```
AI OPPONENT (no WebRTC, no server calls)
═══════════════════════════════════════════════════════════

  Everything runs locally. No room created. No leaderboard.

  ┌──────────────────────────────────────────────────────┐
  │  ANIMATED SVG FACE — "THE MACHINE"                    │
  │                                                      │
  │  Rendered into the opponent-container div.             │
  │  A stylized robotic face in SVG matching the          │
  │  glitch samurai aesthetic:                            │
  │                                                      │
  │  - Dark geometric head shape (hexagonal or            │
  │    angular, NOT round — think mech/robot)             │
  │  - Two large eyes: dark sclera, glowing cyan iris,    │
  │    bright cyan pupil dot                               │
  │  - Eyelids: <rect> elements, color --surface2,        │
  │    animated via transform to slide down                │
  │  - Subtle scan line overlay on the SVG itself          │
  │  - Faint grid pattern in the "face" background         │
  │  - NO nose or mouth (minimal, intimidating)            │
  │                                                      │
  │  Micro-movements (make it feel alive):                │
  │  · Pupils drift slightly (random, every 2–5s)         │
  │    transform: translate(±2px, ±1px)                   │
  │  · Very slight head tilt (CSS transform on SVG,       │
  │    ±2deg, random every 4–8s)                          │
  │  · Occasional micro-squint (eyelid drops 10%,         │
  │    random every 5–10s, lasts 200ms)                   │
  │  · Subtle chromatic aberration flicker on the          │
  │    iris every 8-15s (matches the glitch theme)        │
  │                                                      │
  │  Background: var(--surface) with faint grid            │
  │  Label: "🤖 MACHINE" below, Orbitron font              │
  │                                                      │
  │  SVG should be responsive — viewBox based,            │
  │  scales to container width on mobile.                 │
  │                                                      │
  │  BLINK TIMING:                                        │
  │  Random interval between 30–90 seconds.               │
  │  Distribution weighted toward shorter times:          │
  │                                                      │
  │  function getAIBlinkTime() {                          │
  │    const r = Math.random();                           │
  │    const weighted = r * r;                            │
  │    return 30 + weighted * 60; // 30–90s range         │
  │  }                                                   │
  │                                                      │
  │  Average: ~43 seconds (due to squaring)               │
  │                                                      │
  │  When AI blinks:                                      │
  │  - Eyelid animation plays (300ms close, 200ms open)  │
  │  - Iris glow flickers out during blink                │
  │  - After 500ms → fire onAIBlink callback              │
  │  - Result screen: "⚡ THE MACHINE BLINKED ⚡"         │
  │                                                      │
  │  AI does NOT affect the leaderboard.                  │
  └──────────────────────────────────────────────────────┘

  ai-opponent.js Exports:
  ┌──────────────────────────────────────────────────────┐
  │                                                      │
  │  startAI(containerElement, onAIBlink)                 │
  │    → renders SVG face into container                 │
  │    → starts micro-movement intervals                 │
  │    → sets blink timer (getAIBlinkTime)               │
  │    → calls onAIBlink() when AI loses                 │
  │                                                      │
  │  stopAI()                                            │
  │    → clears all intervals and timers                 │
  │    → removes SVG from container                      │
  └──────────────────────────────────────────────────────┘
```

## Game State Machine — app.js

```
GAME STATES (managed in app.js)
═══════════════════════════════════════════════════════════

  "lobby"
    │
    ├── FACE THE AI clicked ────────────► "cam_setup"
    ├── room created / joined ──────────► "waiting_opponent"
    └── FIND OPPONENT started ──────────► "matching"
                                              │
  "matching"                                  │
    │  (poll every 1.5s)                      │
    ├── opponent found ─────────────────► "cam_setup"
    └── timeout (60s) ──────────────────► "lobby" + toast
                                              │
  "waiting_opponent"                          │
    │  (poll every 1s)                        │
    └── opponent joined ────────────────► "cam_setup"
                                              │
  "cam_setup"                                 │
    │  getUserMedia({ video: { facingMode: "user" }, audio: true })
    │  Show local preview                     │
    ├── permission denied ──────────────► "lobby" + error toast
    └── stream acquired ────────────────► "connecting" (human)
                                         or "pre_game" (AI)
                                              │
  "connecting" (vs Human only)                │
    │  WebRTC handshake via polling            │
    ├── connected ──────────────────────► "pre_game"
    └── failed (15s timeout) ───────────► "lobby" + error toast
                                              │
  "pre_game"                                  │
    │  Show rules overlay                     │
    │  [I'M READY] button                     │
    │                                         │
    │  vs Human: wait for both ready (via poll)│
    │  vs AI: auto-start on tap               │
    └── ready ──────────────────────────► "countdown"
                                              │
  "countdown"                                 │
    │  3... 2... 1... STARE!                  │
    │  (Orbitron numbers, amber → cyan,       │
    │   scale + fade, glitch on STARE)        │
    └── done ───────────────────────────► "playing"
                                              │
  "playing"                                   │
    │  Detection loop active (detection.js)   │
    │  Timer counting up (Orbitron MM:SS)      │
    │  Grace period: first 2s (calibrating)   │
    │                                         │
    ├── local violation detected ────────► "finished" (you lose)
    │   → POST blink-detected to server (human mode)
    │                                         │
    ├── poll shows room.state = finished ─► "finished" (you win)
    │   (opponent reported their own loss)    │
    │                                         │
    └── AI blinks (onAIBlink callback) ──► "finished" (you win)
                                              │
  "finished"                                  │
    │  Stop detection loop                    │
    │  Stop AI (if AI mode)                   │
    │  Disconnect WebRTC (if human mode)      │
    │  Show result screen (glitch text)       │
    │  Record result via API (human mode only) │
    │                                         │
    ├── REMATCH (human only) ───────────► "pre_game"
    │   (both must click — detected via poll) │
    └── BACK TO LOBBY ─────────────────► "lobby"
```

## Polling Loop (app.js — runs during multiplayer)

```
POLLING (drives multiplayer state)
═══════════════════════════════════════════════════════════

  const POLL_INTERVAL = 1000; // 1 second
  let pollTimer = null;
  let lastIceIndex = { me: 0, opponent: 0 };

  function startPolling(roomCode, username) {
    pollTimer = setInterval(async () => {
      const room = await api.pollRoom(roomCode, username);

      // 1. Check if opponent joined (waiting state)
      if (gameState === "waiting_opponent" && room.players.length === 2) {
        transition("cam_setup");
      }

      // 2. Relay signaling data for WebRTC
      const opponent = room.players.find(p => p.username !== username);
      if (opponent) {
        const theirSignals = room.signals[opponent.username];
        if (theirSignals?.offer && !hasProcessedOffer) handleOffer(theirSignals.offer);
        if (theirSignals?.answer && !hasProcessedAnswer) handleAnswer(theirSignals.answer);
        const newIce = theirSignals?.ice?.slice(lastIceIndex.opponent) || [];
        newIce.forEach(c => addRemoteIceCandidate(c));
        lastIceIndex.opponent += newIce.length;
      }

      // 3. Send our batched ICE candidates
      const myIce = getNewIceCandidates();
      if (myIce.length > 0) {
        for (const c of myIce) {
          await api.ice(roomCode, username, c);
        }
      }

      // 4. Check both ready → countdown
      if (gameState === "pre_game" && room.state === "countdown") {
        transition("countdown");
      }

      // 5. Check game over (opponent reported their loss)
      if (gameState === "playing" && room.state === "finished") {
        if (room.winner === username) handleWin(room);
      }

    }, POLL_INTERVAL);
  }

  function stopPolling() {
    clearInterval(pollTimer);
    pollTimer = null;
  }
```

## Toast Notification System

```
TOASTS (bottom center on mobile, bottom-right on desktop)
═══════════════════════════════════════════════════════════

  function toast(msg, type = "") {
    const el = document.createElement("div");
    el.className = "toast " + type;  // "success" or "error"
    el.textContent = msg;
    document.getElementById("toast-container").appendChild(el);
    setTimeout(() => {
      el.style.animation = "toast-out 0.22s forwards";
      el.addEventListener("animationend", () => el.remove());
    }, 3500);
  }

  Style: --surface2 bg, 1px --border border, sharp corners,
  small cyan accent line on left edge (success) or magenta (error).
  Orbitron font for the text. Matches the HUD aesthetic.
```

# Part 5: Gotchas & Design Decisions

```
┌─────────────────────────────────────────────────────────────┐
│              WHY IT'S BUILT THIS WAY                         │
│                                                              │
│  Vanilla JS modules instead of React                         │
│  └── WHY: No build step. No webpack. No node_modules on     │
│      the frontend. Each file is small enough for Claude      │
│      Code to handle well. Deploys as plain static files.     │
│      Splitting into 6 JS files gives organization            │
│      without framework complexity.                           │
│                                                              │
│  Polling instead of WebSockets                               │
│  └── WHY: Netlify functions are stateless. WebSockets        │
│      need a persistent server. Polling every 1s is fine      │
│      for signaling — once WebRTC connects, all real-time     │
│      communication is peer-to-peer anyway.                   │
│                                                              │
│  Blink detection runs locally, loss is self-reported         │
│  └── WHY: Sending video frames to a server would be slow    │
│      and expensive. Each browser detects its own player.     │
│      You only report YOUR OWN loss — never the opponent's.   │
│      This prevents network latency from causing unfair       │
│      results. The downside is a player could cheat by        │
│      not reporting. For a casual game, this is fine.         │
│                                                              │
│  3-consecutive-frame blink threshold                         │
│  └── WHY: Single-frame false positives from shadows,         │
│      lighting, or head movement would make the game          │
│      unplayable. 3 frames at 150ms = 450ms, which still     │
│      catches every real blink (real blinks are 300-400ms)   │
│      but filters nearly all false positives.                │
│                                                              │
│  Calibration during grace period                             │
│  └── WHY: People have different eye shapes. Someone with     │
│      naturally narrow eyes might have a resting EAR of      │
│      0.20, which is right at the default threshold.          │
│      Calibration makes the game fair for everyone.           │
│                                                              │
│  No TURN server                                              │
│  └── WHY: TURN servers cost money and are complex.           │
│      Google STUN servers are free and handle ~85% of         │
│      home connections. If connection fails, we show a        │
│      clear error and suggest trying on a different network. │
│                                                              │
│  AI doesn't count for leaderboard                            │
│  └── WHY: AI blink time is random and easy to farm.          │
│      Leaderboard only tracks human vs human wins.           │
│                                                              │
│  Email as "password"                                         │
│  └── WHY: This is a browser game, not a bank. Email is      │
│      enough to prevent random name-stealing while keeping   │
│      registration to 2 fields. No OAuth complexity.          │
│                                                              │
│  Mobile-first layout                                         │
│  └── WHY: Most users will play on their phones. Webcam      │
│      is built in, it's easy to hold up and stare at.         │
│      Desktop is secondary — works fine with side-by-side    │
│      layout via media query.                                │
│                                                              │
│  150ms detection interval (not every frame)                  │
│  └── WHY: Running face detection on every frame (~33ms      │
│      at 30fps) would crush mobile CPUs and drain battery.   │
│      150ms (6.6 fps detection) is fast enough to catch      │
│      any real blink (300ms+) while staying smooth on        │
│      mid-range phones.                                      │
│                                                              │
│  Orbitron font for headings / HUD elements                   │
│  └── WHY: Geometric, techy, slightly aggressive. Matches    │
│      the glitch samurai aesthetic. Gives the whole app       │
│      an indie game feel rather than a web app feel.          │
│      Inter for body text keeps readability high.             │
│                                                              │
│  Sharp corners on cards, clipped corners on buttons          │
│  └── WHY: Rounded corners feel friendly and SaaS-like.      │
│      Sharp corners + clip-path cuts feel aggressive and      │
│      game-like. The diagonal clip-path on buttons is a      │
│      classic sci-fi HUD pattern. It immediately tells       │
│      users "this is a game, not an app."                    │
│                                                              │
│  Cyan vs Magenta for player sides                            │
│  └── WHY: Creates instant visual identity for "you" vs      │
│      "them" without needing labels. Your video has a cyan   │
│      border, opponent has magenta. These colors also map    │
│      to the chromatic aberration effect in the glitch       │
│      theme — the two split colors ARE the two players.      │
│      Win = cyan glow. Lose = magenta glow. Consistent.     │
│                                                              │
│  Scan lines on video feeds                                   │
│  └── WHY: Without them, the webcam feeds look too "real"    │
│      and clinical. The scan lines make them feel like       │
│      surveillance footage or a fighting game character      │
│      select screen. Cheap CSS trick, big aesthetic payoff.  │
└─────────────────────────────────────────────────────────────┘
```

```
┌─────────────────────────────────────────────────────────────┐
│              GOTCHAS TO WATCH FOR                             │
│                                                              │
│  ✗ getUserMedia requires HTTPS (or localhost)                 │
│    └── Netlify provides HTTPS automatically. Local dev       │
│        with `netlify dev` serves on localhost, which also    │
│        works. Do NOT try to test via local IP (192.168.x.x) │
│        — browsers will block camera access.                  │
│                                                              │
│  ✗ iOS Safari webcam quirks                                  │
│    └── <video> MUST have `playsinline` attribute or iOS      │
│        will force fullscreen. Also needs `autoplay`.         │
│        getUserMedia on iOS requires a user gesture first     │
│        (button tap) — don't try to auto-start the camera    │
│        on page load. Start it when user clicks "FACE THE    │
│        AI" or "I'M READY".                                   │
│                                                              │
│  ✗ iOS Safari input zoom                                     │
│    └── Any <input> with font-size below 16px causes iOS     │
│        to auto-zoom the viewport. Set all inputs to          │
│        font-size: 16px minimum. Add to viewport meta:        │
│        maximum-scale=1 to prevent pinch zoom during game.   │
│                                                              │
│  ✗ face-api.js model loading can fail on slow connections    │
│    └── Show a clear loading state. Retry button if it fails.│
│        Models are small (~270 KB total) but CDN can hiccup. │
│        Cache models in the browser after first load          │
│        (face-api.js does this automatically via browser      │
│        HTTP cache — just make sure CDN headers allow it).   │
│                                                              │
│  ✗ face-api.js landmark indices                              │
│    └── The 68-point model uses specific indices:             │
│        Left eye: 36, 37, 38, 39, 40, 41                     │
│        Right eye: 42, 43, 44, 45, 46, 47                    │
│        Nose tip: 30                                          │
│        Access via: detection.landmarks.positions[index]      │
│        NOT via .getLeftEye() — that returns different         │
│        groupings. Use raw positions array for EAR calc.     │
│                                                              │
│  ✗ Blob race conditions on matchmaking                       │
│    └── Two players could both read the queue as empty and    │
│        both create new rooms. Mitigate by checking queue     │
│        again after creating — if someone else is also now    │
│        waiting, join their room instead (smaller roomCode    │
│        wins as tiebreaker).                                  │
│                                                              │
│  ✗ Room cleanup                                              │
│    └── Old rooms will accumulate in blobs. Add a TTL check:  │
│        any room older than 1 hour and not "playing" gets     │
│        deleted on next access. Don't build a separate        │
│        cleanup job — just clean lazily.                      │
│                                                              │
│  ✗ WebRTC onicecandidate fires many times                    │
│    └── Each candidate is a separate event. Batch them:       │
│        collect for 200ms into an array, then POST all at     │
│        once. Opponent picks up the array on next poll.       │
│        Track lastIceIndex so you don't re-process old ones. │
│                                                              │
│  ✗ Mobile browser tab backgrounding                          │
│    └── Some mobile browsers pause JS when tab is             │
│        backgrounded — detection stops, timers freeze.        │
│        Listen for document visibilitychange. If              │
│        document.hidden fires during "playing" state,         │
│        show warning toast: "Don't switch tabs!"              │
│        Consider it a loss if hidden for > 3 seconds.        │
│                                                              │
│  ✗ Two tabs on same machine (for testing)                    │
│    └── You can open two tabs. Each gets its own webcam       │
│        stream. WebRTC will work over localhost. Both tabs    │
│        can be different "users" for testing. However,        │
│        two tabs sharing one camera may lower framerate —    │
│        this is fine for testing, not a production issue.     │
│                                                              │
│  ✗ Audio feedback loop                                       │
│    └── Local video MUST be muted (you hear yourself echo).   │
│        Remote video is NOT muted (you hear opponent).        │
│        On mobile, users should wear earbuds to prevent       │
│        speaker → mic feedback. No echo cancellation          │
│        needed for MVP — browser's built-in AEC handles it   │
│        for most devices.                                     │
│                                                              │
│  ✗ SVG face scaling on different screens                     │
│    └── Use viewBox on the SVG so it scales responsively.     │
│        Container div should match the aspect ratio of the    │
│        video feed (~4:3) so they look balanced side by side  │
│        (desktop) or stacked (mobile).                        │
│                                                              │
│  ✗ Netlify Blobs import                                      │
│    └── In Netlify functions, import like this:               │
│        import { getStore } from "@netlify/blobs";            │
│        const store = getStore("users");                      │
│        await store.get("key", { type: "json" });             │
│        await store.setJSON("key", value);                    │
│        The store name is just a string — no setup needed.   │
│        Blobs are available automatically in Netlify          │
│        functions, both locally (netlify dev) and deployed.  │
│                                                              │
│  ✗ Clip-path on buttons eats the border                      │
│    └── clip-path cuts the element, including its border.     │
│        To get a visible border on a clipped button, use      │
│        a wrapper <div> with the clip-path and background     │
│        set to the border color, with the inner button        │
│        inset by 1px. Or use outline instead of border        │
│        (outline is drawn outside the clip). Or use           │
│        drop-shadow filter instead of box-shadow (drop-shadow │
│        respects clip-path, box-shadow doesn't).              │
│        Simplest approach: skip clip-path, use a slanted      │
│        SVG background or just use straight corners with a    │
│        subtle diagonal notch via border-image or linear      │
│        gradient. Test early — this WILL bite you.            │
└─────────────────────────────────────────────────────────────┘
```

---

# Part 6: Local Development

```
RUNNING LOCALLY
═══════════════════════════════════════════════════════════

  1. npm install
  2. netlify dev

  That's it. Netlify CLI serves static/ and runs functions
  locally. Opens at http://localhost:8888.

  Blobs work locally too — netlify dev provides a local
  blob store automatically (stored in .netlify/ folder).

  To test AI mode:
  - Just one tab, click "FACE THE AI"
  - Best first test — no room codes, no second player

  To test multiplayer locally:
  - Open two browser tabs (both on localhost:8888)
  - Register two different usernames
  - Create arena in tab 1, enter code in tab 2
  - Both tabs access the same webcam (fine for testing)

  To test on your phone (while developing):
  - You CANNOT access localhost from your phone
  - Instead: deploy to Netlify first (netlify deploy --prod)
  - Then test on phone via the live URL (HTTPS = camera works)
  - Or use a tunnel: npx localtunnel --port 8888
    (gives you an HTTPS URL your phone can access)
```

---

# Part 7: .gitignore

```
node_modules/
.netlify/
.env
/HIDDEN/ (this is a folder that we can hold any temporary things/files/notes in .. for testing etc)
```

---

# Appendix A: Goals & Priorities

```
MACRO GOALS
═══════════════════════════════════════════════════════════

  1. THE GAME MUST FEEL FAIR
     If a player loses, they must feel they deserved it.
     A single false positive blink detection that causes
     an unfair loss will make people close the tab forever.
     Blink detection accuracy is THE top priority.

  2. AI MODE MUST WORK PERFECTLY ON ITS OWN
     Most people will try the game alone first. AI mode
     is the first impression. It must be satisfying,
     feel complete, and work on every device without
     any setup. If AI mode is broken, no one will
     bother trying multiplayer.

  3. THE AESTHETIC MUST FEEL LIKE A GAME, NOT A WEB APP
     Glitch samurai energy. Chromatic aberration. Scan lines.
     Orbitron font. Clipped corners. Neon glow. This is NOT
     a SaaS dashboard. Every screen should feel like you're
     inside a cyberpunk fighting game. The 60/30/10 color
     rule keeps it from becoming a mess — deep void blacks
     dominate, muted purples structure, neon cyan/magenta pop.

  4. MOBILE-FIRST, NOT MOBILE-ALSO
     Design for phones first. Test on phones first.
     Desktop layout is a media query enhancement.
     Touch targets, font sizes, video sizing — all
     phone-first. Most users will play on mobile.

  5. SIMPLE TO BUILD WITH CLAUDE CODE
     The project must be buildable by dropping this
     doc into Claude Code. No ambiguity. No missing
     pieces. Each file is small and focused enough
     that Claude Code won't get confused or produce
     spaghetti.
```

```
MICRO GOALS (in priority order)
═══════════════════════════════════════════════════════════

  PRIORITY 1 — MUST WORK PERFECTLY
  ┌──────────────────────────────────────────────────────┐
  │  • Blink detection accuracy                          │
  │    No false positives. Calibration. Grace period.    │
  │    3-consecutive-frame confirmation. Gaze check.     │
  │    If this is broken, the game is broken.            │
  │                                                      │
  │  • AI mode end-to-end                                │
  │    Camera → SVG face → countdown → play → result.    │
  │    Must work on phone and desktop. No server calls.  │
  │                                                      │
  │  • Mobile layout and touch UX                        │
  │    Videos stack properly. Buttons are tappable.       │
  │    No input zoom on iOS. Camera works on mobile      │
  │    browsers. playsinline attribute on all <video>.   │
  │                                                      │
  │  • Visual identity                                   │
  │    Glitch text effect. Scan line overlays. Cyan/      │
  │    magenta player identity. Orbitron headings.        │
  │    The app must LOOK like the game it is.            │
  └──────────────────────────────────────────────────────┘

  PRIORITY 2 — SHOULD WORK WELL
  ┌──────────────────────────────────────────────────────┐
  │  • Multiplayer room creation + joining via code      │
  │    The "invite a friend" flow. WebRTC connects,      │
  │    video + audio works, game plays to completion.    │
  │                                                      │
  │  • Leaderboard reads and writes correctly            │
  │    Wins increment. Sorted properly. Shows on lobby.  │
  │    Only human vs human counts.                       │
  │                                                      │
  │  • Auth (register + login) works                     │
  │    Username persists in localStorage. Can log back    │
  │    in with email. Simple but functional.             │
  │                                                      │
  │  • Countdown + grace period + calibration flow       │
  │    Feels polished. Clear visual feedback.             │
  │    "CALIBRATING..." in Orbitron during grace period. │
  └──────────────────────────────────────────────────────┘

  PRIORITY 3 — NICE TO HAVE
  ┌──────────────────────────────────────────────────────┐
  │  • "Find Opponent" random matchmaking                │
  │    Cool feature but realistically no one will be     │
  │    in the queue for an MVP. Build it, but don't      │
  │    spend extra time polishing edge cases here.       │
  │                                                      │
  │  • Rematch flow                                      │
  │    Nice UX but not critical. Both players need       │
  │    to click rematch — detected via polling.          │
  │                                                      │
  │  • Win/lose animations                               │
  │    Cyan glow + glitch "VICTORY" for win.             │
  │    Magenta vignette + shake for lose.                │
  │    Nice polish but game works without it.            │
  │                                                      │
  │  • AI micro-movements (pupil drift, squints,         │
  │    chromatic flicker on iris)                         │
  │    Makes the AI feel alive. But a static SVG face    │
  │    that just blinks on a timer still works.          │
  └──────────────────────────────────────────────────────┘

  PRIORITY 4 — EXPLICITLY OUT OF SCOPE
  ┌──────────────────────────────────────────────────────┐
  │  ✗ TURN server / corporate firewall support          │
  │  ✗ Password hashing or real auth                     │
  │  ✗ Chat / text messaging between players             │
  │  ✗ Spectator mode                                    │
  │  ✗ Tournament brackets                               │
  │  ✗ Multiple rounds / best-of-3                       │
  │  ✗ Custom face-api.js model training                 │
  │  ✗ Recording / replay of matches                     │
  │  ✗ Native app / React Native                         │
  │  ✗ Any build step (webpack, vite, etc.)              │
  └──────────────────────────────────────────────────────┘
```

```
DECISION FRAMEWORK
═══════════════════════════════════════════════════════════

  When building, if you face a tradeoff, use this:

  "Does this make the blink detection more reliable?"
  → YES → Do it.

  "Does this make the mobile experience worse?"
  → YES → Don't do it.

  "Does this match the glitch samurai aesthetic?"
  → If it's a UI element, yes it matters. If it's backend,
    don't waste time on it.

  "Is this a cool feature but not in the priorities?"
  → Skip it. Ship the MVP. Add it later.

  "Should I use a library/service for this?"
  → Only if it's a CDN script tag or an npm install.
    No build steps. No webpack. No React. No Docker.

  "Should I merge these JS files into one?"
  → NO. Keep them separate. Each file has one job.
    detection.js handles detection. webrtc.js handles
    WebRTC. app.js orchestrates. Don't mix concerns.

  "Should I add error handling for [edge case]?"
  → If it's in the detection system: YES, always.
  → If it's in matchmaking: only if it's a 1-line fix.
  → If it's in animations: no.

  "Should I use border-radius anywhere?"
  → Almost never. This is a sharp-corner, clipped-edge
    aesthetic. Round corners feel like a SaaS app.
    The only exception: fully round elements like the
    status dot indicator.

  GOLDEN RULE:
  If someone opens this on their phone, taps "FACE THE AI",
  and plays a full round — that experience must be
  flawless AND look incredible. Everything else is secondary.
```