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
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/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;
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;
+}
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 {