# WTF Counter — el3ab-player

**Date:** 2026-07-03
**Total WTFs:** 240
**Critical:** 31 | **Major:** 93 | **Minor:** 116

---

## Summary

| Area | Critical | Major | Minor | Total |
|------|----------|-------|-------|-------|
| API Backend (PHP) | 13 | 19 | 12 | 44 |
| Core Modules (JS) | 6 | 22 | 37 | 65 |
| Chess Module | 4 | 12 | 20 | 36 |
| Play/Social/Auth Scenes | 5 | 18 | 17 | 40 |
| Other Games + Profile + Rewards | 3 | 22 | 30 | 55 |

---

## WHY FRIEND INVITES DON'T WORK (3 converging bugs)

1. **multiplayer.js:79** — When `addFriendFromGame` returns `false` (network failure), code shows "already sent" instead of error. User thinks invite was sent but it wasn't.
2. **chess game.js:22 + lobby.js:175** — Color assignment broken: lobby passes `color: params.color` which may be undefined from `friends.php`. Both players default to `'w'` (white), so neither can move as black.
3. **lobby.js:132-133** — Non-host guest immediately enters game before host detects acceptance (0-3.5s desync window). One player is in-game while other is still in lobby.

---

## CRITICAL (31)

### Security — Any User Can Tamper With Any Match

| # | File | Line | Issue |
|---|------|------|-------|
| 1 | api/game.php | 107-161 | `handleGameMove` has NO auth check — any user can modify any match's FEN/moves |
| 2 | api/game.php | 164-186 | `handleResign` never validates caller is a participant — unrelated user triggers `white_wins` |
| 3 | api/game.php | 210-249 | `handleComplete` has NO auth check — any user can complete any match and steal rewards |
| 4 | api/game.php | 215 | `$winners` array from client input passed unchecked — client can claim arbitrary user IDs as winners |
| 5 | api/friends.php | 506-581 | `accept-invite` never verifies accepting user is the target — anyone with match_id can accept |
| 6 | api/friends.php | 583-606 | `decline-invite` never verifies user is the invited target |

### Security — Secrets & CORS

| # | File | Line | Issue |
|---|------|------|-------|
| 7 | config/constants.php | 4-5 | `SUPABASE_SERVICE_KEY` (admin bypass) hardcoded in source — repo leak = all data compromised |
| 8 | config/constants.php | 3 | `SUPABASE_ANON_KEY` hardcoded in tracked file |
| 9 | All API files | 4 | `Access-Control-Allow-Origin: *` on every endpoint — CSRF via CORS |
| 10 | api/friends.php | 76 | Search query interpolated into PostgREST filter — filter injection |
| 11 | api/friends.php | 88 | `$_GET['ids']` passed directly into `in.()` filter — filter injection |
| 12 | api/auth.php | 129-187 | Guest account creation with no rate limiting — unlimited spam accounts |
| 13 | api/game.php | 408-462 | `find-active-match` accepts arbitrary `player_id` — info disclosure |
| 14 | login.js + register.js | 89-90 | Google OAuth has no `state` parameter or PKCE — CSRF on callback |

### Game-Breaking Bugs

| # | File | Line | Issue |
|---|------|------|-------|
| 15 | multiplayer.js | 79-83 | Friend request failure shows "already sent" success — silent failure |
| 16 | multiplayer.js | 148-155 | Emotes sent via `action: 'move'` overwriting `game_state` — corrupts match state |
| 17 | multiplayer.js | 210-219 | Rematch request also overwrites `game_state` — same corruption risk |
| 18 | chess/game.js | 22 + lobby.js:175 | Friend invite color undefined → both players see white → nobody can move as black |
| 19 | chess/logic/live.js | 1-94 | Entire file is unused dead code (old Supabase realtime approach) |
| 20 | chess/game.js | 913 | `matchLive.session.destroy()` incorrect call — may throw or fail silently |
| 21 | chess/game.js | 588 | `lastKnownMoveCount` module-level — stale state persists between games |
| 22 | modal.js | 9 | Only one `resolvePromise` stored globally — stacked modals hang forever |
| 23 | net.js | 22 | `res.json()` called unconditionally — HTML error pages throw cryptic SyntaxError |
| 24 | match-session.js | 175-179 | `currentSession` null dereference in visibility callback after `destroy()` |
| 25 | ludo/game.js | 1442 | `isLoser` never defined — win celebration fires even on loss |
| 26 | backgammon/rules.js | 258 | `VARIANTS.standard` doesn't exist — crash on gammon/backgammon scoring |
| 27 | backgammon/rules.js | 269 | `getPipCount` hardcodes sheshbesh variant — wrong for mahbousa/thirtyone |
| 28 | lobby.js | 132-133 | Guest enters game immediately before host detects — desync window |
| 29 | lobby.js | 165 | `setTimeout(startGame, 1500)` fires even after navigation — pushes scene onto wrong stack |
| 30 | lobby.js | 129+136 | Poll with no concurrency guard — slow network triggers `startGame` multiple times |
| 31 | chat.js | 225 | `JSON.parse(msg.metadata)` with no try/catch — one bad message crashes entire list |

