# Multiplayer Rules — EL3AB Platform

This document captures every hard-won lesson from building Chess and Ludo multiplayer. Follow these rules exactly when adding any new multiplayer game.

---

## Architecture Overview

```
┌──────────────┐      POST (2s poll)      ┌──────────────┐
│  Player A    │ ───────────────────────▶  │   game.php   │
│  (Browser)   │ ◀─────────────────────── │   (PHP API)  │
└──────────────┘                           └──────┬───────┘
                                                   │ supabaseService()
┌──────────────┐      POST (2s poll)      ┌──────▼───────┐
│  Player B    │ ───────────────────────▶  │   Supabase   │
│  (Browser)   │ ◀─────────────────────── │  (PostgreSQL) │
└──────────────┘                           └──────────────┘
```

- **No WebSocket** — all multiplayer is polling-based (every 2 seconds)
- **Server is source of truth** — clients are renderers, not authorities
- **Service key for ALL writes** — user tokens are blocked by RLS on the `matches` table

---

## Critical Rule #1: ALWAYS Use `supabaseService()`

**NEVER use `$db` (user token) for match operations.** Supabase RLS blocks user-token UPDATEs on the `matches` table. Every INSERT/UPDATE/DELETE on matches MUST go through:

```php
$sdb = supabaseService();
$sdb->update('matches', [...], ['id' => 'eq.' . $matchId]);
```

If you use `$db->update(...)` it will return success but the row won't change. This is a silent failure that looks like sync is broken.

---

## Critical Rule #2: Poll Data Must ALWAYS Be Forwarded

In `match-live.js`, the `onMove` callback must fire on EVERY poll response, not just when `move_count` changes. Many game events don't increment move_count:
- Resign
- Draw offer / accept / decline
- Emotes
- Game state changes (player joined, etc.)

```js
// WRONG — skips resign/draw/emotes
if (moveCount > lastMoveCount) {
  onMove?.(data);
}

// CORRECT — always forward, track moveCount separately
if (moveCount > lastMoveCount) {
  lastMoveCount = moveCount;
}
onMove?.(data); // always
```

---

## Critical Rule #3: game_state is MERGED, Not Replaced

The `game_state` JSONB column is a shared scratchpad. Multiple systems write to it:
- Moves (last_move info)
- Draw offers (draw_offer, draw_offer_t)
- Emotes (emote key, from, timestamp)
- Pings (ping, t)
- Invites metadata

The server MERGES incoming game_state with existing:

```php
$existing = json_decode($match['game_state'], true) ?: [];
$merged = array_merge($existing, $newState);
$update['game_state'] = json_encode($merged);
```

**Never** overwrite game_state entirely. Always send only the keys you're changing.

---

## The `matches` Table Schema

| Column | Type | Purpose |
|--------|------|---------|
| id | uuid | Primary key |
| game_key | text | 'chess', 'ludo', 'domino', etc. |
| match_type | text | 'ranked', 'friendly', 'bot', 'tournament' |
| white_player_id | uuid | Player 1 (or host) |
| black_player_id | uuid | Player 2 (or guest) |
| status | text | 'waiting', 'in_progress', 'completed' |
| result | text | 'white_wins', 'black_wins', 'draw', 'aborted', 'declined' |
| time_control | text | 'bullet_1_0', 'blitz_3_0', 'blitz_5_0', 'rapid_10_0' |
| initial_time_ms | int | Starting time per player in ms |
| white_time_remaining_ms | int | Current clock for white |
| black_time_remaining_ms | int | Current clock for black |
| current_fen | text | Current board state (chess FEN, or JSON for other games) |
| moves | jsonb | Array of move objects |
| move_count | int | Total moves played (critical for sync detection) |
| game_state | jsonb | Shared scratchpad (emotes, draw offers, pings, metadata) |
| is_rated | bool | Whether this affects Elo |
| tournament_id | uuid | If part of a tournament |
| created_at | timestamptz | When match was created |
| started_at | timestamptz | When first move was made |
| ended_at | timestamptz | When match completed |
| updated_at | timestamptz | Last modification (used for abandon detection) |

---

## Match Lifecycle

```
waiting ──────▶ in_progress ──────▶ completed
  │                                    ▲
  │ (declined)                         │
  └─────────────────▶ completed ───────┘
```

### Status Transitions

| From | To | Trigger |
|------|----|---------|
| waiting | in_progress | Opponent accepts invite |
| waiting | completed (declined) | Opponent declines or timeout |
| in_progress | completed (white/black_wins) | Checkmate, resign, timeout, abandon |
| in_progress | completed (draw) | Agreement, stalemate, repetition, 50-move |
| in_progress | completed (aborted) | Both players inactive 30s+ |

