# Architecture Research

**Domain:** TypeScript monorepo — multiplayer browser game (Node WS server + Phaser/Pixi web client) reconstructed from a GameMaker 5.3a `.gmd` source via an extraction + asset pipeline
**Researched:** 2026-05-01
**Confidence:** HIGH for monorepo + topology choices (verified against current docs and ecosystem); MEDIUM for asset pipeline specifics (driven by `.gmd` format + 7-stage roadmap, not external precedent)

## Standard Architecture

### System Overview

REBNO is best modeled as **three independent pipelines that share a typed contract**, not a classic web app. The pipelines are:

1. **Extraction pipeline** (offline, run from `.gmd` source) → produces structured artifacts + documentation.
2. **Runtime pipeline** (online) → authoritative server + thin client, exchanging binary WS frames.
3. **Asset pipeline** (offline, runs after extraction) → converts BMP/MIDI/font assets to web-native formats consumed by the client.

The **shared `protocol` package** is the seam between server and client; the **shared `game-logic` package** is the seam between client-side prediction and server-side authority. Everything else is a leaf.

```
┌────────────────────────────────────────────────────────────────────────┐
│                       OFFLINE / BUILD-TIME                              │
│                                                                          │
│  legacy/*.gmd ──► tools/extract-gmd ──► .extracted/  (per-asset files)  │
│                                            │                             │
│                          ┌─────────────────┼─────────────────┐           │
│                          ▼                 ▼                 ▼           │
│                  docs/extracted-engine  docs/extracted-server            │
│                  (Stage 2 output)       (Stage 3 output)                 │
│                          │                 │                             │
│                          └────────┬────────┘                             │
│                                   ▼                                      │
│                       tools/asset-pipeline                               │
│                       (BMP→atlas, MIDI→OGG, TTF→WOFF2)                  │
│                                   │                                      │
│                                   ▼                                      │
│                        dist/assets/<contenthash>/                        │
└──────────────────────┬─────────────────────────────────────────────────┘
                       │ (assets uploaded to CDN / static host)
┌──────────────────────┼─────────────────────────────────────────────────┐
│                      │           RUNTIME                                │
│                      ▼                                                  │
│  ┌──────────────────────────┐         ┌──────────────────────────┐      │
│  │       apps/client        │         │       apps/server        │      │
│  │   (Phaser/Pixi + Vite)   │  WSS    │   (Node + ws + TS)       │      │
│  │  - render / input        │ ◄────►  │  - room manager          │      │
│  │  - prediction (game-logic)│ binary │  - authority (game-logic)│      │
│  │  - reconciliation        │ frames  │  - persistence           │      │
│  └─────────┬────────────────┘         └────────┬─────────────────┘      │
│            │                                    │                       │
│            │ HTTPS (assets)                     │ Volume / SQLite       │
│            ▼                                    ▼                       │
│      CDN / static                       Fly.io persistent volume        │
│      (atlases, audio,                   + Litestream → S3-compatible    │
│       fonts, manifest)                                                  │
└────────────────────────────────────────────────────────────────────────┘

       ┌───── packages/protocol ─────┐    ┌──── packages/game-logic ────┐
       │ TS types + binary codec     │    │ Pure simulation (no I/O)    │
       │ (depended on by both client │    │ (depended on by both client │
       │  and server)                │    │  for prediction, server for │
       └─────────────────────────────┘    │  authority)                 │
                                          └─────────────────────────────┘
```

### Component Responsibilities

| Component | Responsibility | Implementation |
|-----------|----------------|----------------|
| `tools/extract-gmd` | Parse `.gmd` binary per the 12-block sequential layout (`decomp/wiki/03-gmd-format.md`); emit GML scripts, DnD events, room layouts, raw sprites/sounds/fonts as one-file-per-resource trees. Reproducible from `legacy/open-source-release/*.gmd`. | TS Node CLI; ZLIB inflate for sprite/background blocks; mirrors LateralGM `LibReader.java` semantics. |
| `tools/asset-pipeline` | Convert extracted raw assets into web-native delivery formats: BMP sprite sheets → PNG texture atlases (Free Texture Packer / `texturepacker-cli` / sharp), MIDI → OGG/Opus (timidity or fluidsynth + ffmpeg), BMP fonts → WOFF2, generate `manifest.json` with content hashes. | TS Node CLI; sharp for raster, ffmpeg shell-out for audio. |
| `packages/protocol` | TypeScript types for every client↔server message + a binary codec (encode/decode pair). Single source of truth for the wire — both `apps/server` and `apps/client` import it. Versioned (PROTOCOL_VERSION constant) so handshake can reject mismatched clients. | TS-first; codec built on `DataView` or `@msgpack/msgpack` (preferred for v1 — schema-less, fast, small) with a thin opcode envelope. |
| `packages/game-logic` | Pure deterministic simulation: collision, movement integration, world-tick reducer, room/area model. Zero I/O, zero DOM, zero `ws` imports — runnable in Node and browser identically. The hinge that makes client prediction match server authority. | TS, side-effect-free. Functions of `(state, input, dt) → state`. |
| `packages/shared-utils` (optional) | Logging interface, `Result<T>` types, ID generators, time helpers. Only add if duplication appears. | TS. |
| `apps/server` | Hosts WS endpoint, manages rooms, runs `game-logic` at fixed tick (e.g. 20 Hz), broadcasts state diffs, persists user state, handles auth + reconnect tokens. Single Fly.io machine for v1. | Node 22 LTS, `ws` (or `uWebSockets.js` if perf-bound), Fastify for non-WS HTTP, SQLite + Litestream. |
| `apps/client` | Loads asset manifest, opens WS, runs `game-logic` for prediction, renders via Phaser 3 (or Pixi), reconciles when authoritative diff disagrees with prediction. | Vite-built TS, Phaser 3 (decision deferred to end of Stage 2 per PROJECT.md). |
| `docs/extracted-engine` | Stage 2 deliverable: human-readable docs of every client-side engine feature (rendering, input, collision, animation, scene model, save/load, audio). Derived from extracted GML by humans/agents. | Markdown. |
| `docs/extracted-server` | Stage 3 deliverable: account/auth, world simulation, persistence, chat, room management, packet protocol — including the reversed 39dll wire format (`decomp/wiki/08-39dll-networking.md`). | Markdown. |
| `decomp/wiki/`, `legacy/`, `.planning/` | Pre-existing — RE knowledge base, original artifacts, planning. Untouched by the new code tree. | — |