---

## MAJOR (93)

### Race Conditions & State Corruption

| # | File | Line | Issue |
|---|------|------|-------|
| 32 | api/multiplayer.php | 41-43 | `join_room` read-then-write on players array — simultaneous joins overwrite each other |
| 33 | api/matchmaking.php | 148-166 | Match insert success + null matchId → opponent stuck in "matched" forever |
| 34 | queue.js | 81-88 | `pollForMatch` uses setInterval with async — overlapping in-flight requests |
| 35 | match-session.js | 104-105 | `onConnectionRestored` fires on EVERY successful poll, not only after disconnect |
| 36 | match-session.js | 147-155 | Opponent jumps from active to abandoned without disconnect warning |
| 37 | match-session.js | 160-186 | `visibilitychange` listener never removed on `destroy()` — memory leak |
| 38 | net.js + match-session.js | — | Overlapping 401s trigger dual token refresh — single-use refresh token invalidates itself |
| 39 | chess/game.js | 812 | `lastDrawOfferHandled` module-level persists across games |
| 40 | chess/game.js | 282-293 | Recovery and live setup conflict on clock state |
| 41 | domino/game.js | 609 | `syncPassToServer` pre-increments moveCount before network call — desync on failure |
| 42 | domino/game.js | 837 | `drawFromBoneyard` rapid syncs in while loop overwrite each other |
| 43 | rewards/achievements.js | 36-44 | Recursive re-mount while still rendering |
| 44 | shop/browse.js | 90 | Post-purchase local balance ignores server response — concurrent purchases show wrong amount |

### Broken Game Logic

| # | File | Line | Issue |
|---|------|------|-------|
| 45 | api/multiplayer.php | 37 | Default table is `ludo_matches` for non-domino games — chess writes to wrong table |
| 46 | api/game.php | 198-204 | `handleDraw` overwrites entire `game_state` with `{draw_accepted: true}` — destroys all state |
| 47 | chess/game.js | 926-927 | `opponentRating` uses botElos even in live mode — wrong rating shown |
| 48 | chess/game.js | 19 | Module-level singletons never cleaned — old clock timers keep firing on re-mount |
| 49 | chess/game.js | 514-516 | Bot move retry has no limit — infinite 2s loop if API is down |
| 50 | chess/game.js | 289 | `onGameEnd` always returns 'loss'+'abandon' regardless of actual result |
| 51 | chess/canvas/board.js | 506-513 | `resize()` uses 400px max but `setup` uses 500px — board shrinks permanently |
| 52 | chess/analysis.js | 13 | Analysis shares engine singleton with game — mounting both corrupts state |
| 53 | chess/result.js | 171-174 | Client optimistically credits coins — server may double-credit |
| 54 | chess/result.js | 135 | Rematch always sends `rapid_10_0` regardless of actual time control used |
| 55 | ludo/game.js | 388-389 | Bot indicator grabs wrong `<span>` element — corrupts turn arrow text |
| 56 | backgammon/game.js | 628 | `Object.assign(game, data.game_state)` clobbers all local properties |
| 57 | profile/view.js | 354-359 | Spectate routes domino/backgammon to `chess-spectate` — wrong game shown |
| 58 | tournaments/hub.js | 206 | "Play Now" hardcodes `chess-game` regardless of tournament's game_key |
| 59 | shop/browse.js | 64 | `canAfford` only checks coins, ignores gems — players buy gem items for free |
| 60 | shop/browse.js | 66 | `document.getElementById('overlay')` assumes element exists — crash if missing |

### UX Dead Ends & Broken Flows

| # | File | Line | Issue |
|---|------|------|-------|
| 61 | table.js | 383 | "My Games" always navigates to chess-history regardless of selected game |
| 62 | table.js | 271 | Animation references undefined `slideUpBounce` keyframes |
| 63 | friends.js | 73-77 | Timers never cleaned up — re-mounting stacks intervals indefinitely |
| 64 | friends.js | 182 | "Groups" tab pushes new scene instead of loading inline — breaks tab pattern |
| 65 | notifications.js | 33-38 | Notification items have NO click handlers — dead-end display |
| 66 | notifications.js | 20 | `unreadCount = 0` on mount — marks all read before user sees them |
| 67 | queue.js | 74-76 | Failed `joinQueue` silently pops scene — zero error feedback |
| 68 | chat.js | 246 | `if (scrollToBottom || true)` — always yanks to bottom, user can't read history |
| 69 | chat.js | 132-133 | Poll fetches full history every 3s — `lastTime` computed but never sent |
| 70 | scene.js | 47 | `push()` silently drops navigation during 300ms transition — lost user intent |
| 71 | scene.js | 97 | `innerHTML = ''` without calling unmount on current scene — leaks intervals/listeners |
| 72 | scene.js | 86-90 | If new scene mount() throws, user sees blank screen with no recovery |
| 73 | multiplayer.js | 97 | Empty catch on mute — no error feedback |
| 74 | multiplayer.js | 115 | Empty catch on block — no error feedback |
| 75 | multiplayer.js | 118-121 | Report action fires with one tap, no confirmation dialog |
| 76 | backgammon/game.js | 688 | Quit has no confirmation — accidental tap loses match |
| 77 | backgammon/game.js | 655 | Chat messages shown locally but never synced — opponent can't see them |
| 78 | profile/settings.js | 93-105 | Delete account modal opens then fetches — incomplete UI while loading |
| 79 | rewards/daily.js | 29-36 | UTC date comparison — wrong claim status for non-UTC timezone users |
| 80 | shop/browse.js | 88 | No "already owned" indicator — items remain purchasable forever |