---

## API Actions (game.php)

| Action | Method | Purpose |
|--------|--------|---------|
| start | POST | Create a new match |
| get | POST | Fetch match state (used by polling) |
| move | POST | Submit a move or update game_state |
| resign | POST | Player surrenders |
| draw | POST | Accept a draw (marks match completed) |
| complete | POST | End game with result + Elo calculation |

### Adding a New Game's API

Create `api/{game}-match.php` following the same pattern. Must have:
- `start` — create match row
- `get` — return full match (polling reads this)
- `move` — update match state (merge game_state)
- `resign` — mark completed with winner
- `complete` — final result + stats/Elo

---

## Frontend Architecture

### Files That Handle Multiplayer

| File | Role |
|------|------|
| `core/match-session.js` | Polling loop (2s), ping (10s), disconnect detection |
| `core/match-live.js` | Glue between session and UI — forwards data, manages connection state |
| `core/match-ui.js` | Connection status toasts (reconnecting, opponent disconnected) |
| `core/multiplayer.js` | Opponent bar, emotes, friend add from game, report |

### Polling Flow (Every 2 Seconds)

```
match-session.js polls ──▶ game.php action:get
                                │
                                ▼
                        Returns full match row
                                │
                                ▼
                    match-live.js onOpponentMove(data)
                                │
                                ▼
                    game's handleLivePollData(el, data)
                                │
                    ┌───────────┼───────────────┐
                    ▼           ▼               ▼
              Check moves   Check status   Check game_state
              (new FEN?)    (completed?)   (draw offer? emote?)
```

### What the Game's Poll Handler Must Check

Every game must process ALL of these in its poll handler:

```js
function handleLivePollData(el, data) {
  // 1. Emotes (from game_state)
  checkForEmote(data.game_state, myId);

  // 2. Draw offer (from game_state)
  checkDrawOffer(el, data.game_state, myId);
  checkDrawResponse(el, data.game_state, myId);

  // 3. New move (move_count changed + FEN different)
  if (!isMyTurn && data.move_count > lastKnownMoveCount) {
    // Apply opponent's move, animate, play sound, switch clock
  }

  // 4. Game ended (status changed to completed)
  if (data.status === 'completed' && !gameOver) {
    // Determine if win/loss/draw from data.result
    endGame(result, reason);
  }
}
```

---

## Draw Offer Flow

```
Player A                          Server                         Player B
   │                                │                               │
   │── move(game_state:             │                               │
   │   {draw_offer: A, t: now})────▶│                               │
   │                                │◀── poll ─────────────────────│
   │                                │── returns game_state ────────▶│
   │                                │                               │── shows dialog
   │                                │                               │
   │                                │◀── draw(match_id) ───────────│ (accepts)
   │                                │   sets status:completed       │
   │                                │   result:draw                 │
   │── poll ───────────────────────▶│                               │
   │◀── status:completed,draw ─────│                               │
   │── shows draw result            │                               │
```

### Draw Offer Rules
- `draw_offer` in game_state = who offered (their user ID)
- `draw_offer_t` = timestamp (prevents re-showing same offer)
- Acceptance calls `action: 'draw'` which atomically marks match completed
- Rejection writes `draw_offer: null, draw_declined: userId, draw_declined_t: now`
- Auto-dismiss offer dialog after 30 seconds

---

## Resign Flow

```
Player A                          Server                         Player B
   │                                │                               │
   │── resign(match_id) ──────────▶│                               │
   │                                │ sets status:completed          │
   │                                │ result:black/white_wins        │
   │                                │                               │
   │                                │◀── poll ─────────────────────│
   │                                │── status:completed ──────────▶│
   │                                │                               │── shows opponent won
```