**Boundary justifications (split / merge calls):**

- **Split `extract-gmd` from `asset-pipeline`.** They run on different inputs at different times: extraction reads `.gmd` once and freezes its output (call this the "GML/asset corpus"); the asset pipeline iterates often as we tune atlas packing, audio bitrates, font hinting. Merging them couples slow rare work to fast frequent work.
- **Split `protocol` from `game-logic`.** Wire format and simulation evolve on different cadences: protocol changes are version-gated breaking events; game-logic changes happen every gameplay tweak. Mixing them forces simulation rebuilds on every wire change.
- **Keep `game-logic` as a shared package, not server-only.** Mandatory for client prediction. Without it, every input has 60–250 ms of perceived latency. The discipline cost (no I/O in `game-logic`) is small; the UX win is enormous and matches Stage 6 MVP requirements.
- **Do not split `apps/server` into `apps/server` + `apps/auth-service` for v1.** <50 CCU target makes a microservice split pure tax. Auth lives as a module inside `apps/server` and graduates only if a real boundary appears in Stage 7.
- **Keep `docs/extracted-engine` and `docs/extracted-server` as separate top-level dirs** (not under `tools/extract-gmd/output/`). They're durable artifacts the whole repo references; bury them in a tool's output folder and they get treated as build artifacts and ignored.
- **Do NOT add `packages/types`** as a separate package. Types belong with the package that owns the schema (`protocol` owns wire types, `game-logic` owns world types). A standalone `types` package becomes a dumping ground.

## Recommended Project Structure

**Workspace tool: pnpm workspaces + Turborepo.** This is the 2026 consensus for TS monorepos with 1–N apps + shared packages. pnpm gives strict dependency isolation and the `workspace:*` protocol; Turborepo gives task orchestration + remote caching. Nx is overkill for ~6 packages; npm/yarn workspaces lack pnpm's strictness; Bun workspaces are still maturing for production WS workloads. Sources below.

