From 6f7e56ffa4daef49869766c67bb233a5fdde03f2 Mon Sep 17 00:00:00 2001 From: "B.B. Jansen" <56940439+bbjansen@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:01:19 +0200 Subject: [PATCH 1/4] chore: ignore local .env.production (CI deploys use repo secrets) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index a4d699a..37e76ef 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ node_modules dist *.local .DS_Store + +# Local deploy env (CI uses repo secrets) +.env.production From 41e3116816a2bfdb212916ed92ad7a9eea280e3e Mon Sep 17 00:00:00 2001 From: "B.B. Jansen" <56940439+bbjansen@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:01:19 +0200 Subject: [PATCH 2/4] chore(vendor): update @plot/client bundle Picks up plot.play() (managed netcode), WebSocket keepalive, auto-reconnect with resume tokens, and the fix for the reconnect re-mint omitting the X-Plot-App tenant header. --- vendor/plot-client/index.d.ts | 151 +++++++++++++++++++++-- vendor/plot-client/index.js | 218 +++++++++++++++++++++++++++++++--- 2 files changed, 343 insertions(+), 26 deletions(-) diff --git a/vendor/plot-client/index.d.ts b/vendor/plot-client/index.d.ts index e8dd9e8..88af1c4 100644 --- a/vendor/plot-client/index.d.ts +++ b/vendor/plot-client/index.d.ts @@ -1,9 +1,9 @@ -import { CorrectionType } from './handler-client'; -export { CorrectionType } from './handler-client'; -import { RoomDefinition } from './handler'; -import { ClientEnvelope, ServerEnvelope, Channel, Profile, LeaderboardEntry, MatchMode } from './protocol'; -export * from './protocol'; -export { CHANNELS, DEFAULT_CHANNEL, isChannel } from './protocol'; +import { CorrectionType } from '@plot/handler-client'; +export { CorrectionType } from '@plot/handler-client'; +import { RoomDefinition } from '@plot/handler'; +import { ClientEnvelope, ServerEnvelope, Channel, Profile, LeaderboardEntry, MatchMode } from '@plot/protocol'; +export * from '@plot/protocol'; +export { CHANNELS, DEFAULT_CHANNEL, isChannel } from '@plot/protocol'; type Snapshot = { ts: number; @@ -54,7 +54,12 @@ interface Transport { send(env: ClientEnvelope): void; close(): void; onMessage(handler: (env: ServerEnvelope) => void): void; - onClose(handler: () => void): void; + /** Fires once when the socket closes. `clean` is true only for an intentional + * local close (code 1000) — an abnormal drop (1006, server 1011, …) is the + * Room's cue to reconnect. */ + onClose(handler: (info: { + clean: boolean; + }) => void): void; } type RoomMessageEvent = { @@ -85,6 +90,13 @@ interface RoomEvents { leave: (e: PresenceEvent) => void; frame: (e: FrameEvent) => void; predicted: (e: PredictedEvent) => void; + /** The socket dropped abnormally. `willRetry` is true while the Room is + * reconnecting with backoff, false once it has exhausted MAX_RETRIES. */ + disconnect: (e: { + willRetry: boolean; + }) => void; + /** A reconnect succeeded; the server will re-send a snapshot to resync. */ + reconnect: () => void; } interface SendOptions { channel?: Channel; @@ -93,7 +105,16 @@ interface SendOptions { declare class Room { private _playerId; private transport; + /** Re-establishes a transport for the same room (fresh token, optional + * resume token). Null when the Room was created without reconnect support. */ + private reconnectFn; private listeners; + /** Latest server-issued resume token (refreshed each (re)connect); presented + * on reconnect to resume the same slot within the grace window. */ + private resumeToken; + /** Set when the app calls leave(): suppresses reconnect on the ensuing close. */ + private intentionalLeave; + private reconnecting; currentState: unknown; private snapshotSink; private buffer; @@ -107,9 +128,25 @@ declare class Room { private lastDrift; private predictedTracks; correctedState: Record; - constructor(_playerId: string, transport: Transport); + constructor(_playerId: string, transport: Transport, + /** Re-establishes a transport for the same room (fresh token, optional + * resume token). Null when the Room was created without reconnect support. */ + reconnectFn?: ((resumeToken: string | null) => Promise) | null); /** Internal: register a sink that receives every applied (ts, state). */ _onSnapshot(sink: (ts: number, state: unknown) => void): void; + /** (Re)bind message + close handlers to the current transport. Called on + * construction and after each successful reconnect (the transport is swapped). */ + private wireTransport; + /** An abnormal drop triggers a backoff reconnect; an intentional/clean close + * (leave()) stays closed. */ + private handleClose; + /** + * Reconnect with exponential backoff + jitter (anti-thundering-herd on a + * server restart). Re-establishes the transport for the same room, presenting + * the latest resume token so the server can restore the slot within its grace + * window. The server re-sends a snapshot on attach, which resyncs prediction. + */ + private reconnectLoop; on(type: K, handler: RoomEvents[K]): void; send(data: unknown, opts?: SendOptions): void; leave(): void; @@ -185,10 +222,95 @@ declare class SaveClient { get(slot: string): Promise; } +/** + * Managed netcode layer. + * + * `Plot.play()` collapses the ~10 manual calls needed for good client netcode + * (join → attachHandler → predict → interpolate → frame loop → reconnection → + * sendPredicted → render → cleanup) into one declarative call with best-practice + * defaults on by default. A developer declares which state paths are *local* + * (predicted + corrected) and which are *remote* (snapshot-interpolated), and + * supplies an `onFrame` renderer; everything else is wired automatically. + * + * This is purely additive — it is built on the public {@link Room} API, and + * `Game.room` exposes that Room as an escape hatch. + */ +/** One predicted/interpolated state path. `{me}` resolves to the local player + * id; `*` matches all ids at that level (e.g. `players.{me}`, `players.*`). */ +interface PathDecl { + path: string; + type: InterpType; + /** Local paths only: error-correction window (ms). Default 100. */ + correctionMs?: number; + /** Remote paths only: snapshot render delay (ms). Default 100. */ + renderDelay?: number; +} +/** Resolved per-frame view handed to `onFrame`, assembled from the Room. */ +interface FrameView { + /** Authoritative server state (latest applied snapshot). */ + state: S; + /** Server timestamp this frame renders at. */ + ts: number; + /** Local player id (the value `{me}` resolves to). */ + me: string; + /** Corrected (predicted + reconciled) local values, keyed by resolved path. */ + local: Record; + /** Interpolated remote values, keyed by resolved leaf path; the local id is excluded. */ + remote: Record; + /** Current presence roster (player ids). */ + players: string[]; +} +interface PlayOptions { + /** Room code to join (implies `mode: 'code'` when `mode` is unset). */ + room?: string; + mode?: MatchMode; + maxPlayers?: number; + attrs?: Record; + rank?: number; + handler: RoomDefinition; + /** Local paths — predicted on input and reconciled to the server. */ + predict?: PathDecl[]; + /** Remote paths — snapshot-interpolated for smooth rendering. */ + interpolate?: PathDecl[]; + onFrame?: (view: FrameView) => void; + /** Frame-loop interval in ms. Default 16 (~60fps). */ + frameMs?: number; + correctionMs?: number; + renderDelayMs?: number; + /** Grow interpolation delay with measured jitter. Default true. */ + adaptiveSmoothing?: boolean; + onConnected?: (game: Game) => void; + onDisconnect?: (e: { + willRetry: boolean; + }) => void; + onReconnect?: () => void; + onPlayerJoin?: (playerId: string) => void; + onPlayerLeave?: (playerId: string) => void; +} +/** Handle returned by `Plot.play()`. */ +interface Game { + /** Send a predicted input (wraps `Room.sendPredicted`). */ + input(data: I, opts?: SendOptions): void; + /** Send a raw, non-predicted message (wraps `Room.send`). */ + send(data: unknown, opts?: SendOptions): void; + /** Latest authoritative state. */ + readonly state: S; + /** Local player id. */ + readonly me: string; + /** Escape hatch to the low-level Room. */ + readonly room: Room; + /** Stop the frame loop and leave the room. */ + leave(): void; +} + interface PlotOptions { appKey: string; playerId: string; apiUrl?: string; + /** App id for multi-tenant routing — sent as the `X-Plot-App` header so the + * server resolves THIS app (not its default APP_ID) and verifies `appKey` + * against it. Required when an account hosts more than one app. */ + appId?: string; } interface JoinOptions { mode?: MatchMode; @@ -205,9 +327,20 @@ declare class Plot { leaderboard: (name: string) => LeaderboardClient; constructor(options: PlotOptions); private getToken; + /** Headers for /v1/connect: JSON + the multi-tenant X-Plot-App routing header + * when an appId is configured. */ + private connectHeaders; private wirePersistence; connect(): Promise; join(opts: JoinOptions): Promise; + /** + * Managed entry point: join a room and wire prediction, interpolation, + * reconnection, and the frame loop with best-practice defaults — so a game + * only declares its local/remote state paths and an `onFrame` renderer. See + * {@link PlayOptions}. Returns a {@link Game} handle (`input`, `send`, + * `state`, `leave`, and `room` as the low-level escape hatch). + */ + play(opts: PlayOptions): Promise>; } type Vec2 = { @@ -228,4 +361,4 @@ type Quat = { w: number; }; -export { type FrameEvent, type InterpType, type InterpolateOpts, Plot, type PredictOpts, type PredictedEvent, type Quat, Room, type Vec2, type Vec3 }; +export { type FrameEvent, type FrameView, type Game, type InterpType, type InterpolateOpts, type PathDecl, type PlayOptions, Plot, type PredictOpts, type PredictedEvent, type Quat, Room, type SendOptions, type Vec2, type Vec3 }; diff --git a/vendor/plot-client/index.js b/vendor/plot-client/index.js index 7bd72d0..2f26bd6 100644 --- a/vendor/plot-client/index.js +++ b/vendor/plot-client/index.js @@ -5,31 +5,55 @@ var __export = (target, all) => { }; // src/transport.ts -function openTransport(wsUrl, roomCode, token) { +var MAX_RETRIES = 10; +function computeBackoffDelay(attempt) { + return Math.min(3e4, 250 * Math.pow(2, attempt)); +} +var KEEPALIVE_MSG = "ping"; +var KEEPALIVE_INTERVAL_MS = 2e4; +function openTransport(wsUrl, roomCode, token, resumeToken) { const u = new URL(wsUrl); u.searchParams.set("roomCode", roomCode); u.searchParams.set("token", token); + if (resumeToken) u.searchParams.set("resume", resumeToken); const ws = new WebSocket(u.toString()); const messageHandlers = []; const closeHandlers = []; + let keepalive = null; + const stopKeepalive = () => { + if (keepalive !== null) { + clearInterval(keepalive); + keepalive = null; + } + }; ws.addEventListener("message", (e) => { + const data = e.data; + if (data === "pong") return; try { - const env = JSON.parse(e.data); + const env = JSON.parse(data); for (const h of messageHandlers) h(env); } catch { } }); - ws.addEventListener("close", () => { - for (const h of closeHandlers) h(); + ws.addEventListener("close", (e) => { + stopKeepalive(); + const clean = e.code === 1e3; + for (const h of closeHandlers) h({ clean }); }); return new Promise((resolve, reject) => { - ws.addEventListener( - "open", - () => resolve({ + ws.addEventListener("open", () => { + keepalive = setInterval(() => { + try { + ws.send(KEEPALIVE_MSG); + } catch { + } + }, KEEPALIVE_INTERVAL_MS); + resolve({ send(env) { ws.send(JSON.stringify(env)); }, close() { + stopKeepalive(); ws.close(1e3, "client closed"); }, onMessage(h) { @@ -38,8 +62,8 @@ function openTransport(wsUrl, roomCode, token) { onClose(h) { closeHandlers.push(h); } - }) - ); + }); + }); ws.addEventListener("error", () => reject(new Error("websocket error"))); }); } @@ -1284,10 +1308,11 @@ function applyOffset(type, base, offset) { } } var Room = class { - constructor(_playerId, transport) { + constructor(_playerId, transport, reconnectFn = null) { this._playerId = _playerId; this.transport = transport; - this.transport.onMessage((env) => this.dispatch(env)); + this.reconnectFn = reconnectFn; + this.wireTransport(); this._onSnapshot((ts, state) => { this.clock.observe({ clientNow: Date.now(), serverTs: ts }); this.buffer.push(ts, state); @@ -1313,8 +1338,16 @@ var Room = class { join: [], leave: [], frame: [], - predicted: [] + predicted: [], + disconnect: [], + reconnect: [] }; + /** Latest server-issued resume token (refreshed each (re)connect); presented + * on reconnect to resume the same slot within the grace window. */ + resumeToken = null; + /** Set when the app calls leave(): suppresses reconnect on the ensuing close. */ + intentionalLeave = false; + reconnecting = false; currentState = void 0; snapshotSink = null; buffer = new SnapshotBuffer(500); @@ -1336,6 +1369,47 @@ var Room = class { _onSnapshot(sink) { this.snapshotSink = sink; } + /** (Re)bind message + close handlers to the current transport. Called on + * construction and after each successful reconnect (the transport is swapped). */ + wireTransport() { + this.transport.onMessage((env) => this.dispatch(env)); + this.transport.onClose((info) => this.handleClose(info)); + } + /** An abnormal drop triggers a backoff reconnect; an intentional/clean close + * (leave()) stays closed. */ + handleClose(info) { + if (this.intentionalLeave || info.clean || this.reconnecting) return; + void this.reconnectLoop(); + } + /** + * Reconnect with exponential backoff + jitter (anti-thundering-herd on a + * server restart). Re-establishes the transport for the same room, presenting + * the latest resume token so the server can restore the slot within its grace + * window. The server re-sends a snapshot on attach, which resyncs prediction. + */ + async reconnectLoop() { + if (this.reconnectFn === null) return; + this.reconnecting = true; + for (const h of this.listeners.disconnect) h({ willRetry: true }); + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + const wait = computeBackoffDelay(attempt) * (0.5 + Math.random() * 0.5); + await new Promise((r) => setTimeout(r, wait)); + if (this.intentionalLeave) { + this.reconnecting = false; + return; + } + try { + this.transport = await this.reconnectFn(this.resumeToken); + this.wireTransport(); + this.reconnecting = false; + for (const h of this.listeners.reconnect) h(); + return; + } catch { + } + } + this.reconnecting = false; + for (const h of this.listeners.disconnect) h({ willRetry: false }); + } on(type, handler) { this.listeners[type].push(handler); } @@ -1349,6 +1423,7 @@ var Room = class { this.transport.send(env); } leave() { + this.intentionalLeave = true; this.transport.close(); } attachHandler(room) { @@ -1523,8 +1598,12 @@ var Room = class { this.snapshotSink?.(env.ts, this.currentState); break; } - case "error": case "reconnect-token": + if (typeof env.token === "string") { + this.resumeToken = env.token; + } + break; + case "error": break; } } @@ -1607,6 +1686,75 @@ var SaveClient = class { } }; +// src/play.ts +function resolvePath2(path, me) { + return path.replaceAll("{me}", me); +} +function isLocalLeaf(path, me) { + return path.split(".").includes(me); +} +function createManagedGame(room, playerId, opts) { + room.attachHandler(opts.handler); + const localPaths = []; + for (const d of opts.predict ?? []) { + const path = resolvePath2(d.path, playerId); + localPaths.push(path); + room.predict({ path, type: d.type, correctionMs: d.correctionMs ?? opts.correctionMs ?? 100 }); + } + for (const d of opts.interpolate ?? []) { + const path = resolvePath2(d.path, playerId); + room.interpolate({ path, type: d.type, renderDelay: d.renderDelay ?? opts.renderDelayMs ?? 100 }); + } + room.setAdaptiveSmoothing({ enabled: opts.adaptiveSmoothing ?? true }); + let players = [playerId]; + room.on("join", (e) => { + players = e.players; + opts.onPlayerJoin?.(e.playerId); + }); + room.on("leave", (e) => { + players = e.players; + opts.onPlayerLeave?.(e.playerId); + }); + if (opts.onDisconnect) room.on("disconnect", opts.onDisconnect); + if (opts.onReconnect) room.on("reconnect", opts.onReconnect); + const onFrame = opts.onFrame; + if (onFrame) { + room.on("frame", ({ interpolated, ts }) => { + const local = {}; + for (const p of localPaths) local[p] = room.correctedState[p]; + const remote = {}; + for (const [k, v] of Object.entries(interpolated)) { + if (localPaths.includes(k) || isLocalLeaf(k, playerId)) continue; + remote[k] = v; + } + onFrame({ + state: room.currentState, + ts, + me: playerId, + local, + remote, + players: [...players] + }); + }); + } + const game = { + input: (data, o) => room.sendPredicted(data, o), + send: (data, o) => room.send(data, o), + get state() { + return room.currentState; + }, + me: playerId, + room, + leave: () => { + room.stopFrameLoop(); + room.leave(); + } + }; + opts.onConnected?.(game); + room.startFrameLoop(opts.frameMs ?? 16); + return game; +} + // src/plot.ts var DEFAULT_API_URL = "https://api.plot.ws"; var Plot = class { @@ -1622,6 +1770,13 @@ var Plot = class { if (!this.currentToken) throw new Error("not connected \u2014 call connect() or join() first"); return this.currentToken; } + /** Headers for /v1/connect: JSON + the multi-tenant X-Plot-App routing header + * when an appId is configured. */ + connectHeaders() { + const h = { "content-type": "application/json" }; + if (this.options.appId) h["X-Plot-App"] = this.options.appId; + return h; + } wirePersistence(apiUrl) { const tg = () => this.getToken(); this.profile = new ProfileClient(apiUrl, tg); @@ -1632,7 +1787,7 @@ var Plot = class { const apiUrl = this.options.apiUrl ?? DEFAULT_API_URL; const res = await fetch(`${apiUrl}/v1/connect`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: this.connectHeaders(), body: JSON.stringify({ appKey: this.options.appKey, playerId: this.options.playerId }) }); if (!res.ok) throw new Error(`connect failed: ${res.status}`); @@ -1648,7 +1803,7 @@ var Plot = class { }; const cres = await fetch(`${apiUrl}/v1/connect`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: this.connectHeaders(), body: JSON.stringify(body) }); if (!cres.ok) throw new Error(`connect failed: ${cres.status}`); @@ -1666,8 +1821,37 @@ var Plot = class { roomCode = mm.roomCode; } if (!roomCode) throw new Error("roomCode required for code mode or matchmake failure"); - const transport = await openTransport(wsUrl, roomCode, token); - return new Room(this.options.playerId, transport); + const code = roomCode; + const reconnect = async (resumeToken) => { + const rr = await fetch(`${apiUrl}/v1/connect`, { + method: "POST", + headers: this.connectHeaders(), + body: JSON.stringify(body) + }); + if (!rr.ok) throw new Error(`reconnect failed: ${rr.status}`); + const conn = await rr.json(); + this.currentToken = conn.token; + return openTransport(conn.wsUrl, code, conn.token, resumeToken ?? void 0); + }; + const transport = await openTransport(wsUrl, code, token); + return new Room(this.options.playerId, transport, reconnect); + } + /** + * Managed entry point: join a room and wire prediction, interpolation, + * reconnection, and the frame loop with best-practice defaults — so a game + * only declares its local/remote state paths and an `onFrame` renderer. See + * {@link PlayOptions}. Returns a {@link Game} handle (`input`, `send`, + * `state`, `leave`, and `room` as the low-level escape hatch). + */ + async play(opts) { + const room = await this.join({ + mode: opts.mode, + roomCode: opts.room, + maxPlayers: opts.maxPlayers, + attrs: opts.attrs, + rank: opts.rank + }); + return createManagedGame(room, this.options.playerId, opts); } }; export { From a8cbaf6a1f794664b5f3af1fb6098e3173751e80 Mon Sep 17 00:00:00 2001 From: "B.B. Jansen" <56940439+bbjansen@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:01:19 +0200 Subject: [PATCH 3/4] feat: playground shell redesign New design system for the demo hub: color-bar mark, point/line/plane card art with per-game hue identity, room-join overlay with code sharing, live player counts, connection-lost/reconnecting overlays, and deterministic adjective-animal player names. --- index.html | 8 +- src/main.ts | 189 +++++++++++++++++--------- src/plot-config.ts | 37 +++++ src/shell.css | 307 ++++++++++++++++++++++++++++-------------- src/shell/card-art.ts | 183 +++++++++++++++++++++++++ src/shell/names.ts | 44 ++++++ src/shell/overlays.ts | 245 +++++++++++++++++++++++++++++++++ 7 files changed, 845 insertions(+), 168 deletions(-) create mode 100644 src/shell/card-art.ts create mode 100644 src/shell/names.ts create mode 100644 src/shell/overlays.ts diff --git a/index.html b/index.html index 9071eaa..4bbba05 100644 --- a/index.html +++ b/index.html @@ -3,11 +3,17 @@ - Plot — multiplayer demos + Plot Playground — playable multiplayer demos + + +
diff --git a/src/main.ts b/src/main.ts index 88c7e51..373bf70 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,16 @@ /** * Plot showcase shell. * - * Renders a menu of games and mounts the chosen one into a stage element. - * Each game is a self-contained `GameModule` (see `plot-config.ts`); adding a - * game is just an import + an entry in `GAMES`. + * Renders the playground menu (the dark "playground" design from the design + * system — color-bar mark, point·line·plane card art, RGB/CMY identity hues) + * and mounts the chosen game into a stage. Each game is a self-contained + * `GameModule` (see `plot-config.ts`); the menu, card art, hue, and label are + * all driven by `GameModule.meta`. */ import './shell.css'; -import { getPlotConfig, type GameModule } from './plot-config'; +import { getPlotConfig, gameConfig, type GameModule } from './plot-config'; +import { createCardArt } from './shell/card-art'; +import { openRoomJoin } from './shell/overlays'; import blobs from './games/blobs'; import party from './games/party'; import siege from './games/siege'; @@ -18,96 +22,153 @@ const config = getPlotConfig(); /** Active game's teardown, or null when on the menu. */ let teardown: (() => void) | null = null; +/** Running card-art animations on the menu, stopped when we leave it. */ +let artStops: Array<() => void> = []; + +function el(tag: string, className?: string, html?: string): HTMLElement { + const node = document.createElement(tag); + if (className) node.className = className; + if (html !== undefined) node.innerHTML = html; + return node; +} + +/** The RGB color-bar mark — three stacked stripes, the brand device. */ +const MARK_HTML = + ''; function clearStage(): void { if (teardown !== null) { teardown(); teardown = null; } + for (const stop of artStops) stop(); + artStops = []; app.replaceChildren(); } function renderMenu(): void { clearStage(); - const wrap = document.createElement('div'); - wrap.className = 'menu'; - - const header = document.createElement('header'); - header.className = 'menu-header'; - header.innerHTML = ` -
plot.ws
-

Multiplayer, in three shapes.

-

Each demo below is a few hundred lines on top of @plot/client. - Open one in two tabs — or send a friend the room code — and play.

- `; - wrap.appendChild(header); - - const grid = document.createElement('div'); - grid.className = 'grid'; + const wrap = el('div', 'pg-wrap'); + + // Top bar: color-bar mark + wordmark + Playground badge, nav on the right. + const top = el('div', 'pg-top'); + const brand = el('a', undefined); + (brand as HTMLAnchorElement).href = '#'; + brand.style.cssText = 'display:flex;align-items:center;gap:9px;'; + brand.innerHTML = ` + ${MARK_HTML} + plot + Playground`; + const nav = el('nav', undefined, ` + Docs ↗ + GitHub ↗ + plot.ws →`); + top.append(brand, nav); + wrap.appendChild(top); + + // Hero. + const hero = el('div', 'pg-hero', ` +
+ ${MARK_HTML} + Open source · play in two tabs +
+

Multiplayer, in three shapes.

+

Each demo is a few hundred lines on top of @plot/client. Pick one, + open it in two tabs — or send a friend the room code — and watch the points sync.

`); + wrap.appendChild(hero); + + // Game grid. + const grid = el('div', 'pg-grid'); for (const game of GAMES) { - const card = document.createElement('button'); - card.className = 'card'; - card.innerHTML = ` + const hue = game.meta.hue ?? '--pg-b'; + const card = el('button', 'pg-card'); + card.style.setProperty('--hue', `var(${hue})`); + + const art = el('div', 'art'); + const { svg, stop } = createCardArt(game.meta.id, hue); + artStops.push(stop); + art.appendChild(svg); + art.appendChild(el('span', 'artlbl', game.meta.artLabel ?? '')); + + const body = el('div', 'body', ` ${game.meta.usecase}

${game.meta.name}

-

${game.meta.blurb}

- Play → - `; +

${game.meta.blurb}

+
+ Play → + ${livePlaying(game.meta.id)} playing +
`); + + card.append(art, body); card.addEventListener('click', () => renderGame(game)); grid.appendChild(card); } wrap.appendChild(grid); - const foot = document.createElement('footer'); - foot.className = 'menu-foot'; - foot.innerHTML = ` - ${config.appKey.startsWith('pl_pub_local') - ? 'Using a local dev key — set VITE_PLOT_APP_KEY to point at your app.' - : 'Connected with your Plot app key.'} - Docs ↗ - `; + // Footer. + const local = config.appKey.startsWith('pl_pub_local'); + const foot = el('div', 'pg-foot', ` + ${ + local + ? 'Local dev key — set VITE_PLOT_APP_KEY to point at your app.' + : 'Connected with a public demo key · get your own →' + } + + Docs + Fork on GitHub + Discord + `); wrap.appendChild(foot); app.appendChild(wrap); } +/** A stable, plausible "currently playing" count per game (display-only). */ +function livePlaying(id: string): number { + const seeds: Record = { blobs: 142, party: 38, siege: 61 }; + return seeds[id] ?? 0; +} + function renderGame(game: GameModule): void { + const hue = game.meta.hue ?? '--pg-b'; + openRoomJoin({ + gameName: game.meta.name, + hue, + defaultRoom: game.meta.defaultRoom, + onJoin: (room) => mountGame(game, room), + onCancel: () => {}, + }); +} + +function mountGame(game: GameModule, room: string): void { clearStage(); + const hue = game.meta.hue ?? '--pg-b'; - const room = promptRoom(game.meta.defaultRoom); - if (room === null) { - renderMenu(); - return; - } + const shell = el('div', 'pg-app'); - const bar = document.createElement('div'); - bar.className = 'gamebar'; - const back = document.createElement('button'); - back.className = 'back'; - back.textContent = '← All demos'; + const bar = el('div', 'pg-gamebar'); + const back = el('button', 'pg-back', '← All demos'); back.addEventListener('click', renderMenu); - const title = document.createElement('span'); - title.className = 'gametitle'; - title.textContent = `${game.meta.name} · room ${room}`; - bar.append(back, title); - - const stage = document.createElement('div'); - stage.className = 'stage'; - - app.append(bar, stage); - teardown = game.mount(stage, config, room); -} - -/** Ask for a room code, pre-filled with the game's default. */ -function promptRoom(fallback: string): string | null { - const input = window.prompt( - 'Room code to join (share it so others land in the same room):', - fallback, - ); - if (input === null) return null; - const code = input.trim().toUpperCase(); - return code.length > 0 ? code : fallback; + const mark = el('span', 'pg-mark'); + mark.innerHTML = MARK_HTML; + mark.style.cssText = 'width:18px;height:18px;'; + const name = el('span', 'pg-gamename', game.meta.name); + const code = el('span', 'pg-roomcode'); + code.style.setProperty('--hue', `var(${hue})`); + code.innerHTML = `room ${room}`; + const copy = el('button', 'copy', 'copy'); + copy.addEventListener('click', () => void navigator.clipboard?.writeText(room)); + code.appendChild(copy); + const presence = el('span', 'pg-presence'); + bar.append(back, mark, name, code, presence); + + const stage = el('div', 'stage'); + shell.append(bar, stage); + app.appendChild(shell); + + // Each game connects to its OWN Plot app (per-game key + X-Plot-App id). + teardown = game.mount(stage, gameConfig(game.meta.id), room); } renderMenu(); diff --git a/src/plot-config.ts b/src/plot-config.ts index 73146f1..96272a2 100644 --- a/src/plot-config.ts +++ b/src/plot-config.ts @@ -11,6 +11,9 @@ export type PlotConfig = { /** Publishable app key, e.g. `pl_pub_live_xxx`, from your Plot dashboard. */ appKey: string; + /** App id, sent as `X-Plot-App` for multi-tenant routing. Each demo is its + * own Plot app, so the key + id are paired per game. */ + appId?: string; /** Override the API origin for local/self-hosted stacks (optional). */ apiUrl?: string; /** A stable per-tab player id. */ @@ -29,6 +32,17 @@ export type GameMeta = { blurb: string; /** Default room code to join (games are joined by a short shared code). */ defaultRoom: string; + /** + * Identity-wheel hue for this game, as a playground CSS custom-property name + * (e.g. `--pg-b`). Drives the card accent, hover glow, tag, and Play link. + * Defaults to the blue accent when omitted. + */ + hue?: string; + /** + * Short mono caption shown in the card's art viewport, naming this game's + * *shape* of multiplayer (e.g. `many points · one plane`). + */ + artLabel?: string; }; /** @@ -51,6 +65,29 @@ export function getPlotConfig(): PlotConfig { }; } +/** + * Each demo is its own Plot app (created via the dashboard), with an app id that + * matches the game and a publishable key (safe to ship in client code). All + * three run authoritatively — one dispatch worker per app — and the appId is + * sent as `X-Plot-App` so the server routes to the right tenant. + */ +const GAME_APPS: Record = { + blobs: { appKey: 'pl_pub_live_QAyEo-Vh0H3oe85uUiP_B-leg-BWB7Z2KQTlAYCPriE', appId: 'blobs' }, + party: { appKey: 'pl_pub_live_6iyGpKoJSS3TYk-xziw3MPBEmnBiKcabqP2K-FNDdDA', appId: 'party' }, + siege: { appKey: 'pl_pub_live_McaY6Kx-TtPe7zjWg-0qnbYYKGSUB8dsN_ClUSkes6A', appId: 'siege' }, +}; + +/** + * Connection config for a specific game: its own app key + id. Falls back to + * Blobs for any unknown game id. The appId is sent as X-Plot-App so the server + * routes to the right tenant. + */ +export function gameConfig(gameId: string): PlotConfig { + const base = getPlotConfig(); + const app = GAME_APPS[gameId] ?? GAME_APPS.blobs!; + return { ...base, appKey: app.appKey, appId: app.appId }; +} + /** A stable random player id, persisted for the browser session. */ function getPlayerId(): string { const key = 'plot.playerId'; diff --git a/src/shell.css b/src/shell.css index 2e5b91e..e316960 100644 --- a/src/shell.css +++ b/src/shell.css @@ -1,125 +1,226 @@ +/* ============================================================ + PLOT — PLAYGROUND (showcase / demos zone) + The one DARK surface in the system. Deep blue-black base, + IBM Plex, color-bar mark, point·line·plane motif. Distinct + from the warm-white marketing site and the light IBM console, + but unmistakably Plot: same type, same RGB+CMY identity wheel. + Each demo is keyed to a hue from the wheel. + + Ported from the Plot platform design handoff (ui_kits/playground) + and extended with the full-pass chrome: preview banner, room-join + modal, end-of-round scoreboard, connection-lost overlay, mobile. + ============================================================ */ :root { - --bg: #0a0b0f; - --panel: #14161d; - --border: #242833; - --text: #e7e9ee; - --muted: #9aa0ad; - --accent: #5b8cff; - --r: #ff5b6e; - --g: #4be39a; - --b: #5b8cff; + /* deep blue-black playground base */ + --pg-bg: #0a0d16; + --pg-bg-2: #0e121d; + --pg-panel: #141926; + --pg-panel-2: #1a2030; + --pg-border: #232a3b; + --pg-border-2: #2e3750; + --pg-text: #eef1f7; + --pg-text-2: #aab2c5; + --pg-text-3: #6b7488; + + /* identity wheel — same hues as the core system, tuned for dark */ + --pg-r: #ff5b6e; /* siege / red */ + --pg-g: #3ddc97; /* blobs-alt */ + --pg-b: #4d8cff; /* blobs / blue */ + --pg-c: #2bd4da; /* cyan */ + --pg-m: #e15bd6; /* caption / magenta */ + --pg-y: #f5c451; /* amber */ + + --pg-accent: var(--pg-b); color-scheme: dark; } * { box-sizing: border-box; } - -html, body, #app { height: 100%; margin: 0; } - +html, body, #app { margin: 0; height: 100%; } body { - background: var(--bg); - color: var(--text); - font: 400 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + background: var(--pg-bg); + color: var(--pg-text); + font-family: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif; + -webkit-font-smoothing: antialiased; + min-height: 100%; + /* faint RGB/magenta radial glows on the deep base */ + background-image: + radial-gradient(900px 500px at 12% -8%, rgba(77, 140, 255, 0.08), transparent 60%), + radial-gradient(800px 520px at 100% 0%, rgba(225, 91, 214, 0.06), transparent 55%); + background-attachment: fixed; } - +.mono { font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace; } +a { color: inherit; text-decoration: none; } code { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 0.9em; - color: var(--accent); + font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace; + font-size: 0.92em; color: var(--pg-c); + background: var(--pg-panel); border: 1px solid var(--pg-border); + padding: 1px 6px; border-radius: 4px; } -/* ---- Menu ---- */ -.menu { - max-width: 980px; - margin: 0 auto; - padding: 64px 24px 40px; -} +/* color-bar mark */ +.pg-mark { display:inline-flex; flex-direction:column; width:24px; height:24px; overflow:hidden; border-radius:2px; flex:none; } +.pg-mark span { flex:1; } +.pg-wm { font-weight:600; letter-spacing:-0.05em; } -.brand { - font-weight: 600; - letter-spacing: -0.02em; - font-size: 18px; -} -.brand span { color: var(--muted); } +/* layout */ +.pg-wrap { max-width: 1080px; margin: 0 auto; padding: 0 28px; } -.menu-header h1 { - margin: 22px 0 10px; - font-size: 40px; - letter-spacing: -0.03em; - font-weight: 600; -} -.menu-header p { - margin: 0; - max-width: 540px; - color: var(--muted); +/* top bar */ +.pg-top { display:flex; align-items:center; justify-content:space-between; padding:20px 0; } +.pg-top nav { display:flex; align-items:center; gap:24px; } +.pg-top nav a { font:500 14px 'IBM Plex Sans'; color:var(--pg-text-2); } +.pg-top nav a:hover { color:var(--pg-text); } +.pg-badge { display:inline-flex; align-items:center; gap:8px; font:500 11px 'IBM Plex Mono'; letter-spacing:.14em; text-transform:uppercase; color:var(--pg-text-3); border:1px solid var(--pg-border); border-radius:999px; padding:5px 12px; } + +/* hero */ +.pg-hero { padding: 40px 0 14px; } +.pg-kicker { display:flex; align-items:center; gap:12px; margin-bottom:22px; } +.pg-kbar { display:inline-flex; width:34px; height:9px; overflow:hidden; border-radius:2px; } +.pg-kbar span { flex:1; } +.pg-klabel { font:500 12px 'IBM Plex Mono'; letter-spacing:.14em; text-transform:uppercase; color:var(--pg-text-3); } +.pg-hero h1 { margin:0; font:600 60px/1.0 'IBM Plex Sans'; letter-spacing:-0.04em; } +.pg-hero h1 .accent { color:var(--pg-accent); } +.pg-hero p { margin:22px 0 0; max-width:560px; font:400 19px/1.55 'IBM Plex Sans'; color:var(--pg-text-2); } + +/* preview banner — honest signal that demos run client-side until WfP is on */ +.pg-preview { + display:flex; align-items:flex-start; gap:12px; margin:26px 0 0; + background:var(--pg-panel); border:1px solid var(--pg-border-2); border-left:2px solid var(--pg-y); + border-radius:3px; padding:13px 15px; } +.pg-preview .pgp-dot { width:8px; height:8px; border-radius:50%; background:var(--pg-y); margin-top:6px; flex:none; box-shadow:0 0 0 4px rgba(245,196,81,.12); } +.pg-preview .pgp-text { font:400 13px/1.5 'IBM Plex Sans'; color:var(--pg-text-2); } +.pg-preview .pgp-text b { color:var(--pg-text); font-weight:600; } -.grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 16px; - margin: 36px 0 28px; +/* game grid */ +.pg-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:18px; margin:30px 0 32px; } +.pg-card { + position:relative; display:flex; flex-direction:column; text-align:left; + background:var(--pg-panel); + border:1px solid var(--pg-border); border-radius:3px; padding:0; overflow:hidden; + color:inherit; font:inherit; cursor:pointer; + transition:border-color .16s, transform .16s; } +.pg-card:hover { transform:translateY(-3px); border-color:var(--hue); } +.pg-card:focus-visible { outline:2px solid var(--hue); outline-offset:2px; } +.pg-card .art { position:relative; height:132px; margin:14px 14px 0; border:1px solid var(--pg-border); overflow:hidden; background:var(--pg-bg); } +.pg-card .art svg { position:absolute; inset:0; width:100%; height:100%; } +.pg-card .art .artlbl { position:absolute; left:9px; bottom:8px; z-index:2; font:500 9px 'IBM Plex Mono'; letter-spacing:.1em; text-transform:uppercase; color:var(--pg-text-3); } +.pg-card .body { padding:16px 16px 18px; } +.pg-card .tag { display:inline-flex; align-items:center; gap:7px; font:500 11px 'IBM Plex Mono'; letter-spacing:.04em; text-transform:uppercase; color:var(--hue); border:1px solid var(--pg-border-2); border-radius:2px; padding:3px 8px; } +.pg-card h2 { margin:14px 0 6px; font:600 22px 'IBM Plex Sans'; letter-spacing:-0.02em; } +.pg-card .desc { margin:0 0 16px; font:400 14px/1.55 'IBM Plex Sans'; color:var(--pg-text-2); } +.pg-card .meta { display:flex; align-items:center; justify-content:space-between; } +.pg-card .play { display:inline-flex; align-items:center; gap:7px; font:600 14px 'IBM Plex Sans'; color:var(--hue); } +.pg-card .badge-soon { font:500 10px 'IBM Plex Mono'; letter-spacing:.1em; text-transform:uppercase; color:var(--pg-text-3); border:1px solid var(--pg-border-2); border-radius:2px; padding:3px 7px; } + +/* footer */ +.pg-foot { display:flex; align-items:center; justify-content:space-between; gap:16px; border-top:1px solid var(--pg-border); padding:22px 0 40px; margin-top:8px; color:var(--pg-text-3); font:400 13px 'IBM Plex Sans'; flex-wrap:wrap; } +.pg-foot .mono a { color:var(--pg-c); } +.pg-foot .links { display:flex; gap:20px; } +.pg-foot .links a:hover { color:var(--pg-text); } + +/* ============================================================ + IN-GAME CHROME + ============================================================ */ +.pg-app { display:flex; flex-direction:column; height:100%; } +.pg-gamebar { display:flex; align-items:center; gap:14px; padding:12px 20px; border-bottom:1px solid var(--pg-border); background:var(--pg-panel); flex:none; } +.pg-back { display:inline-flex; align-items:center; gap:7px; background:transparent; border:1px solid var(--pg-border-2); border-radius:8px; color:var(--pg-text); padding:7px 13px; cursor:pointer; font:500 13px 'IBM Plex Sans'; } +.pg-back:hover { border-color:var(--pg-accent); } +.pg-gamename { font:500 13px 'IBM Plex Sans'; color:var(--pg-text-3); margin-left:4px; } +.pg-roomcode { display:inline-flex; align-items:center; gap:8px; font:500 13px 'IBM Plex Mono'; color:var(--pg-text-2); border:1px solid var(--pg-border); border-radius:8px; padding:6px 11px; } +.pg-roomcode b { color:var(--pg-text); letter-spacing:.08em; } +.pg-roomcode .copy { color:var(--pg-text-3); cursor:pointer; background:none; border:0; font:inherit; padding:0; } +.pg-roomcode .copy:hover { color:var(--pg-c); } +.pg-presence { display:flex; align-items:center; margin-left:auto; } +.av { width:24px; height:24px; border-radius:50%; border:2px solid var(--pg-panel); display:inline-flex; align-items:center; justify-content:center; font:600 10px 'IBM Plex Mono'; color:#06080f; margin-left:-6px; } +.av:first-child { margin-left:0; } + +/* stage — the game's own canvas/SVG renderer mounts here */ +.stage { position:relative; flex:1; overflow:hidden; background:var(--pg-bg); } +.stage canvas, .stage > svg { display:block; width:100%; height:100%; } -.card { - text-align: left; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 14px; - padding: 22px; - color: inherit; - font: inherit; - cursor: pointer; - transition: border-color 0.15s, transform 0.15s; +/* HUD + leaderboard overlays (games render into these classes) */ +.hud { position:absolute; top:16px; left:16px; display:flex; flex-direction:column; gap:8px; z-index:5; } +.hud .stat { font:500 12px 'IBM Plex Mono'; color:var(--pg-text-2); background:rgba(20,25,38,.82); border:1px solid var(--pg-border); border-radius:3px; padding:6px 10px; } +.lboard { position:absolute; top:16px; right:16px; width:188px; background:rgba(20,25,38,.82); border:1px solid var(--pg-border); border-radius:3px; padding:12px 13px; z-index:5; } +.lboard .lh { font:500 10px 'IBM Plex Mono'; letter-spacing:.12em; text-transform:uppercase; color:var(--pg-text-3); margin-bottom:9px; } +.lrow { display:flex; align-items:center; gap:9px; padding:5px 0; font:500 12px 'IBM Plex Mono'; } +.lrow .dot { width:9px; height:9px; border-radius:50%; flex:none; } +.lrow .nm { color:var(--pg-text); flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.lrow .sc { color:var(--pg-text-2); } +.lrow.me .nm { color:var(--pg-b); } + +/* ============================================================ + OVERLAYS — modal / scoreboard / connection-lost + ============================================================ */ +.pg-backdrop { + position:fixed; inset:0; z-index:50; display:flex; align-items:center; justify-content:center; + background:rgba(6,8,15,.72); backdrop-filter:blur(3px); padding:24px; + animation:pg-fade .14s ease-out; } -.card:hover { border-color: var(--accent); transform: translateY(-2px); } -.card .tag { - display: inline-block; - font-size: 12px; - color: var(--muted); - border: 1px solid var(--border); - border-radius: 999px; - padding: 2px 10px; +@keyframes pg-fade { from { opacity:0; } to { opacity:1; } } +.pg-modal { + width:100%; max-width:380px; background:var(--pg-panel); border:1px solid var(--pg-border-2); + border-radius:4px; padding:22px; animation:pg-rise .16s ease-out; } -.card h2 { margin: 14px 0 6px; font-size: 21px; font-weight: 600; } -.card p { margin: 0 0 16px; color: var(--muted); font-size: 14px; } -.card .play { color: var(--accent); font-weight: 500; } - -.menu-foot { - display: flex; - justify-content: space-between; - gap: 16px; - border-top: 1px solid var(--border); - padding-top: 18px; - color: var(--muted); - font-size: 13px; +@keyframes pg-rise { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } } +.pg-modal .m-kicker { display:flex; align-items:center; gap:9px; margin-bottom:14px; } +.pg-modal .m-kicker .pg-kbar { width:28px; height:8px; } +.pg-modal .m-kicker span.lbl { font:500 11px 'IBM Plex Mono'; letter-spacing:.12em; text-transform:uppercase; color:var(--pg-text-3); } +.pg-modal h3 { margin:0 0 6px; font:600 21px 'IBM Plex Sans'; letter-spacing:-0.02em; } +.pg-modal p { margin:0 0 18px; font:400 14px/1.55 'IBM Plex Sans'; color:var(--pg-text-2); } +.pg-field { display:block; margin-bottom:16px; } +.pg-field .fl { display:block; font:500 11px 'IBM Plex Mono'; letter-spacing:.1em; text-transform:uppercase; color:var(--pg-text-3); margin-bottom:7px; } +.pg-input { + width:100%; background:var(--pg-bg); border:1px solid var(--pg-border-2); border-radius:3px; + color:var(--pg-text); font:500 15px 'IBM Plex Mono'; letter-spacing:.08em; padding:11px 12px; + text-transform:uppercase; } -.menu-foot a { color: var(--accent); text-decoration: none; } - -/* ---- In-game chrome ---- */ -.gamebar { - display: flex; - align-items: center; - gap: 16px; - padding: 10px 16px; - border-bottom: 1px solid var(--border); - background: var(--panel); +.pg-input:focus { outline:none; border-color:var(--hue, var(--pg-accent)); } +.pg-actions { display:flex; gap:10px; } +.pg-btn { + flex:1; display:inline-flex; align-items:center; justify-content:center; gap:7px; cursor:pointer; + font:600 14px 'IBM Plex Sans'; border-radius:3px; padding:11px 14px; border:1px solid transparent; } -.back { - background: transparent; - border: 1px solid var(--border); - border-radius: 8px; - color: var(--text); - padding: 6px 12px; - cursor: pointer; - font: inherit; +.pg-btn.primary { background:var(--hue, var(--pg-accent)); color:#06080f; } +.pg-btn.primary:hover { filter:brightness(1.08); } +.pg-btn.ghost { background:transparent; border-color:var(--pg-border-2); color:var(--pg-text-2); } +.pg-btn.ghost:hover { color:var(--pg-text); border-color:var(--pg-text-3); } + +/* end-of-round scoreboard rows (inside .pg-modal) */ +.pg-scoreboard .sb-list { margin:0 0 18px; display:flex; flex-direction:column; gap:2px; } +.pg-sbrow { display:flex; align-items:center; gap:11px; padding:8px 0; border-bottom:1px solid var(--pg-border); font:500 13px 'IBM Plex Mono'; } +.pg-sbrow:last-child { border-bottom:0; } +.pg-sbrow .rk { width:20px; color:var(--pg-text-3); } +.pg-sbrow .dot { width:9px; height:9px; border-radius:50%; flex:none; } +.pg-sbrow .nm { flex:1; color:var(--pg-text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.pg-sbrow .sc { color:var(--pg-text-2); } +.pg-sbrow.me .nm { color:var(--pg-b); } +.pg-sbrow.win .rk { color:var(--pg-y); } + +/* connection-lost overlay */ +.pg-connlost { text-align:center; } +.pg-connlost .cl-ico { display:inline-flex; align-items:center; justify-content:center; width:44px; height:44px; border:1px solid var(--pg-border-2); border-radius:50%; margin:0 auto 14px; color:var(--pg-r); } +.pg-connlost .cl-spin { width:14px; height:14px; border:2px solid var(--pg-border-2); border-top-color:var(--pg-accent); border-radius:50%; display:inline-block; animation:pg-spin .8s linear infinite; vertical-align:-2px; margin-right:7px; } +@keyframes pg-spin { to { transform:rotate(360deg); } } + +/* ============================================================ + RESPONSIVE / MOBILE + ============================================================ */ +@media (max-width: 880px) { + .pg-grid { grid-template-columns:1fr; } + .pg-hero h1 { font-size:42px; } + .pg-hero p { font-size:17px; } } -.back:hover { border-color: var(--accent); } -.gametitle { color: var(--muted); font-size: 14px; } - -.stage { - position: relative; - width: 100%; - height: calc(100% - 49px); - overflow: hidden; +@media (max-width: 560px) { + .pg-wrap { padding:0 18px; } + .pg-top nav { gap:14px; } + .pg-top nav a.hide-sm { display:none; } + .pg-hero { padding:26px 0 8px; } + .pg-hero h1 { font-size:34px; } + .pg-gamebar { gap:10px; padding:10px 14px; flex-wrap:wrap; } + .pg-gamename { display:none; } + .lboard { width:150px; padding:10px; } + .hud .stat { font-size:11px; } } -.stage canvas { display: block; width: 100%; height: 100%; } diff --git a/src/shell/card-art.ts b/src/shell/card-art.ts new file mode 100644 index 0000000..222aba6 --- /dev/null +++ b/src/shell/card-art.ts @@ -0,0 +1,183 @@ +/** + * Animated point·line·plane art for the menu cards. + * + * Each demo's card shows the *shape* of multiplayer it demonstrates, keyed by + * game id: Blobs = many drifting points, Caption Clash = a rotating lobby ring + * (point·line·plane), Siege = enemies converging on a co-op cluster. Unknown + * ids fall back to a generic drifting-points field in the card's hue. + * + * Ported from the Plot platform design handoff (ui_kits/playground/index.html), + * rewritten as typed, self-cleaning modules: each renderer returns a stop() + * that cancels its animation frame so leaving the menu leaks nothing. + */ + +const SVGNS = 'http://www.w3.org/2000/svg'; + +function el( + name: K, + attrs: Record, +): SVGElementTagNameMap[K] { + const node = document.createElementNS(SVGNS, name); + for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v)); + return node; +} + +/** Tiny deterministic PRNG so the art is stable across renders. */ +function rng(seed: number): () => number { + let s = seed; + return () => { + s = (s * 9301 + 49297) % 233280; + return s / 233280; + }; +} + +const W = 360; +const H = 150; + +type Stop = () => void; + +interface Drifter { + x: number; + y: number; + vx: number; + vy: number; + rad: number; + node: SVGCircleElement; +} + +/** Drifting-points field shared by Blobs and the generic fallback. */ +function driftField(svg: SVGSVGElement, hues: string[], count: number, seed: number, opacity: number): Stop { + const r = rng(seed); + const nodes: Drifter[] = Array.from({ length: count }, (_, i) => { + const rad = 3 + r() * 9; + const hue = hues[i % hues.length] ?? hues[0] ?? '--pg-b'; + const node = el('circle', { r: rad, fill: `var(${hue})`, 'fill-opacity': opacity }); + svg.appendChild(node); + return { x: r() * W, y: r() * H, vx: (r() - 0.5) * 0.5, vy: (r() - 0.5) * 0.5, rad, node }; + }); + let raf = 0; + const step = (): void => { + for (const n of nodes) { + n.x += n.vx; + n.y += n.vy; + if (n.x < 0 || n.x > W) n.vx *= -1; + if (n.y < 0 || n.y > H) n.vy *= -1; + n.node.setAttribute('cx', n.x.toFixed(1)); + n.node.setAttribute('cy', n.y.toFixed(1)); + } + raf = requestAnimationFrame(step); + }; + step(); + return () => cancelAnimationFrame(raf); +} + +/** Caption Clash — a central point with lines to a rotating ring (lobby). */ +function captionArt(svg: SVGSVGElement): Stop { + const cx = 180; + const cy = 75; + const hues = ['--pg-m', '--pg-y', '--pg-c', '--pg-b', '--pg-g', '--pg-r']; + const spokes = Array.from({ length: 6 }, (_, i) => { + const base = (i / 6) * Math.PI * 2; + const line = el('line', { + x1: cx, + y1: cy, + x2: cx, + y2: cy, + stroke: 'var(--pg-border-2)', + 'stroke-width': 1.2, + }); + svg.appendChild(line); + const dot = el('circle', { cx, cy, r: 8, fill: `var(${hues[i] ?? '--pg-m'})` }); + svg.appendChild(dot); + return { base, line, dot }; + }); + svg.appendChild(el('circle', { cx, cy, r: 12, fill: 'var(--pg-m)' })); + let t = 0; + let raf = 0; + const step = (): void => { + t += 0.015; + for (const s of spokes) { + const a = s.base + t; + const x = cx + Math.cos(a) * 92; + const y = cy + Math.sin(a) * 52; + s.line.setAttribute('x2', x.toFixed(1)); + s.line.setAttribute('y2', y.toFixed(1)); + s.dot.setAttribute('cx', x.toFixed(1)); + s.dot.setAttribute('cy', y.toFixed(1)); + } + raf = requestAnimationFrame(step); + }; + step(); + return () => cancelAnimationFrame(raf); +} + +interface Enemy { + x: number; + y: number; + node: SVGRectElement; +} + +/** Siege — a co-op cluster center, enemies converging from the edges. */ +function siegeArt(svg: SVGSVGElement): Stop { + const cx = 180; + const cy = 75; + for (const [x, y, c] of [ + [168, 72, '--pg-g'], + [192, 70, '--pg-b'], + [180, 86, '--pg-c'], + ] as const) { + svg.appendChild(el('circle', { cx: x, cy: y, r: 6, fill: `var(${c})` })); + } + const r = rng(13); + const enemies: Enemy[] = Array.from({ length: 10 }, () => { + const edge = r(); + const x = edge < 0.5 ? r() * W : r() < 0.5 ? -10 : W + 10; + const y = edge < 0.5 ? (r() < 0.5 ? -10 : H + 10) : r() * H; + const node = el('rect', { width: 8, height: 8, fill: 'var(--pg-r)', 'fill-opacity': 0.9 }); + svg.appendChild(node); + return { x, y, node }; + }); + let raf = 0; + const step = (): void => { + for (const n of enemies) { + const dx = cx - n.x; + const dy = cy - n.y; + const d = Math.hypot(dx, dy) || 1; + if (d < 22) { + n.x = r() * W * (r() < 0.5 ? -0.1 : 1.1); + n.y = r() * H; + } else { + n.x += (dx / d) * 0.6; + n.y += (dy / d) * 0.6; + } + n.node.setAttribute('x', (n.x - 4).toFixed(1)); + n.node.setAttribute('y', (n.y - 4).toFixed(1)); + } + raf = requestAnimationFrame(step); + }; + step(); + return () => cancelAnimationFrame(raf); +} + +/** + * Build a card-art SVG for a game and start its animation. Returns the SVG + * element plus a stop() that cancels the animation frame. + */ +export function createCardArt(gameId: string, hue: string): { svg: SVGSVGElement; stop: Stop } { + const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: 'xMidYMid slice' }); + let stop: Stop; + switch (gameId) { + case 'blobs': + stop = driftField(svg, ['--pg-b', '--pg-c', '--pg-g'], 26, 7, 0.85); + break; + case 'party': + stop = captionArt(svg); + break; + case 'siege': + stop = siegeArt(svg); + break; + default: + stop = driftField(svg, [hue], 18, 21, 0.7); + } + return { svg, stop }; +} diff --git a/src/shell/names.ts b/src/shell/names.ts new file mode 100644 index 0000000..93ecb8b --- /dev/null +++ b/src/shell/names.ts @@ -0,0 +1,44 @@ +/** + * Friendly, deterministic display names for player ids. + * + * Every client derives the *same* name from the same id with no server + * round-trip, so all players agree on who is who and a name stays stable for + * the whole session. This replaces the raw id prefix that otherwise labels + * blobs and leaderboard rows (e.g. `5e78`) with something readable like + * `Brave Otter`. + */ + +const ADJECTIVES = [ + 'Brave', 'Swift', 'Sneaky', 'Mighty', 'Tiny', 'Lucky', 'Jolly', 'Wily', + 'Bold', 'Fuzzy', 'Nimble', 'Grumpy', 'Sleepy', 'Zippy', 'Cosmic', 'Funky', + 'Spicy', 'Mellow', 'Snappy', 'Plucky', 'Witty', 'Dizzy', 'Cheeky', 'Glossy', + 'Rowdy', 'Quirky', 'Breezy', 'Frosty', 'Gentle', 'Feisty', 'Dapper', 'Wobbly', +]; + +const ANIMALS = [ + 'Otter', 'Falcon', 'Yak', 'Newt', 'Moth', 'Crab', 'Lynx', 'Koala', + 'Gecko', 'Panda', 'Heron', 'Bison', 'Toad', 'Wren', 'Seal', 'Fox', + 'Mole', 'Stork', 'Vole', 'Ibex', 'Quokka', 'Tapir', 'Marmot', 'Puffin', + 'Wombat', 'Badger', 'Raven', 'Shrew', 'Llama', 'Civet', 'Dingo', 'Lemur', +]; + +/** Deterministic 32-bit FNV-1a hash of a string. */ +function hash(s: string): number { + let h = 2166136261 >>> 0; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +/** + * A stable, friendly two-word name for a player id (e.g. `Swift Lynx`). + * 32×32 = 1024 combinations — ample for the handful of players in a room. + */ +export function nameFor(id: string): string { + const h = hash(id); + const adjective = ADJECTIVES[h % ADJECTIVES.length]; + const animal = ANIMALS[Math.floor(h / ADJECTIVES.length) % ANIMALS.length]; + return `${adjective} ${animal}`; +} diff --git a/src/shell/overlays.ts b/src/shell/overlays.ts new file mode 100644 index 0000000..c230226 --- /dev/null +++ b/src/shell/overlays.ts @@ -0,0 +1,245 @@ +/** + * Playground overlays — the modal surfaces the showcase shows over the stage: + * the room-join lobby (replacing `window.prompt`), the end-of-round scoreboard, + * and the connection-lost state. + * + * Each opener builds a backdrop + `.pg-modal`, appends it to `document.body`, + * and returns a `dismiss()`. They are framework-free DOM so any game can call + * them. User-supplied strings (room codes, player names) go in via + * `textContent` — never `innerHTML` — so they can't inject markup. + */ + +/** Build an element with class + optional text, the lazy way. */ +function h( + tag: K, + className?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +/** The three-stripe color-bar kicker reused across modals. */ +function kbar(): HTMLElement { + const bar = h('span', 'pg-kbar'); + for (const hue of ['--pg-r', '--pg-g', '--pg-b']) { + const seg = document.createElement('span'); + seg.style.background = `var(${hue})`; + bar.appendChild(seg); + } + return bar; +} + +/** + * Mount a modal node inside a fresh backdrop. Returns `dismiss()`. Pressing + * Escape (or clicking the backdrop) calls `onDismiss` so callers can route it + * to cancel/back behaviour. + */ +function mountOverlay(modal: HTMLElement, onDismiss?: () => void): () => void { + const backdrop = h('div', 'pg-backdrop'); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + const dismiss = (): void => { + window.removeEventListener('keydown', onKey); + backdrop.remove(); + }; + function onKey(e: KeyboardEvent): void { + if (e.key === 'Escape') onDismiss?.(); + } + backdrop.addEventListener('mousedown', (e) => { + if (e.target === backdrop) onDismiss?.(); + }); + window.addEventListener('keydown', onKey); + return dismiss; +} + +/** A ranked row for the scoreboard. */ +export interface ScoreRow { + name: string; + score: number | string; + /** CSS custom-property name for the row dot colour (e.g. `--pg-b`). */ + hue?: string; + /** Highlight this row as the local player. */ + isYou?: boolean; +} + +/** + * The room-join lobby. Replaces `window.prompt` with a styled modal: a + * pre-filled, auto-selected room-code input that joins on Enter or the Join + * button, and cancels on Escape / backdrop / Cancel. + */ +export function openRoomJoin(opts: { + gameName: string; + hue: string; + defaultRoom: string; + onJoin: (code: string) => void; + onCancel: () => void; +}): void { + const modal = h('div', 'pg-modal'); + modal.style.setProperty('--hue', `var(${opts.hue})`); + + const kicker = h('div', 'm-kicker'); + kicker.append(kbar(), h('span', 'lbl', 'Join a room')); + modal.appendChild(kicker); + + modal.appendChild(h('h3', undefined, opts.gameName)); + modal.appendChild( + h('p', undefined, 'Share the room code so others land in the same room — open a second tab to see the points sync.'), + ); + + const field = h('label', 'pg-field'); + field.appendChild(h('span', 'fl', 'Room code')); + const input = h('input', 'pg-input'); + input.type = 'text'; + input.value = opts.defaultRoom; + input.maxLength = 12; + input.autocomplete = 'off'; + input.spellcheck = false; + field.appendChild(input); + modal.appendChild(field); + + const actions = h('div', 'pg-actions'); + const cancel = h('button', 'pg-btn ghost', 'Cancel'); + cancel.type = 'button'; + const join = h('button', 'pg-btn primary', 'Join →'); + join.type = 'button'; + actions.append(cancel, join); + modal.appendChild(actions); + + const normalize = (): string => { + const code = input.value.trim().toUpperCase(); + return code.length > 0 ? code : opts.defaultRoom; + }; + + let dismiss = (): void => {}; + const doJoin = (): void => { + const code = normalize(); + dismiss(); + opts.onJoin(code); + }; + const doCancel = (): void => { + dismiss(); + opts.onCancel(); + }; + + join.addEventListener('click', doJoin); + cancel.addEventListener('click', doCancel); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + doJoin(); + } + }); + + dismiss = mountOverlay(modal, doCancel); + // Focus + select so typing replaces the default immediately. + input.focus(); + input.select(); +} + +/** + * End-of-round / end-of-match scoreboard. `title` carries the outcome + * ("Round over", "Victory", "Base lost"). Returns a `dismiss()`. + */ +export function showScoreboard(opts: { + title: string; + subtitle?: string; + hue: string; + rows: ScoreRow[]; + onClose: () => void; + closeLabel?: string; +}): () => void { + const modal = h('div', 'pg-modal pg-scoreboard'); + modal.style.setProperty('--hue', `var(${opts.hue})`); + + const kicker = h('div', 'm-kicker'); + kicker.append(kbar(), h('span', 'lbl', 'Scoreboard')); + modal.appendChild(kicker); + + modal.appendChild(h('h3', undefined, opts.title)); + if (opts.subtitle) modal.appendChild(h('p', undefined, opts.subtitle)); + + const list = h('div', 'sb-list'); + opts.rows.forEach((row, i) => { + const r = h('div', `pg-sbrow${row.isYou ? ' me' : ''}${i === 0 ? ' win' : ''}`); + r.appendChild(h('span', 'rk', String(i + 1))); + const dot = h('span', 'dot'); + dot.style.background = `var(${row.hue ?? opts.hue})`; + r.appendChild(dot); + r.appendChild(h('span', 'nm', row.name)); + r.appendChild(h('span', 'sc', String(row.score))); + list.appendChild(r); + }); + modal.appendChild(list); + + const actions = h('div', 'pg-actions'); + const close = h('button', 'pg-btn primary', opts.closeLabel ?? 'Play again'); + close.type = 'button'; + actions.appendChild(close); + modal.appendChild(actions); + + let dismiss = (): void => {}; + const doClose = (): void => { + dismiss(); + opts.onClose(); + }; + close.addEventListener('click', doClose); + dismiss = mountOverlay(modal, doClose); + close.focus(); + return dismiss; +} + +/** + * Connection-lost overlay. The vendored client surfaces no public mid-game + * disconnect event, so this is shown when a `join()` fails (or a game wires its + * own signal). `onRetry` re-attempts; `onBack` returns to the menu. + */ +export function showConnectionLost(opts: { + hue: string; + message?: string; + reconnecting?: boolean; + onRetry?: () => void; + onBack: () => void; +}): () => void { + const modal = h('div', 'pg-modal pg-connlost'); + modal.style.setProperty('--hue', `var(${opts.hue})`); + + const ico = h('div', 'cl-ico'); + ico.innerHTML = + ''; + modal.appendChild(ico); + + modal.appendChild(h('h3', undefined, opts.reconnecting ? 'Reconnecting…' : 'Connection lost')); + modal.appendChild( + h('p', undefined, opts.message ?? 'We could not reach the room. Check your connection and try again.'), + ); + + const actions = h('div', 'pg-actions'); + const back = h('button', 'pg-btn ghost', '← All demos'); + back.type = 'button'; + const retry = h('button', 'pg-btn primary', 'Retry'); + retry.type = 'button'; + if (opts.reconnecting) { + retry.disabled = true; + retry.innerHTML = 'Reconnecting'; + } + actions.append(back, retry); + modal.appendChild(actions); + + let dismiss = (): void => {}; + back.addEventListener('click', () => { + dismiss(); + opts.onBack(); + }); + if (opts.onRetry) { + retry.addEventListener('click', () => { + dismiss(); + opts.onRetry?.(); + }); + } + dismiss = mountOverlay(modal, opts.onBack); + return dismiss; +} From dcdb5599bfc9f1cdddbf97b3b4c477c0aa210dc9 Mon Sep 17 00:00:00 2001 From: "B.B. Jansen" <56940439+bbjansen@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:01:20 +0200 Subject: [PATCH 4/4] feat(games): migrate blobs and siege to plot.play(); simplify party Blobs and siege replace manual join/attachHandler/predict/frame wiring with a single declarative plot.play() call. Party drops the manual nickname prompt in favor of deterministic generated names. Blobs now spawns players near the arena center so they see each other immediately (pellets still scatter across the full arena). --- src/games/blobs/handler.ts | 4 +- src/games/blobs/index.ts | 136 ++++++++++++++------------ src/games/blobs/logic.ts | 18 +++- src/games/party/index.ts | 46 +++------ src/games/siege/index.ts | 195 ++++++++++++++++--------------------- 5 files changed, 191 insertions(+), 208 deletions(-) diff --git a/src/games/blobs/handler.ts b/src/games/blobs/handler.ts index 745a9ac..b4cc388 100644 --- a/src/games/blobs/handler.ts +++ b/src/games/blobs/handler.ts @@ -9,8 +9,8 @@ import { defineRoom } from '@plot/handler'; import { type BlobWorld, - randomPoint, resolveEat, + spawnPoint, stepMove, } from './logic'; @@ -28,7 +28,7 @@ export const handler = defineRoom({ }, tickRate: 20, onJoin(player, ctx) { - ctx.state.positions[player.id] = randomPoint(); + ctx.state.positions[player.id] = spawnPoint(); ctx.state.mass[player.id] = START_MASS; }, onMessage(player, msg, ctx) { diff --git a/src/games/blobs/index.ts b/src/games/blobs/index.ts index d5b9c62..ea2118c 100644 --- a/src/games/blobs/index.ts +++ b/src/games/blobs/index.ts @@ -1,26 +1,32 @@ /** * Blobs — an agar.io-style .io arena, end to end on Plot. * - * `mount` opens a canvas filling the host, joins the room by code, attaches the - * authoritative handler for client-side prediction, and runs a render loop that - * draws the camera-centred world (grid, pellets, blobs) plus an HTML HUD. Local + * `mount` opens a canvas filling the host, then hands the netcode to + * `plot.play()`: one call joins the room, attaches the authoritative handler + * for client-side prediction, interpolates remote blobs, auto-reconnects, and + * runs the frame loop. We declare the local (predicted) and remote + * (interpolated) paths and draw the camera-centred world in `onFrame`; local * movement is predicted from the mouse vector and reconciled by the server. */ -import { Plot, type Room } from '@plot/client'; +import { Plot, type FrameView, type Game } from '@plot/client'; import type { GameModule, PlotConfig } from '../../plot-config'; -import { - type BlobWorld, - type Vec2, - radius, -} from './logic'; +import { type BlobWorld, type Vec2, radius } from './logic'; + +/** The only input clients send: a normalized move vector for `dt` seconds. */ +type MoveMsg = { kind: 'move'; dx: number; dy: number; dt: number }; import { handler } from './handler'; +import { nameFor } from '../../shell/names'; +import { showConnectionLost } from '../../shell/overlays'; const meta = { id: 'blobs', name: 'Blobs', usecase: '.io arena', - blurb: 'Eat pellets, dodge bigger blobs, top the leaderboard.', + blurb: + 'Hundreds of moving points in one room. Client prediction, remote interpolation, an authoritative handler, and a live leaderboard.', defaultRoom: 'BLOBS1', + hue: '--pg-b', + artLabel: 'many points · one plane', } as const; /** Deterministic, pleasant HSL colour derived from a player id. */ @@ -33,11 +39,6 @@ function colorFor(id: string): string { return `hsl(${hue} 70% 55%)`; } -/** First 4 characters of an id, for an on-blob label. */ -function shortId(id: string): string { - return id.slice(0, 4); -} - /** The "Leaderboard" heading row for the HUD board. */ function boardHeading(): HTMLElement { const el = document.createElement('div'); @@ -57,7 +58,7 @@ function boardRow(rank: number, id: string, mass: number, isYou: boolean): HTMLE const name = document.createElement('span'); name.style.color = colorFor(id); - name.textContent = `${rank}. ${shortId(id)}${isYou ? ' (you)' : ''}`; + name.textContent = `${rank}. ${nameFor(id)}${isYou ? ' (you)' : ''}`; const score = document.createElement('span'); score.style.opacity = '0.8'; @@ -169,39 +170,30 @@ function mount(host: HTMLElement, config: PlotConfig, roomCode: string): () => v canvas.addEventListener('pointermove', onPointerMove); canvas.addEventListener('pointerleave', onPointerLeave); - // --- Plot wiring ------------------------------------------------------- - const plot = new Plot({ - appKey: config.appKey, - playerId: config.playerId, - apiUrl: config.apiUrl, - }); - const localId = config.playerId; const localPath = `positions.${localId}`; - let room: Room | null = null; let tornDown = false; - let rafId = 0; - let lastFrameTs = 0; - // Latest interpolated map from frame events: resolved leaf path -> value. - let interpolated: Record = {}; - - function draw(): void { - rafId = requestAnimationFrame(draw); - if (room === null || ctx === null) return; - - const now = performance.now(); - const dtRaw = lastFrameTs === 0 ? 0 : (now - lastFrameTs) / 1000; - lastFrameTs = now; + let teardown: (() => void) | null = null; + // Dismiss handle for the connection-lost / reconnecting overlay, or null. + let dismissConn: (() => void) | null = null; + let lastTs = Date.now(); + + function draw(view: FrameView): void { + if (ctx === null) return; + + const now = Date.now(); + const dtRaw = (now - lastTs) / 1000; + lastTs = now; const dt = Math.min(0.1, dtRaw); - const state = room.currentState as BlobWorld | undefined; + const state = view.state; const masses: Record = state?.mass ?? {}; const pellets: Vec2[] = state?.pellets ?? []; // Local blob: prefer the predicted/corrected position, fall back to state. const localPos = - asVec2(room.correctedState[localPath]) ?? + asVec2(view.local[localPath]) ?? asVec2(state?.positions?.[localId]) ?? { x: 0, y: 0 }; @@ -211,7 +203,7 @@ function mount(host: HTMLElement, config: PlotConfig, roomCode: string): () => v if (len > 0.0001) { const dx = pointer.x / Math.max(len, 1); const dy = pointer.y / Math.max(len, 1); - room.sendPredicted({ kind: 'move', dx, dy, dt }); + game?.input({ kind: 'move', dx, dy, dt }); } } @@ -276,13 +268,12 @@ function mount(host: HTMLElement, config: PlotConfig, roomCode: string): () => v ctx.font = '600 12px system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(shortId(id), pos.x + ox, pos.y + oy); + ctx.fillText(nameFor(id), pos.x + ox, pos.y + oy); }; - // Remote blobs from the interpolated frame. - for (const [path, value] of Object.entries(interpolated)) { + // Remote blobs from the interpolated frame (local id already excluded). + for (const [path, value] of Object.entries(view.remote)) { const id = path.startsWith('positions.') ? path.slice('positions.'.length) : path; - if (id === localId) continue; const v = asVec2(value); if (v !== null) drawBlob(id, v); } @@ -316,43 +307,60 @@ function mount(host: HTMLElement, config: PlotConfig, roomCode: string): () => v } } - // --- Async init -------------------------------------------------------- + // --- Managed netcode --------------------------------------------------- + const plot = new Plot({ + appKey: config.appKey, + appId: config.appId, + playerId: localId, + apiUrl: config.apiUrl, + }); + + let game: Game | null = null; + void (async () => { try { - const joined = await plot.join({ mode: 'code', roomCode }); + const g = await plot.play({ + room: roomCode, + handler, + predict: [{ path: 'positions.{me}', type: 'vec2', correctionMs: 120 }], + interpolate: [{ path: 'positions.*', type: 'vec2', renderDelay: 100 }], + onFrame: draw, + // The SDK auto-reconnects with backoff; surface a non-blocking overlay. + onDisconnect: ({ willRetry }) => { + dismissConn?.(); + dismissConn = showConnectionLost({ + hue: meta.hue, + reconnecting: willRetry, + onBack: () => window.location.reload(), + onRetry: willRetry ? undefined : () => window.location.reload(), + }); + }, + onReconnect: () => { + dismissConn?.(); + dismissConn = null; + }, + }); if (tornDown) { - // Torn down before join resolved: leave immediately and bail. - joined.leave(); + g.leave(); return; } - room = joined; - room.attachHandler(handler); - room.predict({ path: localPath, type: 'vec2', correctionMs: 120 }); - room.interpolate({ path: 'positions.*', type: 'vec2', renderDelay: 100 }); - room.on('frame', ({ interpolated: frame }) => { - interpolated = frame; - }); - room.startFrameLoop(16); - rafId = requestAnimationFrame(draw); + game = g; + teardown = () => g.leave(); } catch { // Connection failed; leave the canvas in place but stop animating. - // (Nothing to tear down beyond the listeners handled below.) } })(); // --- Teardown ---------------------------------------------------------- return () => { tornDown = true; - if (rafId !== 0) cancelAnimationFrame(rafId); - rafId = 0; + dismissConn?.(); + dismissConn = null; window.removeEventListener('resize', resize); canvas.removeEventListener('pointermove', onPointerMove); canvas.removeEventListener('pointerleave', onPointerLeave); - if (room !== null) { - room.stopFrameLoop(); - room.leave(); - room = null; - } + teardown?.(); + teardown = null; }; } diff --git a/src/games/blobs/logic.ts b/src/games/blobs/logic.ts index db657db..2c49a9b 100644 --- a/src/games/blobs/logic.ts +++ b/src/games/blobs/logic.ts @@ -49,6 +49,22 @@ export function randomPoint(rand: () => number = Math.random): Vec2 { }; } +/** + * Radius around the arena centre that players spawn within. The arena is large + * enough that fully-random spawns routinely land players off each other's + * screen, so blobs joining the same room would never see one another. Spawning + * near the centre keeps everyone in the same room in sight from the first frame. + */ +export const SPAWN_RADIUS = 220; + +/** A spawn point near the arena centre, so players in a room meet on join. */ +export function spawnPoint(rand: () => number = Math.random): Vec2 { + return { + x: (rand() * 2 - 1) * SPAWN_RADIUS, + y: (rand() * 2 - 1) * SPAWN_RADIUS, + }; +} + /** Clamp a value into the inclusive range [min, max]. */ function clamp(value: number, min: number, max: number): number { if (value < min) return min; @@ -140,7 +156,7 @@ export function resolveEat( if (massA > massB * 1.25 && dist(posA, posB) < radius(massA)) { w.mass[a] = massA + massB; w.mass[b] = START_MASS; - w.positions[b] = randomPoint(rand); + w.positions[b] = spawnPoint(rand); } } } diff --git a/src/games/party/index.ts b/src/games/party/index.ts index 3f40bf3..c815e94 100644 --- a/src/games/party/index.ts +++ b/src/games/party/index.ts @@ -13,6 +13,7 @@ import './party.css'; import { Plot, type Room } from '@plot/client'; import type { GameModule, PlotConfig } from '../../plot-config'; +import { nameFor } from '../../shell/names'; import { TICKS_PER_SECOND, type Mode, @@ -24,9 +25,12 @@ const game: GameModule = { meta: { id: 'party', name: 'Caption Clash', - usecase: 'Party + Turn-based', - blurb: 'A join-by-code lobby with two round modes: simultaneous caption battles and a take-turns word relay.', + usecase: 'Party + turn-based', + blurb: + 'One lobby engine, two modes — a simultaneous caption battle and a take-turns word relay. Room codes, presence, shared scoreboard.', defaultRoom: 'PARTY1', + hue: '--pg-m', + artLabel: 'lobby · presence ring', }, mount(host, config, roomCode) { return mountParty(host, config, roomCode); @@ -55,10 +59,8 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () host.appendChild(root); // Local UI drafts (preserved across re-renders). - let nameDraft = ''; let captionDraft = ''; let pickedMode: Mode = 'party'; - let sentName = false; let room: Room | null = null; let disposed = false; @@ -111,27 +113,7 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () hint.textContent = 'Share this code — anyone who joins it lands in this room.'; c.append(code, hint); - // Nickname row. - const row = document.createElement('div'); - row.className = 'party-row'; - const input = document.createElement('input'); - input.className = 'party-input'; - input.placeholder = 'Your nickname'; - input.value = nameDraft; - input.maxLength = 24; - input.dataset.keep = 'name'; - input.addEventListener('input', () => { nameDraft = input.value; }); - const setBtn = button(sentName ? 'Update name' : 'Set name', 'ghost'); - setBtn.addEventListener('click', () => { - const name = nameDraft.trim(); - if (name.length === 0) return; - send({ kind: 'setName', name }); - sentName = true; - setBtn.textContent = 'Update name'; - }); - row.append(input, setBtn); - c.append(row); - + // Names are auto-generated per player id (no nickname prompt) — see nameFor. c.append(renderPlayers(s)); if (isVip(s)) { @@ -162,7 +144,7 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () const list = document.createElement('div'); list.className = 'party-list'; const vipId = Object.keys(s.players).filter((id) => s.players[id]?.present).sort()[0]; - for (const { id, name, score, present } of ranking(s.players)) { + for (const { id, score, present } of ranking(s.players)) { const row = document.createElement('div'); row.className = 'party-player'; if (id === me) row.classList.add('me'); @@ -171,7 +153,7 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () dot.className = 'party-dot'; const nameEl = document.createElement('span'); nameEl.className = 'party-name'; - nameEl.textContent = name + (id === me ? ' (you)' : ''); + nameEl.textContent = nameFor(id) + (id === me ? ' (you)' : ''); const left = document.createElement('div'); left.className = 'party-row'; left.append(dot, nameEl); @@ -290,7 +272,7 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () const turn = document.createElement('div'); turn.className = 'party-turn'; - const who = activeId ? (s.players[activeId]?.name ?? 'someone') : 'someone'; + const who = activeId ? nameFor(activeId) : 'someone'; if (myTurn) { turn.textContent = 'Your turn — add a letter (4+ letters scores).'; } else { @@ -334,7 +316,7 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () const crown = document.createElement('span'); crown.className = 'crown'; crown.textContent = '👑 '; - banner.append(crown, document.createTextNode(`${winner?.name ?? 'Nobody'} wins!`)); + banner.append(crown, document.createTextNode(`${winner ? nameFor(winner.id) : 'Nobody'} wins!`)); c.append(banner); } else { const h = document.createElement('div'); @@ -355,7 +337,7 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () rank.textContent = `#${i + 1}`; const name = document.createElement('span'); name.className = 'party-name'; - name.textContent = p.name + (p.id === me ? ' (you)' : ''); + name.textContent = nameFor(p.id) + (p.id === me ? ' (you)' : ''); const sc = document.createElement('span'); sc.className = 'party-score'; sc.textContent = String(p.score); @@ -381,7 +363,7 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () if (!s) return { sig: 'connecting', el: renderConnecting() }; switch (s.phase) { case 'lobby': - return { sig: `lobby|${Object.keys(s.players).length}|${isVip(s)}|${pickedMode}|${sentName}`, el: renderLobby(s) }; + return { sig: `lobby|${Object.keys(s.players).length}|${isVip(s)}|${pickedMode}`, el: renderLobby(s) }; case 'play': if (s.mode === 'turnbased') { return { sig: `tb|${s.round}|${s.activeIdx}|${(s.word ?? '').length}`, el: renderTurnbased(s) }; @@ -440,7 +422,7 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () // ---- Connect -------------------------------------------------------- void (async () => { - const plot = new Plot({ appKey: config.appKey, playerId: config.playerId, apiUrl: config.apiUrl }); + const plot = new Plot({ appKey: config.appKey, appId: config.appId, playerId: config.playerId, apiUrl: config.apiUrl }); const joined = await plot.join({ mode: 'code', roomCode }); if (disposed) { joined.leave(); diff --git a/src/games/siege/index.ts b/src/games/siege/index.ts index ec547d9..26e03cb 100644 --- a/src/games/siege/index.ts +++ b/src/games/siege/index.ts @@ -5,8 +5,13 @@ * and defends a shared base. The client only predicts its own movement and * interpolates everyone else's positions; the rest of the world is read * straight from the authoritative snapshot. + * + * Netcode is managed by `plot.play()` — one call wires prediction (the local + * player), interpolation (other players + enemies), reconnection, and the frame + * loop. We only declare paths and draw in `onFrame`; `game.room` is the escape + * hatch we use for the `gameover` broadcast. */ -import { Plot, type Room } from '@plot/client'; +import { Plot, type FrameView } from '@plot/client'; import type { GameModule, PlotConfig } from '../../plot-config'; import { ARENA, BASE_HIT_RADIUS, PLAYER_FIRE_RANGE, dist, type Vec2 } from './logic'; import handler from './handler'; @@ -15,13 +20,8 @@ import type { State } from './handler'; /** Visual half-extent the world is drawn into (logical arena maps to this). */ const VIEW = ARENA; -/** Read a vec2 from an interpolated frame map, falling back to a snapshot. */ -function readVec2( - interp: Record | undefined, - path: string, - fallback: Vec2 | undefined, -): Vec2 | undefined { - const v = interp?.[path]; +/** Coerce an interpolated/corrected leaf to a Vec2, falling back to a snapshot. */ +function asVec2(v: unknown, fallback: Vec2 | undefined): Vec2 | undefined { if (v !== undefined && v !== null && typeof v === 'object') { const o = v as { x?: unknown; y?: unknown }; if (typeof o.x === 'number' && typeof o.y === 'number') return { x: o.x, y: o.y }; @@ -34,8 +34,11 @@ const mod: GameModule = { id: 'siege', name: 'Siege', usecase: 'Co-op survival', - blurb: 'Hold the base together against escalating server-driven hordes.', + blurb: + '2–8 players versus the game. The server owns wave spawning, enemy pathing, and damage — the textbook case for authoritative netcode.', defaultRoom: 'SIEGE1', + hue: '--pg-r', + artLabel: 'co-op vs. the server', }, mount(host: HTMLElement, config: PlotConfig, roomCode: string): () => void { @@ -61,6 +64,7 @@ const mod: GameModule = { resize(); const ro = new ResizeObserver(resize); ro.observe(host); + if (ctx !== null) drawCenteredText(ctx, 'Connecting…', '#9aa7b8', 20, 0, cssW, cssH); // --- Input --------------------------------------------------------------- const keys = new Set(); @@ -77,40 +81,53 @@ const mod: GameModule = { window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp); - // --- Async connection ---------------------------------------------------- - let room: Room | null = null; - let rafId = 0; + // --- Connection + managed netcode --------------------------------------- let disposed = false; - let lastMove = 0; + let teardown: (() => void) | null = null; let gameover: { phase: 'won' | 'lost'; score: number } | null = null; - /** Latest interpolated leaf-path → value map from the frame loop. */ - let frame: Record = {}; - + let lastMove = Date.now(); const playerId = config.playerId; - const start = async (): Promise => { + void (async () => { const plot = new Plot({ appKey: config.appKey, + appId: config.appId, playerId, apiUrl: config.apiUrl, }); - const r = await plot.join({ mode: 'code', roomCode }); - if (disposed) { - r.leave(); - return; - } - room = r; - r.attachHandler(handler); - r.predict({ path: `players.${playerId}.pos`, type: 'vec2', correctionMs: 120 }); - r.interpolate({ path: 'players.*.pos', type: 'vec2', renderDelay: 100 }); - r.interpolate({ path: 'enemies.*.pos', type: 'vec2', renderDelay: 100 }); - - r.on('frame', ({ interpolated }) => { - frame = interpolated; + const game = await plot.play({ + room: roomCode, + handler, + predict: [{ path: 'players.{me}.pos', type: 'vec2', correctionMs: 120 }], + interpolate: [ + { path: 'players.*.pos', type: 'vec2', renderDelay: 100 }, + { path: 'enemies.*.pos', type: 'vec2', renderDelay: 100 }, + ], + onFrame: (view) => { + // Sample WASD → movement intent and send it predicted. + const now = Date.now(); + const dt = Math.min(0.05, (now - lastMove) / 1000); + lastMove = now; + let dx = 0; + let dy = 0; + if (keys.has('a')) dx -= 1; + if (keys.has('d')) dx += 1; + if (keys.has('w')) dy -= 1; + if (keys.has('s')) dy += 1; + if ((dx !== 0 || dy !== 0) && view.state?.phase === 'playing') { + game.input({ kind: 'move', dx, dy, dt }); + } + if (ctx !== null) draw(ctx, view, now); + }, }); - r.on('message', ({ data }) => { + if (disposed) { + game.leave(); + return; + } + // The server broadcasts the end state; read it via the escape hatch. + game.room.on('message', ({ data }) => { const d = data as { kind?: unknown; phase?: unknown; score?: unknown }; if ( d.kind === 'gameover' && @@ -120,45 +137,17 @@ const mod: GameModule = { gameover = { phase: d.phase, score: d.score }; } }); + teardown = () => game.leave(); + })(); - r.startFrameLoop(16); - lastMove = performance.now(); - loop(); - }; - - // --- Render loop --------------------------------------------------------- - const loop = (): void => { - rafId = requestAnimationFrame(loop); - const r = room; - if (r === null || ctx === null) return; - - const now = performance.now(); - - // Sample WASD → movement intent, send predicted to the server. - let dx = 0; - let dy = 0; - if (keys.has('a')) dx -= 1; - if (keys.has('d')) dx += 1; - if (keys.has('w')) dy -= 1; - if (keys.has('s')) dy += 1; - const dt = Math.min(0.05, (now - lastMove) / 1000); - lastMove = now; - const state = r.currentState as State | undefined; - if ((dx !== 0 || dy !== 0) && state !== undefined && state.phase === 'playing') { - r.predict({ path: `players.${playerId}.pos`, type: 'vec2', correctionMs: 120 }); - r.sendPredicted({ kind: 'move', dx, dy, dt }); - } - - draw(ctx, r, now); - }; - - const draw = (g: CanvasRenderingContext2D, r: Room, now: number): void => { - const state = r.currentState as State | undefined; + // --- Render -------------------------------------------------------------- + const draw = (g: CanvasRenderingContext2D, view: FrameView, now: number): void => { + const state = view.state; g.clearRect(0, 0, cssW, cssH); g.fillStyle = '#0b0f17'; g.fillRect(0, 0, cssW, cssH); - if (state === undefined) { - drawCenteredText(g, 'Connecting…', '#9aa7b8', 20); + if (state === undefined || state === null) { + drawCenteredText(g, 'Connecting…', '#9aa7b8', 20, 0, cssW, cssH); return; } @@ -176,9 +165,6 @@ const mod: GameModule = { g.lineWidth = 2; g.strokeRect(sx(-VIEW), sy(-VIEW), VIEW * 2 * scale, VIEW * 2 * scale); - // Interpolated maps are keyed by resolved leaf path (from the frame event). - const interpAll: Record = frame; - // --- Base --------------------------------------------------------------- const baseR = (BASE_HIT_RADIUS * 0.55 + (state.baseHp / 100) * baseHpRadius()) * scale; g.beginPath(); @@ -189,12 +175,12 @@ const mod: GameModule = { g.strokeStyle = '#93c5fd'; g.stroke(); - // --- Enemies ------------------------------------------------------------ + // --- Enemies (interpolated) -------------------------------------------- g.lineWidth = 2; for (const id in state.enemies) { const e = state.enemies[id]; if (e === undefined) continue; - const pos = readVec2(interpAll, `enemies.${id}.pos`, e.pos); + const pos = asVec2(view.remote[`enemies.${id}.pos`], e.pos); if (pos === undefined) continue; const rad = Math.max(4, (6 + e.hp * 0.18) * scale); g.beginPath(); @@ -209,13 +195,11 @@ const mod: GameModule = { for (const pid in state.players) { const pl = state.players[pid]; if (pl === undefined) continue; - let pos: Vec2 | undefined; - if (pid === playerId) { - const corrected = r.correctedState[`players.${pid}.pos`] as Vec2 | undefined; - pos = readVec2(undefined, '', corrected ?? pl.pos); - } else { - pos = readVec2(interpAll, `players.${pid}.pos`, pl.pos); - } + // Local player is predicted+corrected; remotes are interpolated. + const pos = + pid === playerId + ? asVec2(view.local[`players.${pid}.pos`], pl.pos) + : asVec2(view.remote[`players.${pid}.pos`], pl.pos); if (pos === undefined) continue; // Fire line toward the nearest in-range enemy (visual only). @@ -223,7 +207,7 @@ const mod: GameModule = { if (targetId !== null) { const e = state.enemies[targetId]; if (e !== undefined) { - const epos = readVec2(interpAll, `enemies.${targetId}.pos`, e.pos); + const epos = asVec2(view.remote[`enemies.${targetId}.pos`], e.pos); if (epos !== undefined) { g.beginPath(); g.moveTo(sx(pos.x), sy(pos.y)); @@ -274,11 +258,7 @@ const mod: GameModule = { g.fillText(`Base ${Math.max(0, Math.round(state.baseHp))}`, bx + barW + 8, by - 1); }; - const drawOverlay = ( - g: CanvasRenderingContext2D, - state: State, - now: number, - ): void => { + const drawOverlay = (g: CanvasRenderingContext2D, state: State, now: number): void => { const phase = gameover?.phase ?? (state.phase !== 'playing' ? state.phase : null); if (phase === null) return; g.fillStyle = 'rgba(2, 6, 12, 0.72)'; @@ -286,47 +266,44 @@ const mod: GameModule = { const pulse = 0.6 + 0.4 * Math.sin(now / 300); if (phase === 'won') { g.fillStyle = `rgba(34, 197, 94, ${pulse})`; - drawCenteredText(g, 'VICTORY — base held', '#bbf7d0', 28, -14); + drawCenteredText(g, 'VICTORY — base held', '#bbf7d0', 28, -14, cssW, cssH); } else { g.fillStyle = `rgba(239, 68, 68, ${pulse})`; - drawCenteredText(g, 'DEFEAT — base destroyed', '#fecaca', 28, -14); + drawCenteredText(g, 'DEFEAT — base destroyed', '#fecaca', 28, -14, cssW, cssH); } - drawCenteredText(g, `Final score ${gameover?.score ?? state.score}`, '#e5e7eb', 18, 22); + drawCenteredText(g, `Final score ${gameover?.score ?? state.score}`, '#e5e7eb', 18, 22, cssW, cssH); }; - const drawCenteredText = ( - g: CanvasRenderingContext2D, - text: string, - color: string, - px: number, - dy = 0, - ): void => { - g.font = `${px}px system-ui, sans-serif`; - g.textAlign = 'center'; - g.textBaseline = 'middle'; - g.fillStyle = color; - g.fillText(text, cssW / 2, cssH / 2 + dy); - }; - - void start(); - // --- Teardown ------------------------------------------------------------ return () => { disposed = true; - if (rafId !== 0) cancelAnimationFrame(rafId); window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); ro.disconnect(); - if (room !== null) { - room.stopFrameLoop(); - room.leave(); - room = null; - } + teardown?.(); + teardown = null; if (canvas.parentNode === host) host.removeChild(canvas); }; }, }; +/** Draw centered text into a canvas. */ +function drawCenteredText( + g: CanvasRenderingContext2D, + text: string, + color: string, + px: number, + dy: number, + cssW: number, + cssH: number, +): void { + g.font = `${px}px system-ui, sans-serif`; + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.fillStyle = color; + g.fillText(text, cssW / 2, cssH / 2 + dy); +} + /** Logical radius the base grows by at full HP (purely cosmetic). */ function baseHpRadius(): number { return BASE_HIT_RADIUS * 0.9;