### Architecture & Performance

| # | File | Line | Issue |
|---|------|------|-------|
| 81 | api/friends.php | 446-472 | `check-invites` queries ALL waiting domino matches — O(N) table scan |
| 82 | api/friends.php | 475-500 | Same full-table-scan for ludo invites |
| 83 | api/chat.php | 57-84 | N+1 query: separate DB call per friendship for unread counts |
| 84 | api/chat.php | 87-121 | N+1 query: separate DB call per friendship for last message |
| 85 | includes/auth.php | 30-46 | Ban check DB query on EVERY authenticated request |
| 86 | includes/auth.php | 63-66 | `getUserId()` calls `verifyToken()` again — double HTTP call per request |
| 87 | multiplayer.js + match-session.js | — | Two competing disconnect detection systems with different thresholds |
| 88 | match-session.js + realtime.js | — | HTTP polling every 2s even though WebSocket exists — redundant traffic |
| 89 | friends.js | 93-99 | `checkInvites()` fires 2 requests every 5 seconds — no caching |
| 90 | challenge.js + friends.js + chat.js | — | Invite dialog copy-pasted 3 times with diverging behaviors |
| 91 | lobby.js | 8-10 | Module-level mutable state shared across instances — navigation corruption |
| 92 | profile/view.js | 117 | Avatar upload bypasses `net` module — skips auth refresh/error handling |
| 93 | realtime.js | 37-44 | Only handles `postgres_changes` — subscription failures completely silent |

### Backend Logic Bugs

| # | File | Line | Issue |
|---|------|------|-------|
| 94 | api/auth.php | 260-261 | `handleDeleteAccount` only deletes profile — orphans all related data |
| 95 | api/friends.php | 299-398 | Invite creates match immediately but no notification for decline/expiry |
| 96 | api/game.php | 275-305 | Tournament report uses `$_SERVER['HTTP_HOST']` (spoofable) + `@file_get_contents` (silent failure) |
| 97 | api/game.php | 392 | `array_is_list()` requires PHP 8.1+ — fatal on older versions |
| 98 | api/multiplayer.php | 23 | Room code has no uniqueness check — collision creates broken state |
| 99 | multiplayer.js | 232-237 | Friend request error matching relies on exact English substrings |
| 100 | domino/game.js | 396 | `dealAndSyncToServer` swallows sync error — player 2 never gets tiles |

### Hardcoded UI (Theming Violations)

| # | File | Line | Issue |
|---|------|------|-------|
| 101 | multiplayer.js | 27, 60 | Inline colors throughout opponent menu |
| 102 | chess/game.js | 103-173 | Massive inline HTML with hardcoded colors |
| 103 | challenge.js | 20-36, 167 | Full CSS with hardcoded colors |
| 104 | lobby.js | 91-114 | Full CSS with hardcoded colors |
| 105 | modal.js | 33-37, 96-110 | Modal container/buttons all hardcoded |
| 106 | realtime.js | 2, 5 | URL and anon key hardcoded — no env switching |

### Misc Major

| # | File | Line | Issue |
|---|------|------|-------|
| 107 | multiplayer.js | 181-203 | `startDisconnectWatch` duplicates match-session.js logic |
| 108 | chess/game.js | 186 | Board `canSelect` uses stale closure after recovery color change |
| 109 | chess/clock.js | 18-29 | `start()` can produce negative drift on loaded devices |
| 110 | lobby.js (guest) | 130-133 | Guest calls `startGame()` without verifying match exists or is in_progress |
| 111 | api/matchmaking.php | 40-43 | Anti-abuse returns 200 with `{error}` instead of HTTP 429 |
| 112 | includes/supabase.php | 13-28 | `supabaseRpc` returns null on failure — callers don't null-check |
| 113 | backgammon/game.js | 48 | `params.opponentName` injected into innerHTML — XSS vector |
| 114 | friends.js | 341 | Profile JSON in `data-profile` attribute — potential XSS |
| 115 | api/matchmaking.php | 59 | Rating falls back to `elo_rapid` for all time controls |
| 116 | rewards/daily.js | 10 | `DAY_REWARDS` hardcoded client-side — diverges from server |

