Files
wownerogue/docs/TAVERN_AND_MULTIPLAYER.md
T
jwinterm 0f68eec26b feat: real-time multiplayer race mode (free, prestige, crypto)
Adds a complete block-cadence multiplayer race mode alongside solo and tavern.
  Disabled by default; enable with MATCH_ENABLED=true.

  Highlights:
  - New shared engine: MatchRoom, MatchEngine, MatchState (transport-agnostic, unit-tested).
  - Per-economy queues: free, credits_prestige, crypto_race with atomic escrow/refund.
  - MatchScheduler drains queues on every new block and starts 2–32 player races.
  - MatchManager handles Socket.IO lifecycle, server-authoritative movement, reconnect grace,
    forfeit/AFK handling, and end-of-match persistence.
  - Crypto races use race-entry tickets (grants.race_entries) to avoid on-chain refunds; winner
    payout is queued through the existing batch payout/retry service.
  - Leaderboard integration: free → Pleb, prestige → /api/leaderboard?board=prestige,
    crypto → Hall of Champions.
  - Tavern integration: Queue for Race panel, active race list, and live spectator rendering.
  - Dedicated /match.html client with economy selector, race HUD, and post-match scoreboard.
  - Migration 022 adds matches, match_entrants, match_events, match_queue_entries,
    race_entry_transactions, users.race_entries, and payouts.match_id.

  Docs:
  - New docs/MATCH_MODE.md with full architecture, config, events, and operational notes.
  - Updated README.md, docs/TAVERN_AND_MULTIPLAYER.md, docs/TODO.md, src/.env.example,
    and src/.env.

  Tests: 40 new unit tests across 7 suites, all passing.

  Known follow-ups for real-world validation:
  - Full integration test with Postgres + Socket.IO.
  - Server boot check with MATCH_ENABLED=false (default) to ensure solo/tavern remain unaffected.
  - Browser render-kit verification in /match.html and tavern spectator.
  - Crypto-race payout end-to-end with real wallet-rpc.
2026-07-09 13:26:52 -04:00

