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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@ node_modules
dist
*.local
.DS_Store

# Local deploy env (CI uses repo secrets)
.env.production
8 changes: 7 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Plot multiplayer demos</title>
<title>Plot Playground — playable multiplayer demos</title>
<meta
name="description"
content="Playable multiplayer demos built on Plot: an .io arena, a party/turn-based lobby, and a co-op survival game."
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>
Expand Down
4 changes: 2 additions & 2 deletions src/games/blobs/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
import { defineRoom } from '@plot/handler';
import {
type BlobWorld,
randomPoint,
resolveEat,
spawnPoint,
stepMove,
} from './logic';

Expand All @@ -28,7 +28,7 @@ export const handler = defineRoom<BlobWorld, BlobMsg>({
},
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) {
Expand Down
136 changes: 72 additions & 64 deletions src/games/blobs/index.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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');
Expand All @@ -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';
Expand Down Expand Up @@ -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<string, unknown> = {};

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<BlobWorld>): 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<string, number> = 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 };

Expand All @@ -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 });
}
}

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<BlobWorld, MoveMsg> | null = null;

void (async () => {
try {
const joined = await plot.join({ mode: 'code', roomCode });
const g = await plot.play<BlobWorld, MoveMsg>({
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;
};
}

Expand Down
18 changes: 17 additions & 1 deletion src/games/blobs/logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
}
Expand Down
Loading
Loading