---

## MINOR (116)

### i18n / Hardcoded Arabic

| # | File | Line | Issue |
|---|------|------|-------|
| 117 | multiplayer.js | 36 | Fallback opponent name `'خصم'` not using `t()` |
| 118 | multiplayer.js | 62-67 | Mixed i18n: some calls use `t()`, others are raw Arabic |
| 119 | chess/game.js | 239-240 | Draw rejection message Arabic-only |
| 120 | chess/game.js | 253-257 | Draw offer sent message Arabic-only |
| 121 | chess/result.js | 25-32 | Reason text translations are inline Arabic |
| 122 | ludo/game.js | 32 | `PLAYER_NAMES` array uses Arabic strings not through `t()` |

### Hardcoded Emojis (Theming Rule Violations)

| # | File | Line | Issue |
|---|------|------|-------|
| 123 | table.js | 36 | `👋` greeting emoji not using `emoji()` |
| 124 | table.js | 43 | Quick-action gradients use hardcoded hex |
| 125 | multiplayer.js | 63 | `➕` emoji hardcoded |
| 126 | domino/game.js | 112-113 | `👤`/`🤖` hardcoded |
| 127 | domino/game.js | 124 | `📦` boneyard icon hardcoded |
| 128 | domino/game.js | 166 | `😄` emote button hardcoded |
| 129 | domino/game.js | 662 | Emote map uses raw emoji strings |
| 130 | ludo/game.js | 136 | `✕` and `😄` in buttons |
| 131 | ludo/game.js | 471 | Bot emote array raw emojis |
| 132 | ludo/game.js | 1141 | `⚡` turbo mode |
| 133 | rewards/achievements.js | 143 | `tierIcon()` uses `⭐🌟💫👑` |
| 134 | rewards/achievements.js | 118 | Achievement card colors hardcoded |
| 135 | shop/browse.js | 41 | All items show same `🎨` emoji |

### Hardcoded Values / Magic Numbers

| # | File | Line | Issue |
|---|------|------|-------|
| 136 | multiplayer.js | 37 | Default rating `1200` magic number |
| 137 | chess/game.js | 108 | Bot portrait URL hardcoded to stockfishapi domain |
| 138 | chess/game.js | 235 | Bot draw acceptance `Math.random() < 0.5` — not personality-aware |
| 139 | chess/game.js | 922 | Coins/XP rewards hardcoded (win:50, draw:20, loss:10) |
| 140 | chess/game.js | 957-960 | Failsafe rating values (+12, +1, -8) hardcoded |
| 141 | time-select.js | 6-43 | Category colors and backgrounds hardcoded |
| 142 | domino/game.js | 179 | Background colors hardcoded |
| 143 | ludo/game.js | 48 | `TURN_TIMEOUT = 15000` not configurable |
| 144 | ludo/game.js | 627 | Polling interval `1500`ms magic number |
| 145 | ludo/game.js | 384 | Turbo multiplier `0.4` magic number |
| 146 | ludo/game.js | 1297 | Dice shake interval `55`ms magic number |
| 147 | login.js | 9 | `SUPABASE_URL` duplicated in login + register |
| 148 | api/game.php | 294-295 | Tournament URL from `HTTP_HOST` — breaks behind proxy |

### Dead Code

| # | File | Line | Issue |
|---|------|------|-------|
| 149 | table.js | 59 | `const disabled = false` — can never trigger disabled styling |
| 150 | ludo/logic/live.js | 1-44 | Entire module dead — game.js uses own polling |
| 151 | api/game.php | 251-266 | `calculateElo`/`getRatingColumn`/`getTimeControlType` never called |
| 152 | match-session.js | 12 | `RECONNECT_GRACE = 5000` defined but never used |
| 153 | scene.js | 99 | `isBack` parameter received but never used |

### UX Polish Issues

| # | File | Line | Issue |
|---|------|------|-------|
| 154 | time-select.js | 71 | `:hover` on touch device — no effect |
| 155 | friends.js | 655 | Error shows "no news" — misleads instead of reporting failure |
| 156 | queue.js | — | No timeout on matchmaking queue — timer counts forever |
| 157 | hud.js | 94-101 | Guest sees 4 locked tabs — tease UX wasting space |
| 158 | hud.js | 165-169 | Coin animation and count update fire simultaneously |
| 159 | profile/view.js | 478 | Unrated players show `1200` instead of "unrated" |
| 160 | profile/settings.js | 77 | Sound plays after user toggles audio OFF |
| 161 | profile/view.js | 25 | Profile fetches fresh data every mount — no cache |
| 162 | rewards/achievements.js | 33 | Fetch error shows empty page — no retry option |
| 163 | chess/analysis.js | 104-106 | Analysis jumps to final position — no navigation |
| 164 | challenge.js | 210 | Chat invite notification failure silenced |
| 165 | notifications.js | 18 | Bad API shape turns entire response into "notifications" |
| 166 | lobby.js | 86 | "Start" button visible for guest but auto-start already fires |
| 167 | tournaments/hub.js | 113 | Draft tournaments visible to regular players |
| 168 | shop/browse.js | — | No pagination — all items load at once |

