SOURCE / PINNED RELEASE
Made of little things.
Browser Side Quest
- Release
- b9d42f5768e4…
- Author-recorded commit
- c267690145fa…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/browser-side-quest
Archive hash verified: 6d4b90b8a5f328f7…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/**
* Multiplayer over NAP-WEBRTC: "host-run shards".
*
* NAP-WEBRTC gives a room a reliable, ordered JSON broadcast between at most
* 8 remote peers, with no server. Each room ("bq-world-N") is one BrowserQuest
* world: one peer — the host — runs the ported worldserver (src/sim) and every
* other peer is a client speaking the original message protocol to it, wrapped
* in a small envelope. When the host leaves, the remaining peers elect a new
* host, which restores the world from the last broadcast snapshot, and
* everyone re-enters in place. A full world redirects newcomers to the next one.
*
* Transport keys are tab-scoped and unknown to the peer itself, so peers
* identify each other with a random per-tab node id carried in every message;
* the lowest node id among peers wins an election.
*
* Trust: like the original game, the host trusts clients' HIT/HURT reports and
* clients trust the host. This is a cooperative world, not a secured one.
*/
import { webrtc, type Subscription, type WebrtcEvent } from '@napplet/sdk';
import { serverMapData } from '../client/assets.js';
import type { WorldProvider } from '../client/app.js';
import type { Message } from '../sim/entities.js';
import type { Player as SessionPlayer } from '../sim/player.js';
import { isSnapshot, restoreSnapshot, takeSnapshot, type WorldSnapshot } from '../sim/snapshot.js';
import { World } from '../sim/world.js';
import { LocalTransport } from './local.js';
import type { Transport, TransportHandlers } from './transport.js';
const PROTOCOL = 'browserquest/1';
const CAPACITY = 8; // players per world: the host plus 7 guests (NAP-WEBRTC: 8 remote peers per session)
const ELECTION_MS = 1500;
const BEACON_MS = 2000;
const HOST_TIMEOUT_MS = 7000;
const FLUSH_MS = 50; // 20 batches/s each way, well under 120 sends/s
const SNAPSHOT_MS = 5000;
const MAX_PAYLOAD = 14 * 1024; // NAP-WEBRTC allows 16 KiB per message
const MAX_WORLD = 32;
type Envelope =
| { v: 1; t: 'hi'; n: string }
| { v: 1; t: 'host'; n: string; pop: number; cap: number }
| { v: 1; t: 'join'; n: string; h: string; r?: Resume }
| { v: 1; t: 'ready'; n: string; to: string }
| { v: 1; t: 'full'; n: string; to: string }
| { v: 1; t: 'close'; n: string; to: string; reason: string }
| { v: 1; t: 'c'; n: string; h: string; m: unknown[][] }
| { v: 1; t: 's'; n: string; b: Record<string, Message[]> }
| { v: 1; t: 'snap'; n: string; s: WorldSnapshot }
| { v: 1; t: 'bye'; n: string };
export interface Resume {
x: number;
y: number;
checkpoint?: number;
}
const randomNode = (): string => {
const bytes = new Uint8Array(8);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
};
function isEnvelope(value: unknown): value is Envelope {
const e = value as Envelope;
return !!e && typeof e === 'object' && e.v === 1 && typeof e.t === 'string' && typeof e.n === 'string' && e.n.length <= 32;
}
const size = (value: unknown): number => JSON.stringify(value).length;
/** Guest side: the original protocol over the room, addressed to the host. */
class RemoteTransport implements Transport {
handlers: TransportHandlers | null = null;
private queue: unknown[][] = [];
private flushTimer = 0;
closed = false;
constructor(
private readonly net: ShardNetwork,
readonly hostNode: string,
private readonly resume?: Resume,
) {}
connect(handlers: TransportHandlers): void {
this.handlers = handlers;
this.net.post({ v: 1, t: 'join', n: this.net.node, h: this.hostNode, r: this.resume });
}
send(message: unknown[]): void {
if (this.closed) return;
this.queue.push(message);
if (!this.flushTimer) this.flushTimer = window.setTimeout(() => this.flush(), FLUSH_MS);
}
private flush(): void {
this.flushTimer = 0;
while (this.queue.length) {
const batch: unknown[][] = [];
while (this.queue.length && size(batch) + size(this.queue[0]) < MAX_PAYLOAD) batch.push(this.queue.shift()!);
if (!batch.length) batch.push(this.queue.shift()!); // oversize single message: let the host reject it
this.net.post({ v: 1, t: 'c', n: this.net.node, h: this.hostNode, m: batch });
}
}
close(): void {
this.end(null);
}
/** Called by the network; `reason` null means closed locally. */
end(reason: string | null): void {
if (this.closed) return;
this.closed = true;
window.clearTimeout(this.flushTimer);
const handlers = this.handlers;
this.handlers = null;
if (reason !== null) handlers?.onClose(reason);
}
}
/** Host side: one connected guest. */
interface GuestLink {
node: string;
session: SessionPlayer;
outbox: Message[];
}
export class ShardNetwork implements WorldProvider {
readonly node = randomNode();
private worldIndex = 1;
private sessionId: string | null = null;
private eventSub: Subscription | null = null;
/** transport key → node id */
private readonly peers = new Map<string, string>();
private readonly nodes = new Set<string>();
private hostNode: string | null = null;
private hostPop = 0;
private lastBeacon = 0;
private world: World | null = null;
private readonly guests = new Map<string, GuestLink>();
private remote: RemoteTransport | null = null;
private lastSnapshot: WorldSnapshot | null = null;
private timers: number[] = [];
private flushTimer = 0;
private sendQueue: Promise<void> = Promise.resolve();
private rejoinCallback: ((reason: string) => void) | null = null;
private statusCallback: ((status: string) => void) | null = null;
private fullWorlds = new Set<number>();
private electing: Promise<void> | null = null;
private readonly lastPlayerPosition = { current: null as Resume | null };
private fallback: string | null = null;
/** Last known local player position, used to re-enter in place after a migration. */
setResumePoint(resume: Resume | null): void {
this.lastPlayerPosition.current = resume;
}
label(): string {
if (this.fallback) return this.fallback;
const role = this.world ? 'host' : 'guest';
return `World ${this.worldIndex} · ${role}`;
}
onRejoinRequired(callback: (reason: string) => void): void {
this.rejoinCallback = callback;
}
onStatus(callback: (status: string) => void): void {
this.statusCallback = callback;
}
switchWorld(delta: number): boolean {
const next = Math.max(1, Math.min(MAX_WORLD, this.worldIndex + delta));
if (next === this.worldIndex) return false;
this.worldIndex = next;
this.fullWorlds.clear();
void this.leaveRoom('switch');
return true;
}
/** Join the current world: open the room if needed, elect, then hand back a transport. */
async join(): Promise<Transport> {
const resume = this.lastPlayerPosition.current ?? undefined;
this.endRemote();
if (!this.sessionId) {
try {
await this.openRoom();
} catch (error) {
// Consent blocked or no peer network: keep the game playable.
this.fallback = `Solo world (peer network unavailable: ${error instanceof Error ? error.message : String(error)})`;
return this.becomeHost(null, resume);
}
}
this.fallback = null;
await this.elect();
if (this.hostNode === this.node) return this.becomeHost(this.lastSnapshot, resume);
if (this.hostPop >= CAPACITY && this.worldIndex < MAX_WORLD) {
// Full: try the next world.
this.fullWorlds.add(this.worldIndex);
this.worldIndex += 1;
await this.leaveRoom('full');
return this.join();
}
this.remote = new RemoteTransport(this, this.hostNode!, resume);
return this.remote;
}
// ---------- Room ----------
private async openRoom(): Promise<void> {
this.status(`Joining world ${this.worldIndex}…`);
this.eventSub ??= webrtc.onEvent((event) => this.onEvent(event));
const { session } = await webrtc.open({
scope: { type: 'room', room: `bq-world-${this.worldIndex}` },
protocol: PROTOCOL,
});
this.sessionId = session.id;
this.peers.clear();
this.nodes.clear();
this.hostNode = null;
this.lastBeacon = 0;
this.timers.push(window.setInterval(() => this.tick(), 500));
}
private async leaveRoom(reason: string): Promise<void> {
const id = this.sessionId;
if (!id) return;
this.post({ v: 1, t: 'bye', n: this.node });
await this.drain();
this.stopHosting();
this.endRemote();
for (const t of this.timers) window.clearInterval(t);
this.timers = [];
this.sessionId = null;
this.peers.clear();
this.nodes.clear();
this.hostNode = null;
this.lastSnapshot = null;
try {
await webrtc.close(id, reason);
} catch {
// Already closed by the host.
}
}
private status(text: string): void {
this.statusCallback?.(text);
}
/** Wait briefly for a host beacon; otherwise the lowest node id present hosts. */
private elect(): Promise<void> {
if (this.electing) return this.electing;
this.electing = (async () => {
this.post({ v: 1, t: 'hi', n: this.node });
const deadline = performance.now() + ELECTION_MS;
while (performance.now() < deadline && !this.hostAlive()) {
await new Promise((r) => window.setTimeout(r, 100));
}
if (!this.hostAlive()) {
const candidates = [this.node, ...this.nodes].sort();
this.hostNode = candidates[0];
if (this.hostNode !== this.node) {
// Give the elected peer a moment to start beaconing.
const until = performance.now() + ELECTION_MS;
while (performance.now() < until && !this.hostAlive()) {
await new Promise((r) => window.setTimeout(r, 100));
}
if (!this.hostAlive()) this.hostNode = this.node;
}
}
})().finally(() => {
this.electing = null;
});
return this.electing;
}
private hostAlive(): boolean {
return !!this.hostNode && (this.hostNode === this.node ? !!this.world : performance.now() - this.lastBeacon < HOST_TIMEOUT_MS);
}
// ---------- Host ----------
private becomeHost(snapshot: WorldSnapshot | null, resume?: Resume): Transport {
if (!this.world) {
const world = new World(serverMapData);
world.run();
if (snapshot) restoreSnapshot(world, snapshot);
world.onPopulationChange = () => world.updatePopulation();
this.world = world;
this.hostNode = this.node;
if (this.sessionId) {
this.beacon();
this.timers.push(window.setInterval(() => this.beacon(), BEACON_MS));
this.timers.push(
window.setInterval(() => {
if (this.world) this.post({ v: 1, t: 'snap', n: this.node, s: takeSnapshot(this.world) });
}, SNAPSHOT_MS),
);
}
}
this.status(this.label());
return new LocalTransport(this.world, resume);
}
private beacon(): void {
if (!this.world) return;
this.post({ v: 1, t: 'host', n: this.node, pop: this.world.playerCount, cap: CAPACITY });
}
private stopHosting(): void {
for (const guest of this.guests.values()) guest.session.disconnect();
this.guests.clear();
this.world?.dispose();
this.world = null;
}
private acceptGuest(node: string, resume?: Resume): void {
const world = this.world;
if (!world) return;
const existing = this.guests.get(node);
if (existing) {
existing.session.disconnect();
this.guests.delete(node);
}
if (world.playerCount >= CAPACITY) {
this.post({ v: 1, t: 'full', n: this.node, to: node });
return;
}
const link: GuestLink = {
node,
outbox: [],
session: world.connect(
{
id: world.nextConnectionId(),
send: (messages) => {
link.outbox.push(...messages);
this.scheduleFlush();
},
close: (reason) => {
this.guests.delete(node);
link.session.disconnect();
this.post({ v: 1, t: 'close', n: this.node, to: node, reason });
},
},
resume && Number.isFinite(resume.x) && Number.isFinite(resume.y) ? resume : undefined,
),
};
this.guests.set(node, link);
this.post({ v: 1, t: 'ready', n: this.node, to: node });
}
private dropGuest(node: string): void {
const guest = this.guests.get(node);
if (guest) {
this.guests.delete(node);
guest.session.disconnect();
}
}
private scheduleFlush(): void {
if (!this.flushTimer) this.flushTimer = window.setTimeout(() => this.flushGuests(), FLUSH_MS);
}
/** Pack every guest's queued messages into as few ≤14 KiB broadcasts as possible. */
private flushGuests(): void {
this.flushTimer = 0;
let batch: Record<string, Message[]> = {};
let used = 64;
const emit = (): void => {
if (Object.keys(batch).length) this.post({ v: 1, t: 's', n: this.node, b: batch });
batch = {};
used = 64;
};
for (const guest of this.guests.values()) {
for (const message of guest.outbox) {
const cost = size(message) + 1;
if (used + cost + guest.node.length + 8 > MAX_PAYLOAD) emit();
(batch[guest.node] ??= []).push(message);
used += cost + (batch[guest.node].length === 1 ? guest.node.length + 8 : 0);
}
guest.outbox = [];
}
emit();
}
// ---------- Events ----------
private onEvent(event: WebrtcEvent): void {
if (event.sessionId !== this.sessionId) return;
switch (event.type) {
case 'peer':
if (event.state === 'joined') {
this.post({ v: 1, t: 'hi', n: this.node });
if (this.world) this.beacon();
} else {
this.peerLeft(event.pubkey);
}
break;
case 'message':
if (isEnvelope(event.payload)) this.onEnvelope(event.from, event.payload);
break;
case 'closed':
this.sessionId = null;
this.hostLost('The peer network closed');
break;
case 'state':
if (event.state === 'closed' && this.sessionId === event.sessionId) {
this.sessionId = null;
this.hostLost('The peer network closed');
}
break;
}
}
private peerLeft(key: string): void {
const node = this.peers.get(key);
this.peers.delete(key);
if (!node) return;
this.nodes.delete(node);
if (this.world) this.dropGuest(node);
if (node === this.hostNode && !this.world) this.hostLost('The world host left');
}
private onEnvelope(from: string, env: Envelope): void {
if (env.n === this.node) return;
this.peers.set(from, env.n);
this.nodes.add(env.n);
switch (env.t) {
case 'hi':
if (this.world) this.beacon();
break;
case 'host':
this.onBeacon(env.n, env.pop);
break;
case 'join':
if (this.world && env.h === this.node) this.acceptGuest(env.n, env.r);
break;
case 'c':
if (this.world && env.h === this.node && Array.isArray(env.m)) {
const guest = this.guests.get(env.n);
if (guest) for (const message of env.m.slice(0, 64)) guest.session.receive(message);
}
break;
case 'ready':
if (env.to === this.node && this.remote && env.n === this.remote.hostNode) {
const handlers = this.remote.handlers;
queueMicrotask(() => handlers?.onReady());
}
break;
case 's':
if (this.remote && env.n === this.remote.hostNode && env.b && typeof env.b === 'object') {
const mine = env.b[this.node];
if (Array.isArray(mine) && mine.length) this.remote.handlers?.onMessage(mine);
}
break;
case 'full':
if (env.to === this.node && this.remote && env.n === this.remote.hostNode) {
this.hostPop = CAPACITY;
this.endRemote();
this.fullWorlds.add(this.worldIndex);
this.worldIndex = Math.min(MAX_WORLD, this.worldIndex + 1);
void this.leaveRoom('full').then(() => this.rejoinCallback?.('full'));
}
break;
case 'close':
if (env.to === this.node && this.remote && env.n === this.remote.hostNode) {
this.remote.end(env.reason || 'closed');
this.remote = null;
}
break;
case 'snap':
if (env.n === this.hostNode && isSnapshot(env.s)) this.lastSnapshot = env.s;
break;
case 'bye':
this.nodes.delete(env.n);
for (const [key, node] of this.peers) if (node === env.n) this.peers.delete(key);
if (this.world) this.dropGuest(env.n);
if (env.n === this.hostNode && !this.world) this.hostLost('The world host left');
break;
}
}
private onBeacon(node: string, pop: number): void {
if (this.world && node !== this.node) {
// Two hosts (election race or merge): the lower node id keeps the world.
if (node < this.node) {
this.hostNode = node;
this.lastBeacon = performance.now();
this.hostPop = pop;
this.stopHosting();
this.rejoinCallback?.('host-merge');
}
return;
}
if (!this.hostNode || this.hostNode === node || node < this.hostNode || !this.hostAlive()) {
const changed = this.hostNode !== node && !!this.remote;
this.hostNode = node;
this.lastBeacon = performance.now();
this.hostPop = pop;
if (changed) this.hostLost('A new host took over');
}
}
private tick(): void {
if (!this.world && this.remote && !this.hostAlive()) this.hostLost('The world host stopped responding');
}
private hostLost(reason: string): void {
if (!this.remote) return;
this.endRemote();
if (this.hostNode && !this.world && performance.now() - this.lastBeacon >= HOST_TIMEOUT_MS) this.hostNode = null;
if (reason === 'The world host left') this.hostNode = null;
this.rejoinCallback?.(reason);
}
private endRemote(): void {
this.remote?.end(null);
this.remote = null;
}
// ---------- Sending ----------
/** Broadcast to the room. Sends are serialized; failures are dropped (the protocol recovers). */
post(envelope: Envelope): void {
const id = this.sessionId;
if (!id) return;
this.sendQueue = this.sendQueue.then(async () => {
try {
await webrtc.send(id, envelope);
} catch {
// Rate limit or closed session: the next beacon/election repairs state.
}
});
}
private drain(): Promise<void> {
return this.sendQueue;
}
dispose(): void {
void this.leaveRoom('closed');
this.eventSub?.close();
this.eventSub = null;
}
}