### Resign Rules
- Server determines winner from who resigned (if white resigns → black_wins)
- Must validate match isn't already completed (prevent double-resign race)
- Client shows loss immediately after calling resign (don't wait for poll)
- Opponent detects via `data.status === 'completed'` in poll

---

## Clock Sync

- Clocks run CLIENT-SIDE for responsiveness
- Server stores `white_time_remaining_ms` and `black_time_remaining_ms`
- On every MOVE, client sends updated clock values
- On every POLL, client syncs from server values (server is authority)
- Clock reaching 0 → client calls `action: 'complete'` with timeout result

```js
// After receiving opponent's move:
if (data.white_time_remaining_ms) clock.white = data.white_time_remaining_ms;
if (data.black_time_remaining_ms) clock.black = data.black_time_remaining_ms;
clock.switch(); // Start my clock, stop opponent's
```

---

## Connection & Disconnect Handling

### Timeouts (match-session.js)

| Constant | Value | Meaning |
|----------|-------|---------|
| POLL_INTERVAL | 2000ms | Poll server every 2s |
| PING_INTERVAL | 10000ms | Send ping every 10s |
| DISCONNECT_THRESHOLD | 30000ms | Show "opponent disconnected" after 30s no activity |
| ABANDON_THRESHOLD | 60000ms | Auto-win after 60s no activity |

### Tab Visibility

- When tab goes to background → stop polling (save bandwidth)
- When tab returns → immediately poll once, then resume normal interval
- Reset disconnect timer on tab return (don't false-trigger)

### Auto-Abandon Detection (Server-Side in handleGet)

```php
if (match is in_progress && updated_at > 30s ago) {
    mark as completed, result: aborted
}
```

---

## Friend Invite → Lobby → Game Flow

```
Challenge Scene ──▶ friends.php invite ──▶ Lobby Scene (host waits)
                                               │
                                               │ polls game.php get (status?)
                                               │
Friend's Banner ──▶ friends.php accept-invite ──▶ Lobby Scene (guest)
                                               │
                                               │ status changes to in_progress
                                               ▼
                                          Game Scene (both players)
```

### Invite Rules
- Creates match with `status: 'waiting'`, `match_type: 'friendly'`
- `game_state` contains `invite_from`, `invite_to`, `invite_t`
- Invites expire after 2 minutes (server filters by invite_t)
- Acceptance changes status to `in_progress` + sets `started_at`
- Decline changes status to `completed`, result to `declined`
- Lobby polls every 2s until status changes from 'waiting'

---

## Emote System

### Sending
```js
net.post('game.php', {
  action: 'move',
  match_id: matchId,
  game_state: JSON.stringify({ emote: { key: 'laugh', from: userId, t: Date.now() } })
});
```

### Receiving
- Poll reads `game_state.emote`
- Only show if `emote.from !== myId` (don't show own emotes)
- Only show if `emote.t > lastEmoteTimestamp` (don't re-show)
- Only show if `Date.now() - emote.t < 15000` (expire after 15s)

---

## UI/UX Layout Rules (Mobile-First, RTL)

### Screen Layout Structure (Portrait 390×844)

```
┌─────────────────────────────────┐
│ Opponent Bar (name, avatar, clock) │  ← compact, 48px max
├─────────────────────────────────┤
│                                 │
│         GAME BOARD              │  ← flex:1, fills available space
│    (canvas, max 500px wide)     │
│                                 │
├─────────────────────────────────┤
│ Player Bar (name, avatar, clock)  │  ← compact, 48px max
├─────────────────────────────────┤
│ Info Row (opening, material, etc) │  ← optional, 1 line
├─────────────────────────────────┤
│ Move History (horizontal scroll)  │  ← max 40px height
├─────────────────────────────────┤
│ Controls (resign, draw, etc)      │  ← min-height:48px, thumb reach
│ padding-bottom: safe-area-inset   │
└─────────────────────────────────┘
```

### Key Layout Rules

1. **NO ads in game view** — removed permanently, causes layout issues on small phones
2. **justify-content: center or flex-end** — dead space at TOP, never between board and controls
3. **Board sizes to container** — `Math.min(containerWidth - padding, containerHeight - padding, 500)`
4. **Board container MUST have closing tag** — unclosed divs cause flex:1 to leak
5. **Use `env(safe-area-inset-bottom)` not `var(--safe-bottom)`** for notch phones
6. **Controls must have `min-height: 48px`** — thumb-friendly touch targets
7. **Clocks: monospace font, min-width: 64px, text-align: center**

### Opponent Bar (Multiplayer)

```js
import * as mp from '../../../core/multiplayer.js';
mp.renderOpponentBar(container, opponentData, { showRating: true });
```

- Shows avatar, name, rating, connection dot (green/yellow/red)
- Tap opens action menu: View Profile, Add Friend, Report
- Positioned at top of game layout

---

## Adding a New Multiplayer Game — Checklist

### Backend

- [ ] Create `api/{game}-match.php` with actions: start, get, move, resign, draw, complete
- [ ] ALL database operations use `supabaseService()` — never user token
- [ ] `move` action merges game_state, doesn't overwrite
- [ ] `resign` validates match status isn't already completed
- [ ] `draw` atomically sets status + result
- [ ] `complete` calculates Elo + awards coins + records stats

### Frontend — Game Scene

- [ ] Import and use `matchLive.start(matchId, gameType, { onMove, onGameEnd })`
- [ ] Implement `handleLivePollData(el, data)` that checks:
  - [ ] Emotes (game_state.emote)
  - [ ] Draw offer (game_state.draw_offer)
  - [ ] Draw response (game_state.draw_accepted / draw_declined)
  - [ ] New moves (move_count changed)
  - [ ] Game end (status === 'completed')
- [ ] Send moves via `net.post(endpoint, { action: 'move', match_id, ...moveData })`
- [ ] Resign via `net.post(endpoint, { action: 'resign', match_id })`
- [ ] Draw offer via `net.post(endpoint, { action: 'move', match_id, game_state: JSON.stringify({draw_offer, draw_offer_t}) })`
- [ ] Draw accept via `net.post(endpoint, { action: 'draw', match_id })`
- [ ] Clock sync on every received move
- [ ] Call `endGame()` locally immediately after resign (don't wait for poll)
- [ ] Use `mp.renderOpponentBar()` for opponent info

### Frontend — Module Registration

- [ ] Register game scene in `modules/{game}/mod.js`
- [ ] Game receives params: `{ mode, matchId, color, timeControl, isFriendly, tournamentId }`
- [ ] Call `scene.enterGameMode()` on mount
- [ ] Clean up intervals/timers on unmount

### Frontend — Lobby Integration

- [ ] Friend invite navigates to `game-lobby` scene (not directly to game)
- [ ] Lobby polls match status until opponent accepts
- [ ] On status change to `in_progress`, navigate to game scene
- [ ] Guest (acceptor) can start immediately (they triggered the status change)

---

## Common Bugs & Their Causes

| Symptom | Cause | Fix |
|---------|-------|-----|
| Resign not visible to opponent | RLS blocking write OR poll only fires on moveCount change | Use supabaseService() + always forward poll data |
| Draw offer never shows | game_state write failed silently (RLS) | Use supabaseService() for handleGameMove |
| Clock desync | Client not syncing from server on received moves | Always update clock from `data.*_time_remaining_ms` |
| Board not rendering | Container div not closed → flex layout broken | Check HTML template has matching open/close divs |
| Opponent never appears in lobby | Invite creates match but acceptor goes to different matchId | Both sides must use the SAME match_id from the invite |
| Game state keeps resetting | game_state overwritten instead of merged | Always merge: `array_merge($existing, $new)` |
| Move plays twice | moveCount check missing on move processing | Only apply move when `data.move_count > lastKnownMoveCount` |
| "Match not found" on resign | Using getOne() which throws on empty result | Use get() with limit:1 and check array |
| Emote shows to sender | Missing `emote.from !== myId` check | Always filter out own emotes |
| Draw dialog reappears | Missing timestamp dedup (`lastDrawOfferHandled`) | Track last handled timestamp, skip if ≤ |

---

## Testing Multiplayer

### Manual Test Flow (Two Browser Tabs)

1. Tab A: Login as User 1, start a game (or send invite)
2. Tab B: Login as User 2, join the game (or accept invite)
3. Verify: both see the board, correct colors assigned
4. Tab A: Make a move → verify Tab B sees it within 3s
5. Tab B: Make a move → verify Tab A sees it within 3s
6. Test resign: Tab A resigns → verify Tab B shows "you won"
7. Test draw: Tab A offers draw → verify Tab B sees dialog → accept → both see draw
8. Test disconnect: close Tab B → verify Tab A shows "opponent disconnected" after 30s

### Edge Cases to Test

- Refresh mid-game → should reconnect and load current state
- Both players resign simultaneously → only first one should succeed
- Draw offer then resign before response → resign takes priority
- Network drop during move → move should retry or show error
- Tab background for 2+ minutes → should resync on return

---

## File Locations Reference

```
api/game.php                         — Chess match API
api/ludo-match.php                   — Ludo match API
api/friends.php                      — Invite system (create/accept/decline)
api/chat.php                         — Friend messaging

public/js/core/match-session.js      — Polling engine (DO NOT MODIFY LIGHTLY)
public/js/core/match-live.js         — Data forwarding layer
public/js/core/match-ui.js           — Connection status toasts
public/js/core/multiplayer.js        — Opponent bar, emotes, friend add

public/js/modules/chess/scenes/game.js  — Chess game (reference implementation)
public/js/modules/ludo/scenes/game.js   — Ludo game
public/js/modules/play/scenes/lobby.js  — Pre-game lobby
public/js/modules/play/scenes/challenge.js — Friend challenge picker
```

---

## Golden Rule

**If it works in single-player but breaks in multiplayer, the problem is almost certainly one of:**
1. RLS blocking the write (use supabaseService)
2. Poll data not being forwarded (match-live.js filtering)
3. game_state being overwritten instead of merged
4. Missing dedup (timestamp checks on offers/emotes)

Check these four things first. They account for 90% of multiplayer bugs on this platform.