### Minor Bugs

| # | File | Line | Issue |
|---|------|------|-------|
| 169 | multiplayer.js | 130-133 | Outside-click listener never cleaned up on scene transition |
| 170 | multiplayer.js | 164 | `JSON.parse(gameState)` no try/catch |
| 171 | net.js | 63 | Variable `refreshToken` shadows function name |
| 172 | net.js | 77 | Token refresh failure silently swallowed |
| 173 | net.js | 25-34 | No handling for 403/429/5xx — all generic errors |
| 174 | net.js | 44 | Query param keys not URI-encoded |
| 175 | net.js | 6-38 | No request timeout — hung server = permanent loading |
| 176 | store.js | 49 | Deep path set overwrites intermediate non-object values |
| 177 | store.js | 20-26 | Shallow merge loses new nested keys from stale persisted data |
| 178 | store.js | 29-39 | `persist()` on every `set()` — no debouncing |
| 179 | hud.js | 134 | Toast timeout not cleared on button click |
| 180 | bus.js | — | No `removeAllListeners()` for session reset |
| 181 | realtime.js | 57-58 | Reconnect races with manual disconnect |
| 182 | realtime.js | 78 | No duplicate callback detection — fires multiple times |
| 183 | realtime.js | 88 | `leaveChannel` dropped when WebSocket disconnected |
| 184 | realtime.js | 130 | Topic split truncates filters with colons |
| 185 | realtime.js | 30-31 | Re-join on reconnect doesn't wait for confirmation |
| 186 | realtime.js | 21 | Auth token in WebSocket URL query string — visible in logs |
| 187 | match-live.js | 25-28 | `lastMoveCount` tracked but never gates anything |
| 188 | match-live.js | 81-88 | `recover()` misleading name — doesn't actually reconnect |
| 189 | match-session.js | 95,131,174 | Endpoint ternary duplicated 3 times |
| 190 | scene.js | 91 | `isTransitioning` released by hardcoded 300ms regardless of actual animation |
| 191 | modal.js | 144-150 | Button click doesn't remove backdrop listener |
| 192 | modal.js | 47 | Forced reflow for animation — jank on low-end devices |
| 193 | chess/canvas/board.js | 21 | Singleton — only one board instance possible |
| 194 | chess/logic/engine.js | 4 | `Chess()` assumes global — no import, relies on script order |
| 195 | chess/game.js | 644 | `game_state` parsed multiple independent times per poll |
| 196 | chess/game.js | 332 | `bus.emit('game:started')` fires before live session initialized |
| 197 | chess/game.js | 437-439 | Clock only starts on moveCount===1 — recovery misses this |
| 198 | chess/clock.js | 80-86 | `bullet_0_1` returns `time: 0` |
| 199 | chess/canvas/board.js | 284-287 | No listener cleanup on destroy — memory leak during drag |
| 200 | chess/canvas/board.js | 265-278 | `clientHeight` may be 0 before layout — falls back to magic numbers |
| 201 | login.js | 72 | `audio.play('coin', 'reward')` — unclear second argument |
| 202 | login.js | 93 | Guest handler shadows outer `btn` variable |
| 203 | register.js | 16 | Username validation HTML-only — no regex for special chars |
| 204 | friends.js | 11 | `activeTab` module-level leaks between mounts |
| 205 | queue.js | 11-16 | `unmountQueue()` async `leaveQueue()` without await |
| 206 | chat.js | 8-12 | Module-level mutable state — corruption on re-mount |
| 207 | chat.js | 115 | 3s polling instead of WebSocket |
| 208 | domino/game.js | 949 | Non-cancellable timeout accesses stale state on unmount |
| 209 | domino/game.js | 1025 | `winners` uses potentially empty `state.players` |
| 210 | ludo/game.js | 687-690 | Document listener never cleaned up on unmount |
| 211 | backgammon/game.js | 141 | `window.resize` listener leaks on crash |
| 212 | backgammon/game.js | 692-867 | CSS injected as raw `<style>` on every mount |
| 213 | domino/game.js | 174-336 | 160 lines CSS injected via JS on every mount |
| 214 | backgammon/logic/rules.js | 377-379 | Catch-all swallows all exceptions — hides move bugs |
| 215 | rewards/daily.js | 118-119 | `juice.coinFlyTo` targets `#hud-coins` which may not exist |
| 216 | profile/settings.js | 52 | Duplicate `display:none` in inline styles |

### Backend Minor

