# Live Battle v2 — Full Analysis, Fixes & Invite-Flow Redesign

> Companion to [live-battle-analysis-and-invite-feature.md](./live-battle-analysis-and-invite-feature.md).
> That doc designed the original invite feature; **this doc audits the now-implemented
> system end-to-end and redesigns the invite UX** so a player never has to type a
> phone number to challenge a friend.
>
> Status: proposal. Nothing here is built yet. Date: 2026-06-14.

---

## 0. TL;DR

1. **Biggest UX problem:** inviting a friend requires typing their WhatsApp number
   (with country code). Nobody remembers numbers, country codes are error-prone, and
   it feels invasive. The whole `invitee_id` / in-app invite path is currently **dead
   UI** because there is no friends list or user search to pick a person from.
2. **Recommended fix (phased):**
   - **Phase A (quick win, ~1–2 days):** *Shareable invite link* + native share sheet,
     and *Recent Opponents* one-tap re-invite. Neither needs a social graph. This alone
     removes phone-number typing for ~everyone.
   - **Phase B:** a real *Friends* system (add / accept / list) → friends picker.
   - **Phase C:** *Friend codes* + *QR* for discovery, and *Room codes* for "we're in
     the same voice call" play.
3. **Engine hardening (separate track):** durable round state, reconnection snapshot,
   Elo-banded matchmaking, queue-cancel, config object, structured `battle_rounds`.

---

## 1. Current architecture (as built)

### 1.1 Match engine
- `battles` (status `waiting|playing|finished`, `winner_id`, `deck_id`) + `battle_user`
  pivot (`score`, `elo_change`). Live per-round state (round #, question, answers,
  used cards, per-player speed, locks) lives **entirely in `Cache`** under
  `battle:{id}:*`.
- Lifecycle: `queue` → `ready` handshake → `startNextRound` (10 rounds, 10s each,
  `RoundTimeoutJob` force-advances) → `endBattle` (score, speed tiebreak, K=32 Elo).
- Real-time via Reverb; `MatchFound`, `RoundStarted`, `PlayerAnswered`, `RoundEnded`,
  `BattleEnded`, `Rematch*`, and now `BattleInvited`.
- [`BattleService::startMatch()`](../app/Services/BattleService.php) is the shared
  "attach joiner → flip to playing → broadcast `MatchFound` to both" path used by both
  random queue and invite-accept. Good — single source of truth.

### 1.2 Invite feature (built)
- Table `battle_invites`: `battle_id`, `inviter_id`, `invitee_id?`, `invitee_phone?`,
  `token` (uuid), `status` (`pending|accepted|declined|expired|cancelled`), `expires_at`.
- [`BattleInviteController`](../app/Http/Controllers/BattleInviteController.php):
  `create` / `show` / `accept` / `decline` / `cancel`. Guards: inviter must be
  WhatsApp-verified, ≤5 pending invites, single-use accept lock, identity & expiry checks.
- Delivery: [`BattleInviteNotification`](../app/Notifications/BattleInviteNotification.php)
  → WhatsApp (Fonnte) + WebPush; registered invitees also get a live
  [`BattleInvited`](../app/Events/BattleInvited.php) toast.
- Frontend: lobby "Invite Friend" modal (**phone-number input**), deep-link
  `/battle/join/[token]` page, in-app toast, login-redirect preservation.
- `CleanupBattles` expires stale pending invites.

---

## 2. Problems & gaps

### 2.1 Invite UX (the focus of this doc)
| # | Problem | Impact |
|---|---------|--------|
| U1 | **Must type a WhatsApp number + country code** | High friction, error-prone, feels invasive; the #1 complaint. |
| U2 | **No friends list / user search** → the `invitee_id` path & `BattleInvited` toast are unreachable from the UI | Half the feature is dead code. |
| U3 | **No "recent opponents" / rematch-a-friend shortcut** | The people you actually want to re-challenge are already in your battle history, unused. |
| U4 | **Inviter must be WhatsApp-verified even to share a plain link** | Blocks invites for no reason when delivery isn't our WhatsApp gateway. |
| U5 | **Messaging arbitrary numbers = spam/consent risk** | A user can WhatsApp-spam any number via our Fonnte account. |
| U6 | **No delivery/seen feedback to the inviter**, no "remind/resend" | Inviter stares at "waiting" with no signal. |
| U7 | **Wrong-number = silent failure** | Fonnte logs and drops; inviter never learns it didn't arrive. |

