Files
wownerogue/docs/TAVERN_AND_MULTIPLAYER.md
jwinterm fad2a695bd Wownerogue
A browser-based roguelike synchronized with crypto block timing. A run is a race
against the next block: reach the exit before the block lands, or the house wins.
The same codebase runs the solo dungeon, the social Tavern, and operator-selected
PvP and race rulesets, with separate free and paid leaderboards.

Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md
for the design, and docs/DEPLOY.md to operate an instance.
2026-07-27 08:23:01 -04:00

19 KiB

Tavern and Multiplayer

The Tavern is a shared social room; Match mode is a shared competitive dungeon. Both are built on the same room engine, both are opt-in per instance, and neither affects the single-player path when disabled.

Match rules, timing, fairness, and settlement have their own reference: docs/MATCH_MODE.md. Ruleset definitions are documented in docs/RULESETS.md.

Shared engine: one Room, two modes

Both surfaces sit on a real-time, multi-occupant Room:

  • Tavern: a peaceful room. Players pick an avatar, walk around, chat, and watch live games.
  • Match: the same room engine with a dungeon, a monster, and ruleset-selected collision and combat behavior.

The engine under src/multiplayer/ has no Socket.IO, database, or application 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.

src/multiplayer/:

  • Occupant.js: a shared-world avatar (position, name, avatar id, facing).
  • Room.js: map and walkability, server-authoritative one-tile movement, optional occupant collision, a tick() hook for autonomous systems (a heartbeat in the Tavern), and snapshot/full-state serialization.
  • tavernMap.js: the default tavern layout. Rows are built to a uniform width, and one legend drives both client tiles (sceneModel TAVERN_LEGEND/TILE_OF) and Room walkability, so the two cannot disagree about what is solid. Walkable characters are floor, spawn, rug, chair, and door; everything else blocks, which is what makes players weave between furniture.
  • appearance.js: the cosmetic identity model and its normalizer.
  • entitlements.js: the server-side entitlement policy.
  • MatchRoom.js, MatchState.js, MatchEngine.js: match world, state, and lifecycle.

Tavern

A social space that reuses the 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 match games from inside the room, and a queue button lets them enter a match.

Entering anonymously

The Tavern requires no account or login. On connection the browser registers the client and waits for the anonymous session handshake (session_token, session_resumed, or the associated identity response) before enabling Enter tavern. The token is stored as wownerogue_token in that browser's localStorage and reused after refresh or reconnect.

The join form accepts its submit action, including Enter from the optional name field. Its button reads Connecting…, Preparing…, or Entering… while the corresponding step is pending. A disconnect or connection error resets readiness and retries automatically; a join with no server response becomes a visible retryable error after ten seconds. Cosmetic selections are normalized and enforced by server entitlement policy regardless of what the client sends.

Match

A shared dungeon room using the operator-selected ruleset. Depending on the ruleset, players race to escape, fight to remain last alive, compete for score, or cooperate. Block-bounded rulesets end on the first advancing block header after their active-play duration floor (ruleset.timing.minDurationMs), and every match also has a hard ceiling. Match rooms enable the collision and combat rules the ruleset selects; the Tavern does not.

Built-in rulesets are race, last-alive, score-attack, and coop-escape. A solo-classic descriptor exists for catalog parity; the single-player engine does not consume it.

Operator policy

The operator controls which modes are enabled and, for match play, the economic model. Mode activation, the ruleset, and match economy switches are startup environment configuration: set SOLO_ENABLED, TAVERN_ENABLED, MATCH_ENABLED, MATCH_RULESET_ID, or the MATCH_* economy settings and restart the service. The server reads them once at startup: it does not hot-reload match modes or rulesets, and there is no per-room ruleset editor. The full environment template is src/.env.example.

Enabled modes are surfaced to clients in game_mode_info as modes: { solo, tavern, multiplayer, match }. solo, tavern, and multiplayer are booleans; match is an object carrying enabled, the available economies, maxPlayers, the activeRuleset, and the full ruleset catalog. 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 Match are opt-in.