| # | File | Line | Issue |
|---|------|------|-------|
| 217 | api/matchmaking.php | 125 | `rand(0,1)` not cryptographically fair for rated games |
| 218 | api/friends.php | 315-398 | 120s invite timeout — offline players can never see invite |
| 219 | api/notifications.php | 36-49 | PATCH falls through to 405 for unknown actions |
| 220 | api/ratings.php | 17 | Any user can query anyone's rating history |
| 221 | api/auth.php | 50 | Password only checks length >= 6 |
| 222 | api/auth.php | 55 | Username no invalid char / profanity check |
| 223 | api/auth.php | 42-44 | No email format validation |
| 224 | api/friends.php | 221-233 | Report action no rate limiting |
| 225 | api/matchmaking.php | 59 | Rating fallback to rapid for all queues |
| 226 | config/database.php | 111 | Curl failure masked as empty success |

### Cross-Cutting Architecture (Minor)

| # | File | Line | Issue |
|---|------|------|-------|
| 227 | multiplayer.js | 69-70 | `<style>` injected into DOM every menu open |
| 228 | bus.js | 4-5 | Empty arrays accumulate for unsubscribed events |
| 229 | match-live.js | — | Redundant with match-session.js |
| 230 | chess/game.js | 644 | Triple-parse of same game_state per poll |

---

## USER JOURNEY PROBLEMS (Not Bugs, But Bad UX)

| # | Issue | Impact |
|---|-------|--------|
| 231 | No onboarding — new user lands on game grid with no guidance | Users don't know what to do |
| 232 | Friend invite flow: no real-time notification — relies on 5s polling | Invites feel laggy or invisible |
| 233 | Matchmaking queue has no timeout — user waits forever | Users think app is frozen |
| 234 | Notifications are display-only — can't navigate to source | Dead end, user can't act on them |
| 235 | Chat yanks to bottom every 3s — can't scroll up to read history | Unusable for long conversations |
| 236 | Game result screen shows hardcoded ratings — may not match server | Confusing/misleading feedback |
| 237 | Guest sees locked tabs everywhere — frustrating tease, no clear CTA to sign up | Conversion killer |
| 238 | "My Games" always goes to chess history even if playing domino | Broken cross-game UX |
| 239 | Tournament "Play Now" always launches chess regardless of game type | Non-chess tournaments unplayable |
| 240 | Shop has no "owned" state — can re-purchase items | Confusing and potentially exploitable |

---

## TOP 10 FIXES (Priority Order)

1. **Auth checks on game.php endpoints** — any user can corrupt any match right now
2. **Fix friend invite color assignment** — both players get white, game is stuck
3. **Fix lobby desync** — add handshake before entering game
4. **Move secrets to env vars** — service key in source code is a ticking bomb
5. **Fix emote/rematch state corruption** — don't overwrite game_state for non-move actions
6. **Restrict CORS** — `*` allows any site to make authenticated requests
7. **Fix `isLoser` in ludo** — win celebration on every game end
8. **Fix backgammon VARIANTS.standard crash** — game scoring is broken
9. **Add concurrency guards to polling** — multiple game-breaking race conditions
10. **Fix modal stacking** — Promise hangs forever if two modals overlap

---

## WTF DENSITY (issues per file)

| File | WTFs | Lines | Density |
|------|------|-------|---------|
| chess/scenes/game.js | 22 | ~960 | 1 per 44 lines |
| api/game.php | 14 | ~460 | 1 per 33 lines |
| api/friends.php | 12 | ~610 | 1 per 51 lines |
| multiplayer.js | 18 | ~240 | 1 per 13 lines |
| lobby.js | 8 | ~170 | 1 per 21 lines |
| ludo/scenes/game.js | 11 | ~1450 | 1 per 132 lines |

**Worst file: `multiplayer.js` — 1 WTF every 13 lines.**

---

## SCHEMA vs CODE MISMATCH AUDIT (Zero Mismatches Target)

**Database:** Supabase PostgreSQL on `safe-supabase-db`
**Connection:** PHP uses `supabaseService()` = service_role key (bypasses ALL RLS)

### RLS vs Service Key — THE FUNDAMENTAL PROBLEM

The `matches` table has good RLS:
- INSERT: only if you're white_player_id or black_player_id
- UPDATE: only if you're a player in the match
- SELECT: everyone can see

**BUT** the PHP API uses `supabaseService()` which BYPASSES RLS entirely. So:
- `game.php:handleGameMove` → uses service key → NO auth check → any user can edit any match (**CONFIRMED CRITICAL**)
- `game.php:handleResign` → uses service key → NO auth check → any user can resign any match (**CONFIRMED CRITICAL**)
- `game.php:handleComplete` → uses service key → passes client-provided `$winners` to `complete_match` RPC → any user steals rewards (**CONFIRMED CRITICAL**)

The RLS was designed for client-side Supabase access (anon key). The PHP API was written assuming it IS the security layer but forgot to add its own auth checks.

### Table Existence — Code vs DB