### 2.2 Engine / correctness (carried from v1 doc, still open)
- E1 **Cache is the only source of truth for live state** — eviction/restart softlocks a battle.
- E2 **No reconnection snapshot** documented for mid-round refresh.
- E3 **No Elo-banded matchmaking** — 1200 can face 1900.
- E4 **No queue-cancel endpoint** — abandoned lobbies linger up to 10 min.
- E5 **Scattered magic numbers** (10 rounds, 10s, 12s, 4.4 slope, K=32) — no `config/battle.php`.
- E6 **No `battle_rounds` table** — no replays/analytics; history lost when cache clears.
- E7 **God controller** still owns round engine + scoring + Elo (partly mitigated by `BattleService`).

---

## 3. Invite-flow redesign — "never type a number"

Six options, ranked by value-to-effort. Recommendation = **A1 + A2 now**, then B, then C.

### A1. Shareable invite link + native share sheet  ⭐ primary
The inviter taps **Invite Friend** → backend mints an **open** invite (no recipient) →
returns `token` + `join_url` → the app calls the **Web Share API** so the OS share sheet
opens; the inviter taps WhatsApp and **picks the contact inside WhatsApp's own picker**.
No number typed, no country code, works for any messenger, and works for non-registered
friends (they hit `/battle/join/{token}`, register/login, then accept).

```
Inviter                              App / Backend                 Friend
  │  tap "Invite Friend"                   │                          │
  │ ───────────────────────────────────▶  │ POST /battles/invite     │
  │                                        │   { deck_id }            │
  │  ◀─────────────────────────────────── │  { token, join_url }     │
  │  navigator.share({ url: join_url })    │                          │
  │  → picks WhatsApp → picks contact ───────────────────────────────▶ receives link
  │  (waiting…)                            │                          │  taps link
  │                                        │ GET /battles/invite/{t}  │ ◀── preview page
  │                                        │ POST .../accept ◀────────│  taps "Accept"
  │  ◀── MatchFound (Reverb) ───────────── │ startMatch() ──────────▶ │ ──▶ /battle/{id}
```

- **Backend change:** allow `create` with **neither** `invitee_id` nor `invitee_phone`
  → an "open link" invite. Return `join_url = FRONTEND_URL/battle/join/{token}`.
- **Fallback when `navigator.share` is unavailable** (desktop): show the link with a
  **Copy** button + a `https://wa.me/?text=...` deep link.
- **Security:** open link is single-use + 10-min expiry (already enforced); first
  authenticated, non-inviter user to accept binds `invitee_id = self`. Like a Zoom link.