Match economic models:

Model Description
free No entry cost or payout; competitive results use the Free/Pleb board.
credits_prestige Spend credits; competitive results use the separate PvP Prestige board, with no crypto payout.
crypto_race Use a backed race-entry ticket; an eligible competitive winner receives the disclosed pot less the configured fee.

free is always offered. credits_prestige requires credits mode or free play to be enabled. crypto_race additionally requires its own switch, a payout cap, a compatible ruleset, and the global payout safety gate, all evaluated by matchPayoutAdmissionPolicy. Baseline defaults are conservative (free, no payout). Operators are responsible for ensuring their selected configuration complies with applicable law in their jurisdiction.

Character appearance contract

The public identity shape is appearance: { avatar, tint, equipment, colors }. 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 and skin loading.

Normalization in src/multiplayer/appearance.js is total: an unknown avatar id collapses to default, unknown equipment collapses to none per slot, and unknown tints, skin tones, and hair colors collapse to their defaults. Flat colour avatars carry no equipment or colour detail; 3D model avatars carry colours but no 2D equipment overlays; character avatars carry both.

The contract is renderer-agnostic:

  • Top-down grid packs resolve avatar to a base tile or frame and composite equipment overlays above it. tint recolours only the authored recolour pixels, so outlines, skin, wood, and metal stay intact.
  • Isometric packs keep the same appearance ids and supply an isometric resolver for base frames, direction rows, anchor position, and equipment overlays. A missing item falls back by role (round_shield to shield to none) rather than changing the saved identity.
  • 3D packs resolve the same ids to GLB assets, material variants, and 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.

The client may hide or label locked choices, but gating belongs at the server policy layer: identity saves and join requests normalize unauthorized premium avatar ids to an allowed fallback.

Entitlements and the cosmetic catalog

src/multiplayer/entitlements.js is the single policy module used by identity saves, tavern joins, render-mode gating, and payment confirmation. In production the operator-owned cosmetic_catalog table (migration 024) is loaded by CatalogService and passed in; the DEFAULT_CATALOG in the module is the fallback for fresh or partly migrated databases and for unit tests.

Each catalog entry is { id, label, kind, projection, tier, unlockMinCredits, grantOnly, premium }. A pack is unlocked when any of the following holds:

  • it is a free pack (tier 0, no credit threshold, not grant-only);
  • the user holds an explicit grant for it in user_pack_entitlements;
  • the user's lifetime credits purchased (users.total_credits_purchased, cumulative and never decremented by play) is at or above the pack's unlockMinCredits;
  • the user's premium tier is at or above the pack's tier.

Premium tier comes from users.premium_level or an active subscription tier, whichever is higher, over the ladder supporter (1), premium (2), operator (3). Buying credits does not by itself place a user on the tier ladder: credits maps to tier 0, and credit spend unlocks packs only through the unlockMinCredits thresholds.

The seed ladder is the free original tile pack plus premium packs at rising lifetime-credit thresholds: Roguelike Interior at 1, character skins at 5, isometric dungeon at 10, roguelike dungeon tiles at 20, isometric medieval at 40, and animated 3D characters at 50. Adding a rung is a catalog row plus assets, not a code change. See docs/MONETIZATION.md and docs/RENDER_PACKS.md.

snapshotForUser() returns { premium, level, tier, credits, totalCreditsPurchased, packs, catalog }. The packs map is authoritative for gating; canUsePack consults it first and only falls back to the coarse premium flag when a caller supplies a legacy entitlement object without one.

Product grants

Payments stay separated from entitlement policy:

  • Single-game entries buy one paid game attempt and grant no packs by default.
  • Credits packages grant credits and may bundle cosmetic 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 (see src/.env.example). A grant payload normalizes to { credits, packs: [{ id, expiresAt, source }], premiumLevel }, and unknown pack ids are dropped.

