# Transport Interface Specification

**Status:** Finalized for Goal A1 (2026-05-25). The canonical contract is the abstract base class `transports/Transport.js`; this document is its prose specification, error model, and conformance requirements.

## Purpose

The Transport interface is the Layer 1 ↔ Layer 2 boundary. It moves opaque messages between peers without knowing about gameplay, ticks, rollback, queries, or simulation state. Implementations include `MemoryTransport` (deterministic test substrate, Goal A2), `TrysteroTransport` (P2P via WebTorrent, Goal A5/C1), and eventually `ServerTransport`.

The interface exists so that the rollback / simulation layer can be tested deterministically and so that swapping transports (P2P ↔ server-client ↔ hybrid) requires zero changes to Layer 2 or Layer 3.

## Mental Model

- **Push, not pull.** Layer 2 calls `send` / `broadcast` when it has something to say. (The v1 `Loop()` + `GetDataToSend()` pull model is a Layer-2 concern — rate-limiting broadcasts is Layer 2's job, not the transport's.)
- **Best-effort by default.** Messages may drop, duplicate, or reorder. Layer 2's acceptance/grace windows and redelivery absorb this.
- **Opaque payloads.** Messages are structured-clone-compatible plain objects. The transport never inspects them.
- **Eventually-consistent peer set.** `getPeers()`, `onPeerJoined`, `onPeerLeft` reflect the transport's own view, which converges over time.

---

## Interface

The full signatures with JSDoc live in `transports/Transport.js`. Summary:

| Member | Kind | Description |
|---|---|---|
| `localId` | getter | Opaque, stable local peer ID. Available after construction. Never appears in `getPeers()` or peer events. |
| `connect()` | abstract | Begin joining the network. Peers appear asynchronously. Returns void or Promise. |
| `disconnect()` | abstract | Leave and release resources. Idempotent. |
| `send(peerId, message, options?)` | abstract | Unicast. No-op for unknown/departed peer. `options.reliable` optional. |
| `broadcast(message, options?)` | abstract | Send to all connected peers. No self-loopback. |
| `getPeers()` | abstract | Array of connected peer IDs, excluding self. |
| `clockHint(peerId)` | optional | RTT estimate in ms, or `null`. Default returns `null`. |
| `onMessage(cb)` | concrete | `cb(fromPeerId, message)`. Returns unsubscribe fn. |
| `onPeerJoined(cb)` | concrete | `cb(peerId)`. Returns unsubscribe fn. |
| `onPeerLeft(cb)` | concrete | `cb(peerId)`. Returns unsubscribe fn. |
| `isConnected()` | concrete | True after `connect()`. |

The base class implements listener bookkeeping and exposes protected `_emitMessage` / `_emitPeerJoined` / `_emitPeerLeft` for implementations to fire events. Implementations override the abstract methods and set `_localId`.

---

## Usage

### Consumer side (Layer 2 driving a transport)

```js
const transport = new MemoryTransport();   // or TrysteroTransport(...)

const myId = transport.localId;            // stable, available pre-connect

// Register before connect() so no early events are missed.
const offMsg  = transport.onMessage((from, msg) => handleWireMessage(from, msg));
const offJoin = transport.onPeerJoined((peerId) => onParticipantReachable(peerId));
const offLeft = transport.onPeerLeft((peerId) => onParticipantGone(peerId));

await transport.connect();                  // tolerate void OR Promise

// Per-tick intent to everyone, best-effort:
transport.broadcast({ type: 'intent', fromPeer: myId, tick: 1024, intent: { jump: true } });

// Targeted, best-effort (no-op if peer already departed):
for (const peerId of transport.getPeers())
{
    transport.send(peerId, { type: 'hashWindow', fromPeer: myId, oldestTick: 900, /* ... */ });
}

// Bulk one-shot that must arrive intact and ordered:
transport.send(joinerId, { type: 'bootstrapResponse', /* large payload */ }, { reliable: true });

// Optional RTT primitive for the composed SyncedClock:
const rttMs = transport.clockHint(somePeerId);   // number | null

// Teardown:
offMsg(); offJoin(); offLeft();
transport.disconnect();                      // idempotent
```