- **Drops requirement U4:** open-link invites should **not** require inviter WhatsApp
  verification (we aren't sending via our gateway).

### A2. Recent Opponents quick-pick  ⭐ primary
Pull the last N distinct opponents from `battles` history → show avatars → one tap
invites that **registered** user (`invitee_id`), firing the live `BattleInvited` toast +
WebPush (and WhatsApp if they have a number). Reuses data we already store; finally makes
the `invitee_id` path reachable.

- **Backend:** `GET /battles/recent-opponents` → `[{id, name, elo_rating, last_played_at}]`
  (distinct opponents from the user's finished battles, most-recent first, take 8).

### B. Friends system  (medium effort, best long-term)
Add a lightweight social graph so "Invite Friend" shows people you actually know.

- **Tables:** `friendships(user_id, friend_id, status: pending|accepted, requested_by)`,
  unique pair. Endpoints: request / accept / decline / list / remove.
- **Add-friend discovery:** by **friend code** (see C1) or from Recent Opponents
  ("Add friend" on the post-battle screen).
- **Invite picker:** `GET /friends` → tap to invite (`invitee_id`). This is the
  long-term home for the in-app `BattleInvited` flow.

### C. Discovery helpers (lower priority, delightful)
- **C1. Friend code / handle** — give each user a short stable code (e.g. `MANA-7F3K`),
  reusing the existing [`InvitationCode`](../app/Models/InvitationCode.php) code-gen
  pattern. Invite/add-friend by typing a **6-char code** instead of a phone number —
  memorable, shareable verbally. `GET /battles/invite-by-code/{code}`.
- **C2. QR code** — render the inviter's friend-code/join-link as a QR; the friend scans
  in-app. Perfect for in-person classmates.
- **C3. Room code (lobby code)** — host creates a lobby that shows a big **6-char room
  code**; the friend enters it on a "Join with code" screen. Ideal when they're already
  on a voice/video call. Backend: reuse `battle_invites` with a short human code instead
  of (or alongside) the uuid token.

### Recommendation matrix
| Option | Removes number typing? | New social graph? | Effort | Verdict |
|--------|:--:|:--:|:--:|--------|
| A1 Share link | ✅ everyone | no | S | **Build now** |
| A2 Recent opponents | ✅ for re-matches | no | S | **Build now** |
| B Friends | ✅ | yes | M | Next |
| C1 Friend code | ✅ | optional | S–M | After B |
| C2 QR | ✅ | no | S | Nice-to-have |
| C3 Room code | ✅ | no | M | Nice-to-have |

Keep the **manual phone-number field as a last-resort fallback**, hidden behind "Invite
by number" — not the default.

---

## 4. Concrete changes for Phase A

### 4.1 Backend
1. **Relax invite creation** — [`CreateBattleInviteRequest`](../app/Http/Requests/CreateBattleInviteRequest.php):
   allow all-empty (open link). Add an explicit `mode` resolution in the controller:
   `open` (no recipient) | `user` (`invitee_id`) | `phone` (`invitee_phone`).
2. **Skip WhatsApp-verification gate for `open` and `user` modes** (only enforce it for
   `phone` mode, where we actually send via Fonnte). Fixes U4.
3. **Return `join_url`** from `create` so the frontend can hand it straight to
   `navigator.share`.
4. **`GET /battles/recent-opponents`** (new controller method) for A2.
5. **De-dup guard** — if an open/pending invite from this inviter for this deck already
   exists, return it instead of minting another (avoids lobby spam). Addresses part of U6.
6. **(Optional) delivery signal** — `Fonnte` send already returns bool; for `phone` mode
   surface `{ delivery: 'sent'|'failed' }` so the UI can warn on wrong numbers (U7).

### 4.2 Frontend
- **Invite modal redesign** ([app/battle/page.tsx](../../frontend/app/battle/page.tsx)):
  - Primary button **"Share Invite Link"** → `create({deck_id})` → `navigator.share({ title, text, url: join_url })`; fallback to Copy + `wa.me` link.
  - **"Recent Opponents"** row → one-tap `create({deck_id, invitee_id})`.
  - Collapsed **"Invite by number"** as the fallback (current input), de-emphasised.
- **Waiting state** already exists; add a **Copy link again** affordance and (later) a
  delivery/seen indicator.

### 4.3 Tests
- `create` with only `deck_id` → 200, returns `token` + `join_url`, no WhatsApp gate.
- open invite accepted by any authenticated non-inviter → binds `invitee_id`, starts match.
- `recent-opponents` returns distinct, most-recent, excludes self, registered only.
- phone mode still gated on WhatsApp verification; de-dup returns existing pending invite.

---

## 5. Engine hardening (separate track, prioritized)

| Pri | Item | Fix |
|----|------|-----|
| P0 | E1 durable state | Mandate persistent Redis for `battle:*`; on `playing` battle with missing keys, fail gracefully + notify, never softlock. |
| P0 | E2 reconnect | Formalize `GET /battles/{id}` as the resync snapshot; client refetches on WS reconnect; `current()` must surface freshly-created rematch/invite battles. |
| P1 | E3 matchmaking | Elo band widening with wait time (±100 → ±300 after 30s). |
| P1 | E4 queue cancel | `POST /battles/queue/cancel` to leave a `waiting` lobby cleanly. |
| P1 | E5 config | `config/battle.php`: rounds, timers, scoring slope, Elo K, match bands, invite TTL. |
| P2 | E6 history | `battle_rounds` table (battle_id, round, card_id, per-user correct/time) for replays + analytics. |
| P2 | E7 refactor | Extract `StartNextRoundAction` / `EndBattleAction`; centralize cache keys in `BattleCacheKeys`. |
| P3 | obs | Pulse/Telescope metrics: match wait, abandonment %, timeout %, invite accept-rate, broadcast failures. |

---

## 6. Suggested sequencing
1. **Phase A invite UX** (share link + recent opponents) — highest user-visible win, no social graph, directly fixes the phone-number pain.
2. **P0 engine hardening** (durable state, reconnect).
3. **Phase B Friends** + **P1** (matchmaking, queue-cancel, config).
4. **Phase C** discovery (friend code / QR / room code) + **P2/P3** (battle_rounds, observability).

---

## 7. Open questions
- Should open invite links be **single-use** (current) or **multi-join "anyone with link"**
  for casual group play? (Recommend single-use for 1v1 integrity.)
- Friend codes: per-user **permanent** handle vs rotating? (Permanent is friendlier.)
- Do we want **push-only** invites for registered friends (skip WhatsApp) to cut Fonnte cost?
</content>
