Back to Browser Side Quest
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.

src/net/shard.ts
/**
 * 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;
  }
}