| Table Code Expects | Exists in DB | Status |
|---|---|---|
| `matches` | Yes | OK |
| `matchmaking_queue` | Yes | OK |
| `profiles` | Yes | OK |
| `friendships` | Yes | OK |
| `friend_messages` | Yes | OK |
| `notifications` | Yes | OK |
| `domino_matches` | Yes | OK |
| `ludo_matches` | Yes | OK |
| `player_blocks` | Yes | OK |
| `cheat_reports` | Yes | OK |
| `rating_history` | Yes | OK |
| `player_achievements` | Yes | OK |
| `cosmetics` | Yes | OK |
| `player_cosmetics` | Yes | OK |
| `platform_theme` | Yes | OK |
| `reward_config` | Yes | OK |
| `player_game_ratings` | Yes | OK |
| `mp_log` | Yes | OK |
| `activity_feed` | Yes | OK |
| `groups` / `group_members` | Yes | OK |
| `tournaments` | Yes | OK |

**Result: 0 missing tables. All tables exist.**

### Column Mismatches — Code Expects vs DB Has

| # | File | What Code Expects | What DB Has | Status |
|---|------|---|---|---|
| 1 | `matchmaking.php:59` | `profiles.elo_blitz` | Column EXISTS (default 1200) | OK — but code falls back to `elo_rapid` when null, not when absent |
| 2 | `game.php` | `matches.result` accepts strings | DB is ENUM `match_result` | **MISMATCH** — if code sends value not in enum, INSERT/UPDATE fails silently |
| 3 | `game.php` | `matches.time_control` accepts strings | DB is ENUM `time_control` | **MISMATCH** — custom time controls or typos cause hard failures |
| 4 | `friends.php` | `profiles.numeric_id` (used in tournament check) | **DOES NOT EXIST** | **MISMATCH** — `check_opponent_timeout` references `pr.numeric_id` which doesn't exist on profiles |
| 5 | `reward_config` | `chess_win_xp`, `chess_loss_xp` | **NOT IN TABLE** | **MISMATCH** — `complete_match` RPC falls back to 5 XP for chess (no xp rows exist for chess) |
| 6 | `reward_config` | `chess_2nd_coins`, `chess_3rd_coins` | **NOT IN TABLE** | **MISMATCH** — chess is 1v1, so "2nd place" keys don't exist; RPC falls back to 5 coins for loss |

### RPC Mismatches — Code Calls vs Function Signature