{
  "id": "supporter_25",
  "label": "25 Credit Supporter Pack",
  "credits": 20,
  "bonus": 5,
  "price": 1000000000000,
  "grants": {
    "packs": ["generated-skins", "kenney-3d-characters"],
    "premiumLevel": "supporter"
  }
}
{
  "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 and identity_update, so the same state gates character saves, tavern joins, render modes, and match rooms.

Supporting 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/migrations/024_cosmetic_catalog.sql and the ladder migrations that follow it: the operator-owned catalog.
  • src/network/identityService.js: appearance persistence and entitlement-aware normalization.
  • src/payments/productGrants.js: grant normalization and public summaries.

Chat

The chat backend sits behind a ChatProvider interface (src/network/chat/) so it can be swapped without changing callers. SocketChatProvider is the default: Socket.IO delivery plus Postgres history. buildChatProvider() returns the plain local provider unless NOSTR_CHAT_ENABLED is set and NOSTR_CHAT_SCOPE is global, in which case NostrChatProvider layers cross-server relay fan-out over it. See docs/CHAT_AND_NOSTR.md.

Tavern chat is global chat. When a global chat provider is injected, tavern_chat publishes to the global scope, reaching every connected client and the persisted history, so it must clear the same bar as the lobby path. TavernManager._moderateChat applies the chat ban list, the reconnect-proof rate limiter (keyed on the stable session id plus IP), and user_id attribution before publishing. Without those guards tavern_chat would be an unauthenticated bypass of lobby moderation under a client-chosen display name. When no global provider is injected, messages stay in the ephemeral tavern-scoped room that nobody outside it sees, and the per-occupant cooldown alone governs them.

Message text is escaped at publish time because delivery is trusted-escaped and rendered as HTML on the client.

Speech bubbles resolve the speaker from the occupant list by display name. The chat provider publishes a short publicId, never the raw socket id, so anything keying bubbles off socketId never fires.

Chain ambience

The Tavern stage carries a decorative chain layer (RK.mountChainAmbience, chainAmbience.js): fragments of the top block hash fade in around the border and drift away, a corner readout tracks the height, and a landing block sends a ripple around the frame plus a brighter burst.

It consumes the public blockheight broadcast, which carries an optional cosmetic tip (hash, difficulty, txPoolSize). The tip is refreshed from get_info only when the height actually advances, since it is decoration and not worth an extra RPC per poll. RpcService.getChainTipInfo() never throws and never feeds fairness, payouts, or match seeding, and DebugManager.getChainTip() returns null under simulated blocks because there is no chain to describe.

The layer is pointer-events: none, marked aria-hidden with the corner readout as its accessible copy, honours prefers-reduced-motion, and stops spawning while the tab is hidden. A hidden tab pauses CSS animations, so animationend never fires and motes would otherwise accumulate indefinitely; the backlog is dropped on hide.

Rendering

Rendering goes through a renderer-agnostic scene model: game or tavern state is adapted into a Scene (a tile grid plus entities), and any renderer draws it. This lives in html/js/render/ and serves the Tavern, the match client, spectators, and the single-player render bridge.

Selectable render techniques (RK.RENDER_MODES):

Mode Implementation Premium
tiles Coloured tiles and entity sprites on canvas 2D. The default. No
ascii Monospace glyph grid on canvas. Always-available fallback; accessible. No
iso Canvas isometric projection using the isometric dungeon pack art. Yes
3d Three.js projection with generated GLB avatars (idle, run, jump clips) and a low-poly fallback when a model cannot load. Yes

A premium mode unlocks once the user holds any unlocked pack for that mode's projection, so entitlement is expressed once in the catalog rather than duplicated per mode. Render mode is a per-user persisted choice with an operator default. Because rendering is client-side this is a cosmetic gate; premium-only assets are the enforceable lever.

fancyRenderer.js is an internal PixiJS prototype, not a registered selectable mode. No mode button invokes it. If it is invoked directly it loads @pixi/unsafe-eval immediately after pixi.js so shaders precompile without adding 'unsafe-eval' to the CSP, and it requires the server's external renderer policy to be on.

Production top-down styling comes from interchangeable packs plus the shared canvas FX layer. The render kit provides the engine and programmatic tiles and effects; authored tilesets, sprite sheets, and shader effects are a separate content effort.

Asset delivery and performance

  • Heavy source artifacts are not committed. 3D models and generation intermediates (*.glb, *.fbx, *.blend) are gitignored. Only the outputs the game loads are candidates for shipping.
  • Generated and premium assets are provisioned, not committed. Premium skins and runtime GLBs live under html/assets/generated/, which is gitignored. They are provisioned into the same-origin runtime path or delivered by an explicitly reviewed asset host and CSP policy, and are not present in the Git release artifact. The base tier's small Kenney CC0 tiles stay in-repo.
  • Load by mode and entitlement. 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. Generated GLBs are emitted by scripts/build_kenney_3d_characters.py from local Kenney FBX sources into html/assets/generated/3d/.
  • Three.js is served from this origin. src/index.js publishes the browser build and addons under /vendor/three/0.160.0/ from the pinned three dependency, with immutable versioned URLs so a cached copy cannot survive an upgrade to different bytes. Pages declare it through an import map, and RK.ensureThree imports it only when 3D is selected. RENDERER_CDN_ENABLED defaults to false: third-party renderer code would execute with the same privileges as the game and payment UI, so trusting a CDN is an explicit operator decision and widens the CSP when enabled.
  • Optimize what ships. Sprite sheets are delivered as WebP with alpha, sized to display resolution, content-hashed with a long cache TTL. The demo walk sheet is roughly ten times smaller as WebP than as PNG.

Client and transport

src/network/tavernManager.js owns transport and lifecycle: one shared Room, the server-tick timer (default 100ms, override with TAVERN_TICK_MS), and Socket.IO room broadcasts on the tavern:<roomId> channel. It is wired into SocketHandlers (tavern_join, tavern_move, tavern_leave, plus disconnect cleanup and shutdown) and is inert unless TAVERN_ENABLED=true.

src/network/tavernMatchBridge.js relays match progress into the Tavern (tavern_match_tick, tavern_match_end) and requires both TAVERN_ENABLED and MATCH_ENABLED.

html/tavern.html is the browser client: establish or resume an anonymous session, choose an optional name and avatar, enter the room, walk with keyboard or on-screen controls, chat, watch solo or match games, or join a match queue. No account login is required.

html/js/render/ is the render kit: sceneModel.js (renderer-agnostic adapters), the ASCII, tiled, isometric, and Three.js renderers, the internal Fancy prototype, and renderModes.js (registry, factory with graceful fallback, entitlements, persistence). Clients expose a mode toolbar where applicable; premium modes are marked and gated by RK.entitlements.

Tests

  • test/tavernRoom.test.js: the room engine.
  • test/tavernManager.test.js: the manager, including chat moderation.
  • test/tavernMatchBridge.test.js: match-to-tavern relay.
  • test/chatProvider.test.js: the provider seam.
  • test/renderScene.test.js: the scene adapter.
  • test/entitlements.test.js, test/catalogEntitlements_ultra.test.js: entitlement policy and the operator catalog.
  • test/identityService.test.js, test/appearance.test.js, test/appearanceModel3d.test.js: appearance normalization and persistence.
  • test/productGrants.test.js: grant normalization.
  • test/pvpUiPolish.test.js: anonymous readiness and join states.
  • test/rendererCdnPolicy.test.js: the same-origin Three.js runtime policy.

Running locally

Set TAVERN_ENABLED=true (optionally TAVERN_TICK_MS), start the server, and open /tavern.html in two browser tabs. Add MATCH_ENABLED=true and a MATCH_RULESET_ID to exercise match entry from the Tavern.