265 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Tavern & Multiplayer — Design & Build Plan
Match mode (multiplayer races) now has its own dedicated design doc: see `docs/MATCH_MODE.md`.
**Status:** Tavern is complete; multiplayer race mode is implemented (see `docs/MATCH_MODE.md`).
PvP combat remains a future extension. Both modes are disabled by default and gated by config, so
existing single-player play is unaffected.
## Shared engine: one Room, two modes
Both modes are built on a single shared component — a real-time, multi-occupant `Room`:
- **Tavern** — a peaceful Room: players pick an avatar, walk around, chat, and watch live games.
- **Multiplayer** — the same Room engine with a dungeon, monsters, hazards, and player-vs-player
combat added.
The engine (`src/multiplayer/`) has no Socket.IO, database, or Wownerogue coupling. A manager
layer owns transport (Socket.IO rooms, broadcasts) and the server-tick timer. Keeping the engine
transport-agnostic makes it unit-testable in isolation and reusable as a standalone package.
## Modes
**Tavern** — a social space that reuses the existing lobby and spectator infrastructure. Players
choose an avatar, move around a shared map, and chat. A spectator camera lets them watch live
single-player or multiplayer games from inside the room.
**Multiplayer Race** — a shared match Room. Players race to escape a dungeon before the next block
and contend with a shared monster. Match rooms enable occupant collision; the Tavern does not.
PvP combat is planned for a later milestone.
## Operator Policy
The server operator controls which modes are enabled and, for multiplayer, the economic model.
Configuration is driven through the existing `config/` layer (DB-backed, hot-reloadable) rather
than static env vars alone, so operators can adjust settings without a restart. A later extension
allows per-room settings.
Which modes are enabled is surfaced to clients in the `game_mode_info` event as
`modes: { solo, tavern, multiplayer }` (from `SOLO_ENABLED` / `TAVERN_ENABLED` /
`MULTIPLAYER_ENABLED`). The client shows or hides entry points from this, so any single mode can
run on its own — including a Tavern-only instance. Solo is on unless explicitly disabled;
Tavern and Multiplayer are opt-in.
Multiplayer economic models (operator-selected per instance):
| Model | Description |
|---|---|
| Free | No entry cost or payout. |
| Entry fee, leaderboard only | Players pay to enter; results affect a leaderboard, no payout. |
| Winner-take-pot | Entrants stake into a pot awarded to the winner. |
| Even-return | Entrants recover their stake; only a flat fee is deducted. |
The default configuration is conservative (free, no payout), matching the current wownerogue
instance. Operators are responsible for ensuring their chosen configuration complies with
applicable laws in their jurisdiction.
## Avatars
Avatars are cosmetic. Availability is controlled by Operator Policy — for example, a set available
to everyone plus additional avatars unlocked by credit purchases. The avatar id is stored on the
occupant and broadcast to the room; unlock eligibility is enforced at join time.
## Character Appearance Contract
The public identity shape is `appearance: { avatar, tint, equipment }`. The server normalizes this
shape, persists it on `users.appearance`, stores it on the `Occupant` at join time, and broadcasts
it in room snapshots. Renderers use `appearance` as the canonical cosmetic identity; the flat
`avatar` field remains as a compatibility shortcut and as the first discriminator for sprite/skin
loading.
This contract is intentionally renderer-agnostic:
- **Top-down grid packs** resolve `avatar` to a base tile/frame and composite `equipment` overlays
above it. `tint` recolours only the authored recolour pixels so outlines, skin, wood, and metal
remain intact.
- **Isometric packs** should keep the same `appearance` ids and supply an isometric resolver for
base frames, direction rows, anchor/feet position, and equipment overlays. If an exact item is
missing, the resolver should fall back by role (`round_shield -> shield -> none`) rather than
changing the saved identity.
- **True 3D packs** should resolve the same ids to GLB assets/material variants/animation clips.
Source models and intermediates stay out of git; shipped GLB or baked sprite outputs are hosted
and lazy-loaded like premium sprite sheets.
Premium costumes are catalog entries with an entitlement requirement. The client may hide or label
locked choices, but real gating belongs at the server/operator policy layer: identity saves and
join requests normalize unauthorized premium `avatar` ids to an allowed fallback.
Current baseline premium policy: a player is treated as premium after any successful credits
purchase (`users.total_credits_purchased > 0`). That unlocks the preview premium packs
(`generated-skins`, `iso-dungeon`, and `kenney-3d-characters`) unless a future operator policy
tightens them. The policy shape is already pack-based so products can sell or grant different packs
independently. Operator/admin grants go through
`user_pack_entitlements(user_id, pack_id, source, expires_at, metadata)`, while
`users.premium_level` is reserved for broader tiering.
## Product Grants
Payments stay separated from entitlement policy:
- **Single-game entries** buy one paid game attempt and do not grant premium by default.
- **Credits packages** grant credits and may bundle cosmetic/render packs or a premium tier.
- **Cosmetic products** grant packs or tiers directly and may grant zero credits.
Catalog configuration supports `grants` on credit packages and a separate `COSMETIC_PRODUCTS`
catalog. A grant payload is normalized to:
`{ credits, packs: [{ id, expiresAt, source }], premiumLevel }`.
Examples:
```json
{
"id": "supporter_25",
"label": "25 Credit Supporter Pack",
"credits": 20,
"bonus": 5,
"price": 1000000000000,
"grants": {
"packs": ["generated-skins", "kenney-3d-characters"],
"premiumLevel": "supporter"
}
}
```
```json
{
"id": "pack_3d",
"label": "3D Character Pack",
"price": 250000000000,
"grants": {
"packs": ["kenney-3d-characters"]
}
}
```
Confirmed product payments persist the normalized grant on `payments.product_grants`, update
credit balances only when credits are part of the product, and upsert pack ownership in
`user_pack_entitlements`. The client receives the updated entitlement snapshot through
`credits_update` / `identity_update`, so the same premium state gates character saves, tavern joins,
render modes, and later multiplayer rooms.
## Chat
The existing chat backend is retained and wrapped behind a `ChatProvider` interface so the backend
can be swapped later without changing callers. The current backend remains the default.
## Rendering (art pass)
Rendering goes through a shared, renderer-agnostic **scene model**: game/tavern state is adapted
into a `Scene` (a tile grid + entities), and any renderer draws it. This lives in
`html/js/render/` and is used by both the Tavern and (planned) the main game.
Render tiers:
- **ASCII** — monospace glyph grid (canvas). Always-available fallback; accessible.
- **Tiled** — coloured tiles + entity sprites (canvas 2D). The default. Real tilesets/atlases
slot in here later.
- **Fancy** — WebGL via PixiJS: a blurred bloom on entities, flickering warm lights along the
bar, drifting dust motes, a vignette, and smooth movement — all programmatic (no art assets).
- **Iso** — canvas isometric projection using Kenney's Isometric Miniature Dungeon runtime PNGs.
- **3D** — Three.js projection. It loads generated GLB avatars with `idle` / `run` / `jump`
clips when available and falls back to lightweight low-poly avatars during asset iteration.
- Planned: **Fancy ASCII** — shader-lit, animated glyphs.
Render mode is a per-user choice (persisted) with an operator default. Modes can be marked
**premium** — a cosmetic entitlement intended to be unlocked with credits (the same Operator
Policy pattern as avatar unlocks). Because rendering is client-side, this is a soft/cosmetic gate,
not a hard boundary; premium-only *assets* (special tilesets, shaders) are the real lever if
stronger gating is wanted. The render kit provides the engine and programmatic tiles/effects —
polished pixel-art tilesets, sprite sheets, and shader effects are a separate content effort.
**CSP note:** PixiJS compiles shaders with `eval`, which the app's strict CSP blocks. The Fancy
tier therefore loads `@pixi/unsafe-eval` (it precompiles shaders without eval) immediately after
`pixi.js`, so no `'unsafe-eval'` is added to the CSP. Any page using the Fancy renderer must
include both scripts, in that order.
## Asset delivery & performance
Rules for keeping the tree lean and the client fast:
- **Heavy source artifacts never go in git.** 3D models and generation intermediates (`*.glb`,
`*.fbx`, `*.blend`, …) are produced by the sprite pipeline (`~/src/docs/animated-sprite-pipeline.md`)
and are gitignored. Only the *outputs* the game loads are considered for shipping.
- **Generated / premium assets are hosted, not committed.** AI-generated premium skins live under
`html/assets/generated/` (gitignored) and are delivered from an external asset host / CDN by URL.
The base tier's Kenney CC0 tiles are small and stay in-repo.
- **Lazy-load by mode / entitlement.** Nothing heavy loads until it's actually needed:
- PixiJS (+ `@pixi/unsafe-eval`) loads only when the **Fancy** mode is first selected — ASCII and
Tiled need no WebGL library.
- A premium skin's sprite sheet is fetched only when that skin is selected and the player is
entitled to it.
- Three.js loads only when the **3D** mode is selected. Generated GLBs are emitted by
`scripts/build_kenney_3d_characters.py` from the local Kenney FBX sources into
`html/assets/generated/3d/` (gitignored).
- **Optimize what ships.** Sprite sheets are delivered as **WebP** (alpha), right-sized to display
resolution, content-hashed with a long cache TTL. (Example: the demo walk sheet is 633 KB as PNG,
65 KB as WebP — ~10×.)
## Milestones
Tavern:
- **T0** — Shared engine scaffold: `Room`, `Occupant`, tavern map, unit tests. (Done)
- **T1** — `TavernManager`: Socket.IO room, server-tick timer, join/move/leave broadcasts; minimal
client (`html/tavern.html`) rendering avatars; gated behind `TAVERN_ENABLED`. (Done)
- **T2a** — `ChatProvider` seam + tavern chat. (Done)
- **T2b** — Spectator camera into live games from the Tavern.
- **T3** — Avatar picker with policy-gated unlocks.
Multiplayer:
- **M0** — Match Room with a dungeon and shared monsters; players race the block. Includes a load
test with many simulated clients to measure server-tick performance under concurrency.
- **M1** — Player-vs-player combat.
- **M2** — Environmental hazards.
- **M3** — Operator Policy economic models wired in.
Rendering:
- **R0** — Shared render kit: scene model + ASCII / Tiled / Fancy (PixiJS) renderers + mode
switch, wired to the Tavern. Premium-mode gating scaffolded. (Done)
- **R1** — Wire the main game to the render kit (refactor the `display/` layer behind it).
- **R2** — Real tilesets / sprite sheets and the Fancy-ASCII tier.
- **R3** — Wire premium render modes to credit entitlements (Operator Policy).
All milestones are additive and config-gated; the single-player path is unchanged.
## Current status
`src/multiplayer/`
- `Occupant.js` — a shared-world avatar (position, name, avatar id, facing).
- `Room.js` — map/walkability, server-authoritative one-tile movement, optional occupant
collision, a `tick()` hook for future autonomous systems, and snapshot/full-state serialization.
- `tavernMap.js` — a procedurally generated (rectangular) default tavern layout.
`src/network/tavernManager.js` — transport + lifecycle: a shared Room, the server-tick timer,
and Socket.IO room broadcasts. Wired into `SocketHandlers` (`tavern_join` / `tavern_move` /
`tavern_leave`, plus disconnect cleanup and shutdown). Inert unless `TAVERN_ENABLED=true`.
`html/tavern.html` — a minimal self-contained client: connect, choose a name/avatar, enter the
room, walk with arrow keys / WASD, and chat. Renders the map and occupants on a canvas.
`src/network/chat/` — the `ChatProvider` seam. `ChatProvider` (interface) + `SocketChatProvider`
(default: Socket.IO delivery + Postgres history). Both the global chat (`ChatHandler`) and tavern
chat deliver through it, so the backend can be swapped (e.g. a Nostr channel) without changing
callers. Tavern chat is room-scoped and ephemeral.
`html/js/render/` — the render kit: `sceneModel.js` (renderer-agnostic scene + tavern adapter),
`asciiRenderer.js`, `tileRenderer.js`, `fancyRenderer.js` (PixiJS), and `renderModes.js`
(registry, factory with graceful fallback, entitlements, persistence). The tavern client uses it
with a mode toolbar; premium modes are marked and gated by `RK.entitlements`.
Tests: `test/tavernRoom.test.js` (engine), `test/tavernManager.test.js` (manager incl. chat),
`test/chatProvider.test.js`, `test/renderScene.test.js` (scene adapter),
`test/entitlements.test.js`, `test/identityService.test.js`, and `test/productGrants.test.js`.
End-to-end join/move/leave and chat over a real socket verified out-of-band.
Payment/product entitlement pieces:
- `src/migrations/020_user_appearance_and_pack_entitlements.sql` — persisted appearance,
premium tier, and pack grants.
- `src/migrations/021_payment_product_grants.sql` — product ids and normalized grants on payments.
- `src/network/identityService.js` — server-side appearance persistence and entitlement-aware
normalization.
- `src/payments/productGrants.js` — product grant normalization/public summaries.
- `src/multiplayer/entitlements.js` — server policy snapshot used by identity, tavern joins, and
payment confirmations.
Enable locally with `TAVERN_ENABLED=true` (optionally `TAVERN_TICK_MS`), then open `/tavern.html`
in two browser tabs.