/AYjnt/
All examples
realtimegamemultiplayer

Multiplayer space game

Asteroid-field shooter. One Durable Object owns the world, runs a 30Hz physics tick, and broadcasts the entire state every frame. Clients send keyboard inputs and render the canvas from state.

What you'll learn
  • Running a real setInterval physics loop inside a Durable Object
  • Authoritative server + dumb clients — send inputs, render state
  • Why 30Hz state sync is fine for 12 ships but not for AAA production
01 step

Start from the blank scaffold (with UI)

Same starter as the blank template but with React, react-dom and matching @types preinstalled so your agent.ts can have an app.tsx next to it. The default project is a Counter agent — we'll replace it with the example's agent in the next steps.

~/my-agent-app
my-app/ (--with-ui scaffold)
agent.ts
app.tsx
package.json
tsconfig.json
02 step

Replace counter/ with sector/

One DO per /sector/<name> — /sector/7-G and /sector/9 are independent games. Ships are tracked by connection id. Asteroids and bullets live in state.

~/my-agent-app
03 step

agents/sector/agent.ts — physics on the DO

The physics loop is a real `setInterval` inside the DO. The DO is alive as long as there's an open WebSocket, so the loop survives request boundaries. `ensureLoop()` starts it on first connect; `stopLoop()` tears down on last disconnect so the alarm bill doesn't pile up.

agents/sector/agent.ts ts
import { Agent, type Connection, type WSMessage } from "agents";
import type { GeneratedEnv } from "@ayjnt/env";

const WORLD = { w: 800, h: 600 };
const TICK_HZ = 30;

type Ship = { id: string; name: string; x: number; y: number; vx: number; vy: number; a: number; thrust: boolean; left: boolean; right: boolean; kills: number; deaths: number; nextShotAt: number; respawnedAt: number; };
type State = { ships: Ship[]; bullets: unknown[]; asteroids: unknown[]; lastTick: number };

export default class SectorAgent extends Agent<GeneratedEnv, State> {
  override initialState: State = { ships: [], bullets: [], asteroids: [], lastTick: 0 };
  private loopHandle: ReturnType<typeof setInterval> | null = null;

  override async onConnect(conn: Connection) {
    this.seedAsteroids();
    this.setState({ ...this.state, ships: [...this.state.ships, this.spawn(conn.id, "guest")] });
    this.ensureLoop();
  }

  override async onMessage(conn: Connection, message: WSMessage) {
    if (typeof message !== "string") return;
    const frame = JSON.parse(message);
    // handle hello / input / fire — see examples/space-game for full code
  }

  override async onClose(conn: Connection) {
    this.setState({ ...this.state, ships: this.state.ships.filter((s) => s.id !== conn.id) });
    if (this.state.ships.length === 0) this.stopLoop();
  }

  private ensureLoop() {
    if (this.loopHandle) return;
    this.loopHandle = setInterval(() => this.tick(), 1000 / TICK_HZ);
  }
  private stopLoop() { if (this.loopHandle) clearInterval(this.loopHandle); this.loopHandle = null; }

  private tick() {
    // integrate ships, advance bullets, collide, setState(world)
  }

  private spawn(id: string, name: string): Ship { /* random edge, random heading */ return {} as Ship; }
  private seedAsteroids() { /* 12 drifting asteroids if state empty */ }

  override async onRequest() { return Response.json({ instance: this.name, ...this.state }); }
}
04 step

agents/sector/app.tsx — canvas renderer

Clients are display + input. On state change (30Hz), re-draw the canvas from `agent.state`. Keyboard handler only sends deltas — when a key's flag changes, it ships one input frame.

agents/sector/app.tsx tsx
import { useEffect, useRef, useState } from "react";
import { useAgent } from "@ayjnt/sector";

export default function Game() {
  const [name] = useState(() => prompt("pilot?") ?? "guest");
  const canvas = useRef<HTMLCanvasElement | null>(null);
  const agent = useAgent();
  const input = useRef({ thrust: false, left: false, right: false });

  useEffect(() => { agent.send(JSON.stringify({ kind: "hello", name })); }, [agent, name]);

  useEffect(() => {
    const down = (e: KeyboardEvent) => update(e.key, true);
    const up = (e: KeyboardEvent) => update(e.key, false);
    function update(key: string, on: boolean) {
      const i = input.current;
      let changed = false;
      if (key === "w" || key === "ArrowUp") { if (i.thrust !== on) { i.thrust = on; changed = true; } }
      else if (key === "a" || key === "ArrowLeft") { if (i.left !== on) { i.left = on; changed = true; } }
      else if (key === "d" || key === "ArrowRight") { if (i.right !== on) { i.right = on; changed = true; } }
      else if (key === " " && on) { agent.send(JSON.stringify({ kind: "fire" })); return; }
      if (changed) agent.send(JSON.stringify({ kind: "input", ...i }));
    }
    window.addEventListener("keydown", down); window.addEventListener("keyup", up);
    return () => { window.removeEventListener("keydown", down); window.removeEventListener("keyup", up); };
  }, [agent]);

  useEffect(() => {
    /* draw asteroids, bullets, ships from agent.state onto canvas */
  });

  return <canvas ref={canvas} width={800} height={600} tabIndex={0} />;
}
05 step

Run + open two browsers

One tab per pilot. W/↑ thrust, A/D turn, SPACE fire. Your ship is green, others are blue. Leaderboard in the HUD tracks kills/deaths across tabs.

~/my-agent-app
06 step

What it looks like

Two ships on the same canvas. Server is authoritative — if you edit `agent.state.ships[0].x = 10000` in DevTools, it snaps back on the next tick because the server overwrites client state on every setState.

SECTOR 7-G — 2 pilots result
  ┌──────────── SECTOR 7-G  2 pilots ────────────┐
  │                                                │
  │        ○               ·        ◯              │
  │                                                │
  │           ▷ alice                              │
  │                      ·    ·                    │
  │         ·                                      │
  │                        ▷ bob                   │
  │                          →→→ .                 │
  │    ○                           ·               │
  │                                                │
  │              ·     ○                  ○        │
  └────────────────────────────────────────────────┘
   [W/↑] thrust  [A D] turn  [SPACE] fire

   pilot   kills   deaths
   alice     3       1
   bob       1       3

   ▷ you  ▷ others  · bullet  ○ asteroid
07 step

Deploy

`ayjnt deploy` checks your git tree is clean + synced with origin, regenerates the wrangler config from scratch, then shells out to `wrangler deploy`. The committed migrations.json file is the source of truth for what's in production.

~/my-agent-app