```
rebno/
├── package.json                         # Root: pnpm + turbo scripts only
├── pnpm-workspace.yaml                  # Lists apps/*, packages/*, tools/*
├── turbo.json                           # Pipeline: build, test, lint, typecheck
├── tsconfig.base.json                   # Shared compiler options (strict, NodeNext)
├── .nvmrc                               # Node 22 LTS pin
├── .gitignore
│
├── apps/
│   ├── server/                          # Node WS game server, deployed to Fly.io
│   │   ├── src/
│   │   │   ├── index.ts                 # Boot: load config, start WS + HTTP
│   │   │   ├── ws/                      # Connection handling, frame codec wiring
│   │   │   ├── rooms/                   # Room registry, lifecycle, broadcast
│   │   │   ├── auth/                    # Login, token issue, password migration
│   │   │   ├── persistence/             # SQLite repos (user, world, MB log)
│   │   │   ├── tick/                    # Fixed-rate game loop, state diff calc
│   │   │   └── ops/                     # Health, metrics, structured logging
│   │   ├── migrations/                  # SQL schema migrations (drizzle/kysely)
│   │   ├── Dockerfile                   # Multi-stage: build TS → slim runtime
│   │   ├── fly.toml                     # Fly app config: machines, volumes, regions
│   │   └── package.json
│   │
│   └── client/                          # Web client, Vite, Phaser 3 (or Pixi)
│       ├── src/
│       │   ├── main.ts                  # Bootstrap, scene registry
│       │   ├── scenes/                  # Phaser scene classes
│       │   ├── net/                     # WS client, codec wiring, reconnect
│       │   ├── input/                   # Keyboard / gamepad mapping
│       │   ├── render/                  # Sprite/atlas adapters
│       │   ├── audio/                   # Music + SFX manager
│       │   ├── prediction/              # Wraps packages/game-logic for client side
│       │   └── ui/                      # HUD, chat box, menus (DOM or Phaser UI)
│       ├── public/                      # index.html, favicon
│       ├── vite.config.ts
│       └── package.json
│
├── packages/
│   ├── protocol/                        # Shared wire contract — depended on by both apps
│   │   ├── src/
│   │   │   ├── version.ts               # PROTOCOL_VERSION constant
│   │   │   ├── messages.ts              # TS types: ClientToServer, ServerToClient
│   │   │   ├── opcodes.ts               # Numeric opcode enum (1 byte envelope)
│   │   │   ├── codec.ts                 # encode(msg) / decode(buf)
│   │   │   └── index.ts
│   │   └── package.json
│   │
│   ├── game-logic/                      # Pure simulation — zero I/O, zero DOM
│   │   ├── src/
│   │   │   ├── world.ts                 # WorldState type + reducer
│   │   │   ├── movement.ts              # Player tick (input, dt) → next pos
│   │   │   ├── collision.ts             # Tile / entity collision
│   │   │   ├── rooms.ts                 # Area / room model
│   │   │   ├── tick.ts                  # step(state, inputs, dt) → state
│   │   │   └── index.ts
│   │   └── package.json
│   │
│   └── shared-utils/                    # OPTIONAL — only create when duplication appears
│       └── src/
│
├── tools/
│   ├── extract-gmd/                     # .gmd → structured GML + raw assets
│   │   ├── src/
│   │   │   ├── index.ts                 # CLI entry
│   │   │   ├── reader/                  # Sequential block parser (mirrors LateralGM)
│   │   │   │   ├── header.ts            # Block 1: magic
│   │   │   │   ├── settings.ts          # Block 2
│   │   │   │   ├── sounds.ts            # Block 3
│   │   │   │   ├── sprites.ts           # Block 4 (ZLIB inflate)
│   │   │   │   ├── backgrounds.ts       # Block 5 (ZLIB inflate)
│   │   │   │   ├── paths.ts             # Block 6
│   │   │   │   ├── scripts.ts           # Block 7 (GML source)
│   │   │   │   ├── data-files.ts        # Block 8
│   │   │   │   ├── fonts.ts             # Block 9
│   │   │   │   ├── timelines.ts         # Block 10
│   │   │   │   ├── objects.ts           # Block 11 + DnD nodes
│   │   │   │   └── rooms.ts             # Block 12
│   │   │   ├── dnd/                     # DnD action node format (wiki/04)
│   │   │   └── emit/                    # One-file-per-resource writer
│   │   └── package.json
│   │
│   └── asset-pipeline/                  # Raw → web-native + manifest
│       ├── src/
│       │   ├── index.ts                 # CLI entry
│       │   ├── sprites/                 # BMP frames → PNG atlases (sharp + packer)
│       │   ├── audio/                   # MIDI → OGG/Opus (ffmpeg shell-out)
│       │   ├── fonts/                   # BMP/TTF → WOFF2
│       │   ├── manifest/                # Content-hash + atlas frame index
│       │   └── upload/                  # OPTIONAL: push to CDN bucket
│       └── package.json
│
├── docs/
│   ├── extracted-engine/                # Stage 2 deliverable (markdown)
│   ├── extracted-server/                # Stage 3 deliverable (markdown)
│   └── adr/                             # Architecture Decision Records
│
├── .extracted/                          # gitignored — output of tools/extract-gmd
│                                        # (one-file-per-resource tree, diffable)
│
├── decomp/                              # EXISTING — RE wiki, untouched
├── legacy/                              # EXISTING — original artifacts, untouched
└── .planning/                           # EXISTING — phase plans + research
```

### Structure Rationale

- **`apps/` vs `packages/` vs `tools/` split:** `apps/` are deployable units (have a process), `packages/` are libraries imported by apps, `tools/` are CLIs run by humans/CI but not deployed. The three categories have different release cadences and different test expectations.
- **`tools/` lives at workspace root, not under `packages/`:** Extraction and asset transform are first-class to the project (the entire roadmap orbits them) and CI runs them. They're not internal dev utilities.
- **`apps/server/migrations/` next to its app:** Persistence is owned by the server, not a shared concern. Don't lift it to a `packages/db` until a second consumer exists.
- **`packages/protocol/src/codec.ts` is one file, not a folder:** Codec is small (a few hundred lines for a binary message format). Folder = ceremony. If it grows past ~500 lines, then split.
- **`.extracted/` is gitignored, `docs/extracted-*` is committed:** Raw extraction output is large and regenerable; documentation derived from it is small and reviewable. The diff signal lives in the docs.
- **No `apps/web` or `apps/landing`:** v1 is single-page Chrome client only (PROJECT.md). Don't pre-create.

## Architectural Patterns

### Pattern 1: Shared simulation between client (prediction) and server (authority)

**What:** `packages/game-logic` exports a pure `step(state, inputs, dt) → state` function. Server runs it on the authoritative state at fixed tick. Client runs the same function on local state every input frame to predict the result, then reconciles when the server's diff arrives.

**When to use:** Any real-time multiplayer where input latency feels bad (movement, action games). Not needed for turn-based or text-only experiences.