### Implementer side (writing a transport)

```js
class MyTransport extends Transport
{
    constructor()
    {
        super();
        this._localId = generateStableId();   // required: makes localId usable
    }

    connect()    { /* begin joining; later call this._emitPeerJoined(id) per peer */ this._connected = true; }
    disconnect() { /* release resources; safe to call twice */ this._connected = false; }
    send(peerId, message, options = {})    { /* unicast; no-op if peerId unknown */ }
    broadcast(message, options = {})       { /* to all peers, never self */ }
    getPeers()   { return [/* connected peer ids, excluding localId */]; }
    // On inbound traffic / liveness changes, fire:
    //   this._emitMessage(fromId, msg) / this._emitPeerJoined(id) / this._emitPeerLeft(id)
}
```

---

## Resolved Design Questions

The six open questions from the draft are resolved as follows (see `DECISIONS.md` #22).

### 1. Reliable vs unreliable channel

**Best-effort is the baseline contract.** All of Layer 2 must function correctly assuming messages may drop, duplicate, or reorder — this is what the semantic-rollback model is designed around (silence-as-default, window-based redelivery).

Implementations **MAY** honor `send(peerId, message, { reliable: true })` for guaranteed eventual, ordered delivery. This exists for bulk/critical paths — bootstrap full-state transfer (B10) and severe-desync state transfer (B8) — where redelivery-by-window is inappropriate. Layer 2 must never *depend* on reliability for correctness of the core simulation loop; it is an optimization for large one-shot payloads.

- `MemoryTransport`: implements `reliable` as guaranteed delivery (still subject to virtual latency).
- `TrysteroTransport`: Trystero's `makeAction` provides ordered/reliable channels; map `reliable` onto one.

### 2. Maximum message size

The interface guarantees transparent delivery of messages up to **16 KB** of structured-clone payload without the caller chunking. Larger messages **MAY** be supported; implementations that wrap a chunking transport (Trystero auto-chunks) may exceed this freely.

Layer 2 guidance: keep per-tick messages (intents, hash windows, heartbeats) well under 16 KB. Large payloads (bootstrap, state transfer) should use `{ reliable: true }` and may exceed 16 KB only against implementations that document larger support.

### 3. Backpressure

**No backpressure signal in v1.** Sends are fire-and-forget. Under load an implementation may drop best-effort messages; Layer 2's windows and redelivery absorb the loss. Revisit only if profiling shows it matters.

### 4. Self-peer reporting

**Self is excluded everywhere.** `getPeers()` never includes `localId`. `onPeerJoined` / `onPeerLeft` never fire for self. `broadcast` never loops back to the local peer. The only way to refer to the local peer is `localId`. (Matches Trystero semantics.)

### 5. Heartbeat cadence / liveness

**Liveness is transport-internal.** The interface exposes only the *results* — `onPeerJoined`, `onPeerLeft`, `getPeers()`. Each implementation chooses its own liveness mechanism:

- `TrysteroTransport`: WebRTC connection state (Trystero's `onPeerJoin` / `onPeerLeave`) — no app-level heartbeat needed.
- `MemoryTransport`: liveness is set directly by the test (connect/disconnect/partition controls).
- A hypothetical raw-datagram transport: would run its own internal heartbeat.

Crucially: **silence of application messages never affects liveness.** A peer that sends no intents for minutes but whose connection is healthy stays in `getPeers()`. This is what makes "silence is meaningful" (no packet = unchanged, not gone) safe at Layer 2.

> Note: Goal B2 "transport-level heartbeats" refers to this internal mechanism, realized inside transport implementations that need it. Heartbeats are **not** a Layer-2 wire message and do not appear in `PROTOCOL_SPEC.md`.

### 6. Connection establishment

Construction is cheap and does not touch the network; `localId` is available immediately. `connect()` begins joining and returns void or a Promise (implementations that need async setup may return one; callers should tolerate both). There is **no global "ready" event** — in a mesh, peers arrive over time via `onPeerJoined`. Layer 2 deals with peers as they appear.

### Bonus resolved: where does clock sync live?

The design doc lists "clock synchronization" under Layer 1. We refine this: **the transport provides only the RTT primitive (`clockHint`)**; the synced clock that maps wall-time to a shared timeline (the existing `SyncedClock`) is composed alongside the transport and exchanges its ping/pong as ordinary Layer-2 messages. Rationale: tick mapping is a Layer-2 concern (Layer 1 must not know about ticks), and keeping the transport to a pure message+liveness primitive keeps it trivially implementable and testable. This unentangles the clock ping/pong currently baked into `WorldNetworkCommunicator.SendData`.

---

## Error Model

- **Send to unknown/departed peer** → silent no-op. The peer set is eventually consistent; a peer may have left between `getPeers()` and `send()`. Not an error.
- **Send/broadcast before `connect()`** → throw. Programmer error.
- **Non-clone-safe message** → implementations are not required to validate; behavior is undefined (debug builds MAY validate). Callers must pass structured-clone-safe objects.
- **Registering listeners after `connect()`** → allowed, but early events may be missed. Register before connecting.
- **`disconnect()` twice** → no-op the second time (idempotent).
- **Exception thrown by a listener** → the transport does not catch it; it propagates to the emit caller. Listeners must not throw. (Implementations MAY isolate listeners in future; not required in v1.)

---

## Conformance Requirements

Every Transport implementation MUST satisfy these. They become the executable conformance suite delivered with Goal A2 (`MemoryTransport`), written as a reusable function that any implementation runs against.

1. **Identity** — `localId` is defined, stable, and string-typed after construction; equal across repeated reads.
2. **Self-exclusion** — for any topology, `getPeers()` never contains `localId`; no peer event ever fires with `localId`.
3. **Broadcast reaches all peers** — a `broadcast(m)` from peer A is delivered to every other connected peer exactly the message `m` (deep-equal), and never back to A. (Best-effort: under a lossless configuration, delivery is total.)
4. **Unicast reaches one peer** — `send(B, m)` from A is delivered to B and to no other peer.
5. **No content mutation** — delivered message deep-equals the sent message (transport must not mutate or reorder fields).
6. **Unknown-peer send is a no-op** — `send('does-not-exist', m)` neither throws nor delivers anywhere.
7. **Peer events fire** — connecting two peers fires `onPeerJoined` on each for the other; disconnecting one fires `onPeerLeft` on the survivor within the implementation's liveness bound.
8. **Liveness is message-independent** — a peer that sends zero application messages for an arbitrary duration remains in `getPeers()` of its connected peers.
9. **Pre-connect send throws; post-disconnect send is a no-op.**
10. **Unsubscribe works** — a handler removed via its returned unsubscribe fn receives no further events.
11. **`reliable` delivery (if supported)** — a `{reliable:true}` message is eventually delivered exactly once and in order relative to other reliable messages on the same link, even under a lossy best-effort configuration.
12. **Determinism (test transports only)** — given the same seed and the same action schedule, `MemoryTransport` produces byte-identical delivery logs.

---

## Implementations

- **`MemoryTransport`** — Goal A2. In-process, deterministic; controllable per-link latency, jitter, loss, duplication, reorder, partition. The substrate the conformance suite and scenario harness run on.
- **`TrysteroTransport`** — Goal A5 / C1. Wraps the current Trystero/WebTorrent path; unentangles clock ping/pong from data send.
- **`ServerTransport`** — out of scope this phase. The interface accommodates it (a hub that relays unicast/broadcast and reports liveness) but no implementation is planned.

---

## Versioning

Layer-2 messages carry their own versioning (see `PROTOCOL_SPEC.md`). The Transport interface itself is unversioned in v1; breaking changes to this contract are tracked in `DECISIONS.md`.
