Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions docs/services/web-api/architecture.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Architecture — web-api & the TV↔phone transport

This page documents how a phone drives the TV: the **WebRTC peer-to-peer**
transport (backlog **P1**) and the thin signaling service that sets it up.
transport (backlog **P1**) and the thin signaling service that sets it up. The
same process also hosts a small **stats store** (backlog **P6**) — see
[Stats store](#stats-store-backlog-p6) below.

## Why WebRTC (the decision)

Expand Down Expand Up @@ -73,22 +75,47 @@ not restructured:
| game-launcher-web | `assets/js/players.js` | `PlayerSession` — the **hub/answerer**. Accepts one `RTCPeerConnection` per phone, answers offers, adds each to the roster on channel-open, and re-emits received intents as `intent` events. |
| game-launcher-web | `assets/js/app.js` | Routes session `intent` events through the **same `handleIntent()`** the keyboard uses — a phone is just another intent source. |

## Current limitation (backlog P2)
## Stats store (backlog P6)

The launcher launches a game by **full navigation**
(`location.href = ../games/<id>/`), which unloads the launcher page and **drops
the peer connection**. So today a phone drives the launcher **menu**, but not a
**running game**. Closing this needs a **persistent shell** — the launcher
hosting games in an iframe / in-page swap so the connection (and the
`PlayerSession`) survives launch, with games subscribing to controller intents.
Alongside signaling the service runs a tiny **SQLite** store (`db.js`,
`better-sqlite3`) for the metrics the console keeps *across devices* — so it can't
live in one browser's `localStorage`. It's a single file
(`web-api/data/spaceconsole.db`, `DB_PATH`-overridable, git-ignored) served on the
**same port** as signaling/app (iOS only lets a page reach its own origin, so the
stats API must be same-origin too — the same constraint that drove single-origin
serving in P5).

```
game (iframe) ──sc:gameover──▶ launcher ──POST /api/{play,score,result}──▶ web-api ──▶ SQLite
(adds game id, room, player names) plays · scores · results
```

Writes are **fire-and-forget beacons** from the launcher — a stats failure never
stalls gameplay. Games stay network-free: they call a shared `Stats` helper that
just `postMessage`s the launcher. Tables: `plays` (popularity, solo-vs-group),
`scores` (per-game leaderboards), `results` (2-player win/draw). API:

| Method | Path | Purpose |
| --- | --- | --- |
| POST | `/api/play` | Log a launch. |
| POST | `/api/score` | Record a leaderboard score. |
| POST | `/api/result` | Record a 2-player result. |
| GET | `/api/leaderboard?game=<id>` | Top scores for a game. |
| GET | `/api/stats` | Totals + most-played games. |

Same-screen 2-player rides the existing transport: two phones join the **same
room**, the launcher gives each a stable **seat** (P1/P2) and tags relayed intents
with it, and a shared `Roster` (`sc:players`) carries player **names** into games.

## Design intents to preserve

- **Keep the relay out of the hot path** — it brokers the handshake only;
gameplay stays peer-to-peer.
- **No game state / auth / DB in web-api** — it stays a thin matchmaker.
- **Signaling stays a thin, stateless matchmaker** — no game state or auth in the
relay path. The stats store shares the process but is a separate concern, out of
the signaling/gameplay path (and best-effort, so it can never block a game).
- **One intent vocabulary** end to end; a phone is just another source feeding
the same `handleIntent()`.
the same `handleIntent()`, now tagged with the sender's seat for 2-player games.

## Deploy

Expand Down
4 changes: 4 additions & 0 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ title: TODO / Backlog
<span class="logentry__tag">P5</span>
<span class="logentry__body"><b>Public multiplayer deploy.</b> Connectivity is <b>solved for dev</b> and verified with a real iPhone as controller. Three fixes made it work: (1) signaling binds <b>IPv4 <code>0.0.0.0</code></b> (the default was IPv6-only, unreachable by LAN phones); (2) <code>web-api</code> can serve the app <b>and</b> the WebSocket <b>same-origin on one port</b> (<code>STATIC_DIR</code> → <code>npm run serve:app</code>) — needed because iOS Safari only lets a page reach its own origin host:port, so cross-port signaling and a local TURN are unreachable from a phone; clients now default signaling to the page origin; (3) a <b>public TURN</b> (free-tier) relays when direct P2P fails (iOS mDNS / cross-network). <b>Still needed for an always-on public URL</b> a friend can use off-LAN: host <code>web-api</code> on a public host with <b>wss://</b> (e.g. Render, or the college box) + our own <b>coturn</b>. GitHub Pages serves the static app but has no signaling/TURN, so Pages alone isn't playable; a dev cloudflared tunnel works only when the host network allows it. See <a href="services/web-api/architecture.md">web-api / transport architecture</a>.</span>
</div>
<div class="logentry">
<span class="logentry__tag">P6</span>
<span class="logentry__body"><b>Stats + same-screen multiplayer.</b> <b>Framework shipped.</b> <b>web-api</b> now also runs a small <b>SQLite</b> stats store (<code>better-sqlite3</code>, single file, on the same port as signaling): <code>POST /api/play|score|result</code> and <code>GET /api/leaderboard|stats</code> track <b>which games get played</b>, <b>high scores</b>, and <b>2-player results</b>. The launcher fires fire-and-forget beacons; games report via a shared <code>Stats</code> helper (<code>sc:gameover</code> postMessage) — no network code in games. <b>Two phones now share one screen from the same room QR</b>: the launcher assigns stable seats (P1/P2), tags each relayed intent with its seat, and a shared <code>Roster</code> feeds player <b>names</b> into games for turn labels ("VET's turn (X)"). Controllers enter a name on join (persisted). A dropped player <b>rejoins mid-game</b> via an in-game QR/roster overlay and <b>keeps their seat</b> (matched by name); the <b>lead</b> player (P1) drives the menu &amp; switches games and is <b>promoted</b> to the next player if they leave; local/remote <b>Back</b> now reliably returns to the game grid with the session intact. First real 2-player game: <b>tic-tac-toe</b>. <b>Remaining:</b> roll real 2-player out to the other multiplayer-capable games (chess, checkers, scrabble, space-tower, tic-tac-boom); single-player games stay solo by decision. See <a href="services/web-api/architecture.md">web-api / stats store</a>.</span>
</div>
</section>

</div>
Expand Down
Loading