**Trade-offs:**
- Pro: Removes 60–250 ms of perceived input lag. Smooth movement on real internet connections. Stage 6 MVP (movement) needs this.
- Pro: One simulation = one source of truth = fewer behavior divergence bugs.
- Pro: Determinism + shared simulation make **moving-platform live positions** (SRV-14) fall out for free — platforms are world-state entities advanced by the same `step()`; the server's state-diff envelope already carries platform positions to all clients in the room, who interpolate (never extrapolate) the received tick. No special-case protocol or sync code.
- Con: `game-logic` must stay pure (no `Math.random()` without seeded RNG, no `Date.now()`, no I/O). Discipline + lint rule needed.
- Con: Reconciliation logic is non-trivial — on misprediction, replay buffered inputs from the authoritative tick forward.
- Con: World state — including platform entities — must be addressable by stable IDs in the state-diff schema from Phase 4 (even if Phase 4's MVP room ships zero platforms), so Phase 7 can introduce platform-bearing rooms without a protocol or `step()` signature change.

**Example:**
```typescript
// packages/game-logic/src/tick.ts
export function step(state: WorldState, inputs: PlayerInput[], dt: number): WorldState {
  // pure reducer — same code runs on server (authority) and client (prediction)
  let next = state;
  for (const input of inputs) next = applyMovement(next, input, dt);
  next = resolveCollisions(next);
  return next;
}

// apps/server/src/tick/loop.ts
setInterval(() => {
  const next = step(world, drainInputs(), TICK_DT);
  broadcast(diff(world, next));
  world = next;
}, TICK_MS);

// apps/client/src/prediction/local.ts
function onLocalInput(input) {
  predicted = step(predicted, [input], LOCAL_DT);
  pendingInputs.push(input);
  send(input);
}
function onServerDiff(diff) {
  authoritative = apply(authoritative, diff);
  predicted = replayInputs(authoritative, pendingInputs.dropAcked());
}
```

### Pattern 2: Binary opcode envelope + msgpack payload

**What:** Every WS frame is `[1 byte opcode] [msgpack payload]`. Opcode names the message type; payload is structured data. Both encoded/decoded by `packages/protocol`.

**When to use:** Real-time games where frame size matters but you don't want to hand-write a binary schema. Trade some bytes vs a fully hand-crafted layout (e.g. flatbuffers/protobuf) for huge dev velocity.

**Trade-offs:**
- Pro: 2–5× smaller than JSON, 5–10× faster to parse than JSON, single shared TS type covers both ends.
- Pro: Adding a message = add an opcode + a TS type. No `.proto` compile step.
- Con: Larger than hand-rolled DataView packing (matters past ~thousands of CCU; irrelevant at <50).
- Con: msgpack doesn't enforce a schema — runtime validation (zod/valibot) recommended on the server boundary.

**Example:**
```typescript
// packages/protocol/src/codec.ts
import { encode, decode } from "@msgpack/msgpack";
export const PROTOCOL_VERSION = 1;
export function encodeMessage(msg: ClientToServer): Uint8Array {
  const payload = encode(msg.data);
  const frame = new Uint8Array(1 + payload.byteLength);
  frame[0] = msg.op;
  frame.set(payload, 1);
  return frame;
}
export function decodeMessage(buf: Uint8Array): ServerToClient {
  const op = buf[0] as Opcode;
  const data = decode(buf.subarray(1));
  return { op, data } as ServerToClient;
}
```

### Pattern 3: Room-as-aggregate with single-process authority

**What:** A room is a state container + a tick loop. Every player connection is bound to exactly one room. All authority for that room runs in the room's tick. Cross-room communication is explicit (e.g. global chat broadcasts).

**When to use:** Whenever the world is partitionable (areas, lobbies, instances) and players in different partitions don't need to interact in real time. BNO's area model fits this exactly per legacy `Areas_*.bnu` files.

**Trade-offs:**
- Pro: Bounds the per-tick work. A 30-player room ticks ~30 entities, not the whole world.
- Pro: Sharding-ready — when the day comes, you move whole rooms to new processes. No reshuffling of in-room state.
- Con: Cross-room features (global chat, friends list, who's-online) need an extra mechanism (event bus / pub-sub).

**Room layouts are server-authoritative (SRV-13).** The room aggregate owns the layout — geometry, collision, spawn points, platform entities — and ships it to the client on room entry over the same WS as a `RoomLoad` opcode (or, when delivered out-of-band, as a content-addressed asset bundle whose URL is signed by the server). The client bundle ships zero static layout data: the only way for the client to know what a room looks like is to receive it from the authoritative server. The wire frame is integrity-verified (signed manifest hash or per-frame checksum) so a tampered intermediate cannot inject a forged room — protects against MITM that would otherwise let an attacker spawn invisible walls or relocate exits. MVP slice (Phase 4) ships at minimum one room via this path; PAR-03 (Phase 7) populates the full set without touching the protocol or client load path.

**Example:**
```typescript
// apps/server/src/rooms/Room.ts
class Room {
  private state: WorldState;
  private inputs = new Map<PlayerId, PlayerInput[]>();
  private timer: NodeJS.Timeout;

  start() { this.timer = setInterval(() => this.tick(), TICK_MS); }

  tick() {
    const drained = [...this.inputs.values()].flat();
    this.inputs.clear();
    const next = step(this.state, drained, TICK_DT);
    this.broadcast(diff(this.state, next));
    this.state = next;
  }

  enqueueInput(id: PlayerId, input: PlayerInput) {
    (this.inputs.get(id) ?? this.inputs.set(id, []).get(id)!).push(input);
  }
}
```

### Pattern 4: Asset manifest with content-addressed delivery

**What:** `tools/asset-pipeline` emits `manifest.json` mapping logical asset names → versioned URLs (`/<contenthash>/atlas-navi.png`). Client fetches the manifest first, then loads everything by manifest URL. New asset build = new hashes = automatic cache busting; old hashes stay valid for in-flight clients.

**When to use:** Any production web client with a non-trivial asset catalog. Eliminates the entire class of stale-asset bugs.

**Trade-offs:**
- Pro: Atomic releases — client either sees the full new manifest or the full old one, never half.
- Pro: CDN cache headers can be `immutable, max-age=31536000` since URLs change on content change.
- Con: One extra round-trip (manifest first). Mitigate by inlining manifest hash in `index.html` or `<link rel=preload>`.

**Example:**
```typescript
// tools/asset-pipeline/src/manifest/build.ts
const manifest = { version: PIPELINE_VERSION, assets: {} as Record<string, string> };
for (const file of outputs) {
  const hash = sha256(file.bytes).slice(0, 8);
  const url = `${hash}/${file.name}`;
  await write(`dist/assets/${url}`, file.bytes);
  manifest.assets[file.logicalName] = url;
}
await write("dist/assets/manifest.json", JSON.stringify(manifest));

// apps/client/src/main.ts
const manifest = await (await fetch("/assets/manifest.json")).json();
this.load.atlas("navi", `/assets/${manifest.assets["navi-atlas-png"]}`,
                       `/assets/${manifest.assets["navi-atlas-json"]}`);
```

## Data Flow

### Client → Server (input)

```
[Player keypress]
     ↓
[apps/client/src/input]   → PlayerInput object (frame#, dx, dy, action)
     ↓
[apps/client/src/prediction]  → step(local, [input]) → predicted state
     ↓                            (also: pendingInputs.push)
[apps/client/src/net]      → encodeMessage({op: INPUT, data: input})
     ↓
[WSS binary frame]
     ↓
[apps/server/src/ws]       → decodeMessage(buf)
     ↓
[apps/server/src/rooms]    → room.enqueueInput(playerId, input)
     ↓
[next tick]                → step(authoritative, drained) → next state
```

### Server → Client (state diff broadcast)

```
[Room.tick fires every TICK_MS (50 ms = 20 Hz)]
     ↓
[step(state, inputs, dt) → next]
     ↓
[diff(state, next)]         → DiffPayload {playerId: {pos, vel, anim}, ...}
     ↓
[for each connection in room]
     ↓
[encodeMessage({op: STATE_DIFF, ack: lastInputSeq, data: diff})]
     ↓
[WSS binary frame, broadcast]
     ↓
[apps/client/src/net]       → decodeMessage(buf)
     ↓
[apps/client/src/prediction] → reconcile: drop acked inputs,
                                replay pendingInputs from authoritative
     ↓
[apps/client/src/render]    → Phaser scene reads predicted state, renders
```

### Chat (push, server-fanout)

```
[Player sends chat]
     ↓
[CHAT_SEND] → server validates (length, mute, rate-limit)
     ↓
[Persisted to MB_Log table (if board) or transient (if local chat)]
     ↓
[CHAT_BROADCAST] → all connections in room (or global, depending on channel)
     ↓
[Client renders in HUD chat box]
```

### Disconnect / Reconnect

```
[Connection drops]
     ↓
[Server holds Player record in room for RECONNECT_GRACE_MS (e.g. 30 s)]
     │       — entity remains in world, marked .disconnected=true
     │       — other players see a "linkdead" indicator
     ↓
[Client tries to reconnect with {sessionToken, lastAckedTick}]
     ↓
[Server matches token → restores Player → sends FULL_STATE snapshot]
     ↓
[Client resumes; predicted state catches up]

If grace expires: server emits PLAYER_LEFT, removes from room, persists state.
```

### Room isolation

Rooms own their state. A `RoomManager` holds `Map<RoomId, Room>`. Joining = `manager.join(roomId, conn)` which removes from old room (broadcasting LEAVE), adds to new (broadcasting JOIN + sending FULL_STATE snapshot). Cross-room concerns (global chat, online list) go through a separate `EventBus` singleton, not through rooms.

### Asset load (one-time, on client boot)

```
[Browser loads index.html] → preload manifest URL
     ↓
[Client fetch /assets/manifest.json]
     ↓
[Phaser/Pixi loader] → load each atlas + audio + font by manifest URL
     ↓                  (parallel, with progress bar)
[All assets ready]   → connect WS, enter login scene
```

## Authoritative Server Topology

### v1: Single Fly.io machine, single process, all rooms

For the <50 CCU target stated in PROJECT.md:

- **One Fly.io app, one machine** (e.g. `shared-cpu-1x` with 512 MB → 1 GB RAM). Region pinned to wherever the test community lives (likely `iad` or `ord`).
- **Single Node process** runs the WS server + all rooms. A 20 Hz tick across ~5 active rooms with ~10 players each is well under 1% CPU on modern hardware.
- **One persistent volume** mounted at `/data` for SQLite. **Litestream** sidecar replicates SQLite to S3-compatible storage (Cloudflare R2 or Backblaze B2) every few seconds → free continuous backup, near-zero RPO.
- **No load balancer, no Redis, no pub-sub.** Single process = in-memory event bus is enough.
- **No clustering, no `cluster` module.** WS state is in-process; clustering would require sticky sessions + cross-worker pubsub, which is exactly the complexity v1 should avoid.

### Sharding triggers (don't cross until needed)

| Signal | Action |
|--------|--------|
| Tick budget consistently > 25 ms (i.e. half the 50 ms tick) | Profile first; usually a single bad collision query, not a true scale wall. |
| Single machine RAM > 70% sustained | Vertical bump: bigger Fly machine. Cheap. Buys 2–4×. |
| Vertical scale exhausted (~500–1000 CCU on one machine) | Move to **room-per-process / room-per-machine**: a "lobby" service routes new connections to the machine hosting their room. This is where Colyseus / Nakama / custom orchestration enters. |
| Cross-region latency complaints | Multi-region Fly with region-affinity routing; per-region SQLite replicas read-only, writes routed to primary. |

### Rooms-per-process vs all-rooms-one-process

**v1: all rooms in one process.** Simpler, faster to develop, no IPC, zero serialization between rooms. Pure JavaScript runtime can comfortably tick dozens of rooms.

**Future shard model: one room per process (or N rooms per process where N = `cores - 1`).** Each room is naturally a "shardable unit" because cross-room comms are already explicit (event bus). Migration path is clean: `RoomManager` becomes a router that knows which machine hosts which room. This is exactly Colyseus's model and validates the boundary.

**Anti-pattern: thread-per-room or worker_threads-per-room before it's needed.** TS+Node single-threaded event loop is fine until it's measurably not.

## Asset Deployment

### Where assets live

- **Build output:** `dist/assets/<contenthash>/<file>` produced by `tools/asset-pipeline`.
- **Production hosting:** **Cloudflare R2 + Cloudflare CDN** (free egress, content-addressed URLs, set-and-forget). Alternative: Fly's static assets / Tigris if you want everything in the Fly ecosystem. **Avoid bundling assets into the client JS** — defeats caching and ships unused assets.
- **Manifest:** `manifest.json` itself is *not* content-hashed (stable URL). Cache it for ~60 s with `s-maxage` so client always sees recent versions but CDN absorbs load.

### How the client loads assets

1. Vite builds `apps/client` → `index.html` includes `<script src="main.[hash].js">`.
2. Boot fetches `/assets/manifest.json`.
3. Phaser/Pixi loader iterates manifest, loads atlases (PNG + JSON), audio (OGG), fonts (WOFF2) in parallel with a progress bar.
4. Once `LOAD_COMPLETE`, transition to login scene.

### Version pinning

- Each manifest carries `pipelineVersion` (bumped when asset-pipeline schema changes) and per-asset content hash.
- Client validates `pipelineVersion` against its compiled-in expected value; mismatch shows a "please refresh" message.
- Old hashed asset URLs remain valid forever — in-flight clients keep working until they reload.

### Fly volume vs CDN

Use **Fly volume only for server state** (SQLite, logs). **Never serve game assets from the Fly machine** — wastes the WS server's bandwidth budget on cacheable static content and doesn't benefit from edge caching. Strict separation: machine = stateful real-time; CDN = stateless static.

## Build Order Across the 7 Stages

The architectural pieces unlock the stages in this order. Items in **bold** are first-time creation; italics are extensions of an earlier piece.

| Stage | Architectural pieces needed | Pieces still absent |
|-------|----------------------------|---------------------|
| **Stage 1 — Extract** | **`tools/extract-gmd`**, **`.extracted/` output convention**, monorepo skeleton (pnpm + turbo + tsconfig.base) | Everything else |
| **Stage 2 — Client analysis** | *Read* `.extracted/`. **`docs/extracted-engine/`** (markdown, no code). Decision: Phaser vs Pixi recorded in `docs/adr/`. | Server, client, asset pipeline still absent |
| **Stage 3 — Server analysis** | **`docs/extracted-server/`** including reversed 39dll opcode table → becomes input to `packages/protocol`. Persistence decision (SQLite vs PG) recorded in `docs/adr/`. | Code packages still absent |
| **Stage 4 — Server rebuild** | **`packages/protocol`** (TS types + codec, MVP messages only), **`packages/game-logic`** (movement + collision MVP), **`apps/server`** (WS + rooms + auth + persistence + tick loop) | `apps/client`, full asset pipeline still absent |
| **Stage 5 — Deploy** | *`apps/server`* gains `Dockerfile` + `fly.toml` + Litestream + CI/CD. Health endpoint + structured logging. Migrations runnable on boot. | `apps/client` still absent |
| **Stage 6 — Client MVP (movement + chat)** | **`apps/client`** (Vite + Phaser/Pixi, scene boot, WS client, prediction, reconciliation, chat HUD). **`tools/asset-pipeline` minimal slice** (just enough to convert player sprite + one room background to atlas + PNG). | Full asset catalog still absent |
| **Stage 7 — Full parity** | *`tools/asset-pipeline`* extended to handle every asset type (audio, fonts, all sprites). *`packages/protocol`* extended for every message type. *`packages/game-logic`* extended for every gameplay system. *`apps/server`* and *`apps/client`* feature-complete. | — |

### MVP architecture vs full-parity architecture (the delta)

**Stage 6 MVP needs (smallest set that ships movement + chat):**

- `packages/protocol`: ~6 message types — `LOGIN`, `LOGIN_RESULT`, `JOIN_ROOM`, `INPUT`, `STATE_DIFF`, `CHAT_SEND`, `CHAT_BROADCAST`, `DISCONNECT`. PROTOCOL_VERSION = 1.
- `packages/game-logic`: movement integration + tile collision + room model. No combat, no inventory, no message board logic.
- `apps/server`: one room type, one tick loop, in-memory state, SQLite for accounts only. No persistence of world state (rooms reset on restart). Auth supports new-account creation only — migrated original accounts deferred.
- `apps/client`: login screen, single room, predicted player movement, other-player rendering, chat box. No inventory UI, no message board, no settings menu, no audio.
- `tools/asset-pipeline`: handles 1 sprite atlas + 1 background. Audio + fonts skipped.

**Stage 7 parity adds (the delta):**

- `packages/protocol`: every legacy 39dll opcode mapped to a TS message type — likely 50–100 messages. PROTOCOL_VERSION bumped to 2 (or kept at 1 if all changes are additive).
- `packages/game-logic`: combat, inventory, NPC AI, area transitions, message-board interactions, save/load semantics matching `.bno`/`.bnb` source-of-truth.
- `apps/server`: world state persisted on tick (or on transition); migrated user accounts (re-hash on first login per PROJECT.md); message board; operator commands (analog of `,ServerCommands.txt`); rate limiting; admin tooling.
- `apps/client`: full UI surface — inventory, message board, settings, audio (music + SFX), gamepad input, HiDPI / responsive layout, full feature set per `docs/extracted-engine/`.
- `tools/asset-pipeline`: every asset format (all sprites, MIDI → OGG conversion of `legacy/audio/bno-songs/`, BMP fonts → WOFF2, manifest with full catalog).

**Critical insight for the roadmap:** The MVP and parity differ in *breadth* (how many messages, how many systems, how many assets), not in *architecture*. The same packages, the same boundaries, the same deploy topology serve both. This is why getting the seams right in Stage 4 + Stage 6 matters: Stage 7 should be additive, not restructuring.

## Scaling Considerations

| Scale | Architecture Adjustments |
|-------|--------------------------|
| **0–50 CCU (v1 target)** | Single Fly machine, single process, all rooms in-process, SQLite + Litestream. Default. |
| **50–500 CCU** | Vertical bump on Fly. Same architecture. May need to move chat history to dedicated table indexed by room. |
| **500–5,000 CCU** | Room-per-process model: a "gateway" service routes new connections to "room host" services by room ID. Move SQLite → Postgres if write contention bites (likely fine to stay on SQLite if writes are batched). Add Redis for cross-process pub-sub (global chat, online list). |
| **5,000+ CCU** | Region-affinity, multi-region Fly, read replicas. Out of scope for any reasonable horizon — REBNO is friends/test scale. |

### Scaling priorities (what breaks first)

1. **First bottleneck: tick CPU on a hot room.** Mitigation: profile collision queries (`game-logic` is pure, easy to micro-benchmark). Spatial hash if needed.
2. **Second bottleneck: WS broadcast bandwidth.** Mitigation: state diffs (already designed in), per-player interest filtering (only send entities visible to that player). Both Stage 7 work, not MVP.
3. **Third bottleneck: SQLite write contention.** Mitigation: WAL mode (default with Litestream), batch writes per tick, or move to Postgres.

## Anti-Patterns

### Anti-Pattern 1: Putting `ws` import in `packages/game-logic`

**What people do:** Reach into networking from inside the simulation reducer ("just to send one event").
**Why it's wrong:** Breaks purity. Means the package can't run in the browser for client prediction. Means tests need WS mocks. Means deterministic replay is impossible.
**Do this instead:** `step()` returns a `{ state, events }` tuple. Server reads `events` and decides what to broadcast. Client reads `events` and decides what to render.

### Anti-Pattern 2: Putting wire types in `packages/game-logic` (or world types in `packages/protocol`)

**What people do:** "It's all types, who cares which package owns it."
**Why it's wrong:** Couples wire-format evolution to simulation evolution. A protocol bump shouldn't force a sim rebuild and vice versa.
**Do this instead:** `protocol` owns over-the-wire shapes (often denormalized for size). `game-logic` owns in-memory simulation shapes. Map between them at the server boundary.

### Anti-Pattern 3: Single shared "DTO" package between client/server/extractor

**What people do:** Create `packages/types` and dump every shared type there.
**Why it's wrong:** Becomes a kitchen sink. Every package depends on it. Any change rebuilds everything. Cohesion goes to zero.
**Do this instead:** Each package owns its types. If a type is genuinely shared, put it with whichever package owns the *concept* (e.g. `Room` lives with `game-logic`, not in a generic types bag).

### Anti-Pattern 4: Trying to keep the `.gmd` parser online (running at server boot)

**What people do:** "Why pre-extract? Just parse the .gmd at server start."
**Why it's wrong:** Couples runtime to GameMaker's binary format forever. Slow boot. Ties deployment to a heavyweight dependency. The whole point of REBNO is to *escape* the `.gmd` runtime.
**Do this instead:** Extraction is build-time, results are checked into `docs/extracted-*` (humans) and consumed as inputs by code that's hand-written from the documented behaviors. The `.gmd` is an archaeological reference, not a runtime dependency.

### Anti-Pattern 5: Bundling assets into the client JS

**What people do:** `import navi from './navi.png'` in client code so Vite inlines or fingerprints it.
**Why it's wrong:** Asset changes invalidate the JS bundle. Can't update content without redeploying code. Can't share assets across game versions.
**Do this instead:** Asset pipeline emits to `dist/assets/`, client loads via manifest at runtime, JS bundle stays small and stable.

### Anti-Pattern 6: Premature room-per-process sharding

**What people do:** Build the room/process router in v1 because "we'll need it for scale."
**Why it's wrong:** Doubles every networking concern (cross-process pub-sub, IPC serialization, machine routing). 10× the bug surface for 0× the user benefit at <50 CCU.
**Do this instead:** Single-process v1. Migrate later — the room-as-aggregate pattern keeps the migration mechanical.

## Integration Points

### External Services

| Service | Integration Pattern | Notes |
|---------|---------------------|-------|
| Fly.io | `flyctl deploy` from CI; `fly.toml` declares machines, regions, volumes, health checks. WS works natively over Fly's edge proxy. | Pin region; verify WS upgrade isn't broken by intermediate proxies. |
| Cloudflare R2 (assets) | Asset-pipeline `upload/` step pushes versioned blobs; CDN serves with `Cache-Control: public, max-age=31536000, immutable`. | R2 has no egress fees → ideal for game assets. |
| Litestream → R2 | Sidecar process inside server container, replicates SQLite WAL to R2 every few seconds. | Restore on boot if volume is empty. |
| Cloudflare DNS / TLS | DNS → Fly via `flyctl certs add`. TLS handled at Fly edge. | Standard. |
| GitHub Actions (CI) | On push to `main`: typecheck, test, run extraction smoke test, build all packages, deploy server. | Cache pnpm + turbo cache for fast builds. |

### Internal Boundaries

| Boundary | Communication | Notes |
|----------|---------------|-------|
| `apps/client` ↔ `apps/server` | WS binary frames via `packages/protocol` | Single seam. PROTOCOL_VERSION enforces compat. |
| `apps/server` ↔ `packages/game-logic` | Direct function call inside server tick | game-logic has zero awareness of server. |
| `apps/client` ↔ `packages/game-logic` | Direct function call inside client prediction | Same code path as server. |
| `tools/extract-gmd` → `.extracted/` | Filesystem (one file per resource) | Output is the contract. |
| `tools/asset-pipeline` → `dist/assets/` + `manifest.json` | Filesystem + content-addressed URLs | Manifest is the contract. |
| `apps/client` ← `dist/assets/` | HTTPS fetch via manifest | Pure read; client never writes. |
| `apps/server` ↔ SQLite | Drizzle/Kysely repos in `apps/server/src/persistence/` | DB is server-internal; never exposed. |

## Sources

**Monorepo tooling (HIGH — multiple recent sources agree on pnpm + Turborepo for TS monorepos with shared packages):**

- [Monorepos with TypeScript in 2026: Turborepo, pnpm Workspaces & Project References](https://medium.com/@mernstackdevbykevin/monorepos-with-typescript-93c9233f6df8)
- [Monorepo Tools 2026: Turborepo vs Nx vs Lerna vs pnpm Workspaces Compared](https://viadreams.cc/en/blog/monorepo-tools-2026/)
- [How we configured pnpm and Turborepo for our monorepo (Nhost)](https://nhost.io/blog/how-we-configured-pnpm-and-turborepo-for-our-monorepo)
- [pnpm Workspaces (official)](https://pnpm.io/workspaces)
- [Step-by-Step: Set Up 2026 Monorepo with Turborepo 2.0, pnpm 8.15, and Next.js 15](https://johal.in/step-by-step-set-2026-monorepo-turborepo-20-pnpm-815-stepbystep/)

**Authoritative WS server patterns / room model (HIGH for room-as-aggregate; MEDIUM for Fly specifics):**

- [Colyseus — Multiplayer Game Framework for Node.js (docs)](https://docs.colyseus.io/) — canonical reference for room-based authoritative WS architecture, validates the room-per-process scaling path
- [Colyseus on GitHub](https://github.com/colyseus/colyseus)
- [Fly.io community — WebSocket sharding](https://community.fly.io/t/websocket-sharding/12910)
- [Horizontally Scaling Node.js and WebSockets with Redis (GoldFire Studios)](https://goldfirestudios.com/horizontally-scaling-node-js-and-websockets-with-redis)
- [How to Implement WebSocket Connections in Node.js with Socket.io and Scaling](https://oneuptime.com/blog/post/2026-01-06-nodejs-websocket-socketio-scaling/view)

**Phaser asset loading / atlas / CDN (HIGH from official docs):**

- [Phaser 3 Loader concepts (official)](https://docs.phaser.io/phaser/concepts/loader)
- [Phaser 3 Textures concepts (official)](https://docs.phaser.io/phaser/concepts/textures)
- [Phaser texture atlas loading example](https://phaser.io/examples/v3.55.0/loader/texture-atlas-json/view/load-texture-atlas)
- [Compressed texture atlas from file pack](https://phaser.io/examples/v3.55.0/textures/view/compressed-texture-atlas-from-file-pack)

**Project-internal sources (HIGH — these define the constraints the architecture must satisfy):**

- `C:\Users\decid\Documents\projects\rebno\.planning\PROJECT.md` — 7-stage roadmap, scale target, tech stack constraints
- `C:\Users\decid\Documents\projects\rebno\.planning\codebase\ARCHITECTURE.md` — current archive-only architecture
- `C:\Users\decid\Documents\projects\rebno\.planning\codebase\STRUCTURE.md` — existing repo layout
- `C:\Users\decid\Documents\projects\rebno\decomp\wiki\03-gmd-format.md` — `.gmd` 12-block sequential layout that drives `tools/extract-gmd`
- `C:\Users\decid\Documents\projects\rebno\decomp\wiki\15-extraction-pipeline.md` — extraction methodology (Tier 4 parse-`.gmd` is the relevant tier — source files are already paired with `.gmd`)

---
*Architecture research for: TypeScript monorepo (game extraction + asset pipeline + Node WS server + web client)*
*Researched: 2026-05-01*