| # | Code Calls | RPC Exists | Issue |
|---|---|---|---|
| 1 | `complete_match(p_match_id, p_game_key, p_winners, p_reason)` | Yes | OK — but `p_winners` comes from untrusted client (WTF #4 in backend section) |
| 2 | `merge_game_state(p_table, p_match_id, p_patch)` | Yes | OK — atomic merge, handles all 3 tables |
| 3 | `match_heartbeat(p_match_id, p_player_id, p_game_key)` | Yes | OK |
| 4 | `can_player_queue(p_player_id)` | Yes | OK — but called with service key so anyone can check anyone |
| 5 | `log_mp_event(...)` | Yes | OK |
| 6 | `enforce_turn_timeout(...)` | Yes | OK |
| 7 | `award_coins(p_player_id, p_amount, p_reason)` | Yes | OK — called inside `complete_match` |
| 8 | `check_opponent_timeout(p_bracket_id, p_user_numeric_id)` | Yes | **MISMATCH** — references `profiles.numeric_id` which DOESN'T EXIST |

### Index Gaps — Missing Performance Indexes

| # | Table | Missing Index | Why It Matters |
|---|---|---|---|
| 1 | `matches` | No index on `(white_player_id)` or `(black_player_id)` | `find-active-match` does full scan on player lookups |
| 2 | `matches` | No index on `status` | Filtering `in_progress` matches requires full scan |
| 3 | `matches` | No index on `(game_key, status)` | Game-specific queries scan all matches |
| 4 | `matchmaking_queue` | No index on `(game_key, time_control, status)` | Matchmaking opponent search is a full scan |
| 5 | `matchmaking_queue` | No index on `player_id` | `leaveQueue` per-player lookup is a scan |
| 6 | `notifications` | No index on `(user_id, is_read)` | Unread count query scans all user notifications |
| 7 | `friendships` | No index on `addressee_id` | Finding pending requests TO a user scans all friendships |

### Enum Completeness

**`match_result` enum values:**
```
white_wins, black_wins, draw, white_timeout, black_timeout,
white_resign, black_resign, white_abandon, black_abandon,
stalemate, insufficient_material, threefold_repetition,
fifty_moves, mutual_draw, aborted
```

**What code sends:**
- `game.php:handleResign` → `white_resign` / `black_resign` — OK
- `game.php:handleComplete` → whatever client sends in `result` param — **NO VALIDATION** against enum
- `game.php:handleDraw` → `mutual_draw` — OK
- `matchmaking.php` → creates with no result (null) — OK (nullable)

**`time_control` enum values:**
```
bullet_1_0, bullet_1_1, bullet_2_1, blitz_3_0, blitz_3_2,
blitz_5_0, blitz_5_3, rapid_10_0, rapid_10_5, rapid_15_10,
rapid_30_0, classical_60_0, classical_90_30, custom
```

**What code sends:**
- `challenge.js` default: `bullet_1_0` — OK
- `friends.js` invite: `blitz_3_0` — OK
- `time-select.js` offers all common ones — OK
- `matchmaking.php` passes client-provided value — **NO VALIDATION** (non-enum value = hard DB error)

### `complete_match` RPC — Security Hole Confirmed

The RPC trusts `p_winners` array completely:
```sql
v_player_id := (p_winners->>v_placement)::uuid;
PERFORM award_coins(v_player_id, v_coins, ...);
UPDATE profiles SET total_wins = total_wins + 1 WHERE id = v_player_id;
```

An attacker can:
1. Call `game.php?action=complete` with `winners: [their_own_uuid]` and any `match_id`
2. Get 50 coins + 1 win + XP credited to their account
3. Repeat indefinitely for infinite coins

**The RPC doesn't verify the player is actually in the match.**

### `merge_game_state` — Emote/Rematch Corruption CONFIRMED

Code in `multiplayer.js` sends emotes/rematch via `action: 'move'` which calls `merge_game_state`. The RPC does:
```sql
SET game_state = COALESCE(game_state, '{}'::jsonb) || p_patch
```

This is a **shallow merge** (`||` operator). If emote payload has any key that overlaps with real game state (e.g., if both have `last_move_at`), the emote overwrites it. But since emotes use their own keys (`emote`, `rematch_request`), in practice it's safe for chess... **UNLESS** `handleDraw` in `game.php:198-204` does a raw PATCH instead of using `merge_game_state`:

```php
// game.php line 199 - REPLACES entire game_state
$sdb->from('matches')->update(['game_state' => json_encode(['draw_accepted' => true])])->eq('id', $matchId)->execute();
```

This **does not merge** — it REPLACES. So `handleDraw` destroys connection heartbeats, emotes, everything.

### `match_heartbeat` — Connection Tracking Lives in `game_state`

The heartbeat RPC stores connection status inside `game_state.connections`. This means:
- Every heartbeat writes to `game_state`
- Every move also writes to `game_state`
- `handleDraw` obliterates `game_state` (including connections)
- After a draw acceptance, heartbeat data is gone → other player appears "abandoned"

### Missing `reward_config` Rows

| Key Pattern | Exists | Impact |
|---|---|---|
| `chess_win_coins` | Yes (50) | OK |
| `chess_loss_coins` | Yes (10) | OK |
| `chess_draw_coins` | Yes (20) | OK — but RPC never uses it (draw = placement 1 of 2) |
| `chess_win_xp` | **NO** | Falls back to 5 XP |
| `chess_loss_xp` | **NO** | Falls back to 5 XP |
| `ludo_win_coins` | Yes (40) | OK |
| `ludo_loss_coins` | **NO** | Falls back to 5 coins (but `ludo_4th_coins` = 5 covers it) |
| `domino_win_coins` | Yes (40) | OK |
| `domino_loss_coins` | Yes (10) | OK |
| `backgammon_*` | **ALL MISSING** | Falls back to 5 coins / 5 XP for everything |

---

## SCHEMA MISMATCH SCORE

| Category | Count | Severity |
|----------|-------|----------|
| Missing auth checks (service key bypasses RLS) | 3 | CRITICAL |
| Missing DB columns referenced by code | 1 | CRITICAL |
| Missing reward_config rows | 6 | MAJOR |
| Missing indexes (performance) | 7 | MAJOR |
| Enum validation missing in code | 2 | MAJOR |
| Draw handler destroys game_state | 1 | CRITICAL |
| complete_match RPC trusts client winners | 1 | CRITICAL |
| **Total mismatches** | **21** | |

### ZERO-MISMATCH FIXES NEEDED

1. **Add auth checks to game.php** — verify `$userId` is white_player_id or black_player_id before any mutation
2. **Add `chess_win_xp`, `chess_loss_xp` to `reward_config`** — currently all chess XP is hardcoded fallback 5
3. **Add all `backgammon_*` reward rows** — backgammon currently awards 5/5 for everything
4. **Fix `handleDraw`** — use `merge_game_state` RPC instead of raw PATCH that obliterates game_state
5. **Add index on `matches(white_player_id)`, `matches(black_player_id)`, `matches(status)`**
6. **Add index on `matchmaking_queue(game_key, time_control, status)`**
7. **Add index on `notifications(user_id, is_read)`**
8. **Add index on `friendships(addressee_id)`**
9. **Validate `time_control` and `result` values before DB writes** — reject non-enum strings
10. **Remove/fix `check_opponent_timeout`** — it references non-existent `profiles.numeric_id`
11. **Validate `p_winners` in `complete_match` RPC** — verify each UUID is actually a player in the match
