Transport Layer

The transport layer is how martini-kit sends and receives messages between peers. martini-kit is transport-agnostic, meaning you can swap networking backends without changing your game code.

What is a Transport?

A transport is an implementation of the Transport interface that handles:

  • Message sending - Broadcast or send to specific peers
  • Message receiving - Handle incoming messages
  • Peer lifecycle - Track when players join/leave
  • Identity - Know who you are and who’s connected
interface Transport {
  send(message: WireMessage, targetId?: string): void;
  onMessage(handler: MessageHandler): () => void;
  onPeerJoin(handler: PeerHandler): () => void;
  onPeerLeave(handler: PeerHandler): () => void;
  getPlayerId(): string;
  getPeerIds(): string[];
  isHost(): boolean;
  metrics?: TransportMetrics;  // Optional observability
}

Available Transports

martini-kit provides three built-in transports:

TransportUse CaseLatencySetupPeers
LocalTransportDemos, testing0ms (instant)EasySame page
IframeBridgeTransportIDE sandboxes~1msMediumParent ↔ Iframe
TrysteroTransportP2P production20-100msMedium2-8 players

LocalTransport

Use case: Testing, demos, prototyping

How it works: In-memory message passing using a global registry

Pros:

  • Zero latency (instant)
  • No server needed
  • Perfect for development

Cons:

  • Only works on same page
  • Can’t test real network conditions

Example:

import { LocalTransport } from '@martini-kit/transport-local';
import { GameRuntime } from '@martini-kit/core';

// Create host
const hostTransport = new LocalTransport({
  roomId: 'my-game',
  isHost: true
});

const hostRuntime = new GameRuntime(game, hostTransport, {
  isHost: true,
  playerIds: [hostTransport.getPlayerId()]
});

// Create client (same page)
const clientTransport = new LocalTransport({
  roomId: 'my-game',
  isHost: false
});

const clientRuntime = new GameRuntime(game, clientTransport, {
  isHost: false,
  playerIds: [hostTransport.getPlayerId(), clientTransport.getPlayerId()]
});

LocalTransport uses a global registry keyed by roomId. All transports in the same room can communicate instantly.


IframeBridgeTransport

Use case: Sandboxed code execution (martini-kit IDE)

How it works: postMessage API for parent ↔ iframe communication

Pros:

  • Sandboxed execution (security)
  • Very low latency (~1ms)
  • Works across origins

Cons:

  • Requires iframe setup
  • More complex than LocalTransport

Example:

// In parent window (sets up relay)
import { IframeBridgeRelay } from '@martini-kit/transport-iframe-bridge';

const relay = new IframeBridgeRelay();
const hostIframe = document.querySelector('#host-iframe');
const clientIframe = document.querySelector('#client-iframe');

relay.registerIframe(hostIframe, 'host');
relay.registerIframe(clientIframe, 'client');

// In iframe (host)
import { IframeBridgeTransport } from '@martini-kit/transport-iframe-bridge';

const transport = new IframeBridgeTransport({
  isHost: true
});

const runtime = new GameRuntime(game, transport, {
  isHost: true,
  playerIds: [transport.getPlayerId()]
});

// In iframe (client)
const transport = new IframeBridgeTransport({
  isHost: false
});

const runtime = new GameRuntime(game, transport, {
  isHost: false,
  playerIds: [/* host and client IDs */]
});

See IframeBridge API for details.


TrysteroTransport

Use case: Peer-to-peer production games

How it works: WebRTC via Trystero library (BitTorrent trackers for signaling)

Pros:

  • No server needed (P2P)
  • Low latency (direct connections)
  • Free to use

Cons:

  • NAT traversal can fail
  • Limited to ~8 players
  • WebRTC complexity

Example:

import { TrysteroTransport } from '@martini-kit/transport-trystero';

const transport = new TrysteroTransport({
  roomId: 'my-game-room',
  appId: 'my-app',  // Unique app identifier
  isHost: true,
  config: {
    // Optional STUN/TURN servers
    iceServers: [
      { urls: 'stun:stun.l.google.com:19302' }
    ]
  }
});

const runtime = new GameRuntime(game, transport, {
  isHost: true,
  playerIds: [transport.getPlayerId()]
});

See Trystero API for details.


Transport Interface Deep Dive

Core Methods

send(message, targetId?)

Send a message to specific peer or broadcast to all:

// Broadcast to all peers
transport.send({
  type: 'action',
  payload: { actionName: 'move', input: { x: 100, y: 200 } }
});

// Send to specific peer
transport.send({
  type: 'event',
  payload: { eventName: 'chat', message: 'Hello!' }
}, 'player-2');

onMessage(handler)

Listen for incoming messages:

const unsubscribe = transport.onMessage((message, senderId) => {
  console.log('Received message from', senderId, ':', message);

  if (message.type === 'action') {
    // Handle action
  }
});

// Cleanup
unsubscribe();

onPeerJoin(handler)

Listen for new peers connecting:

transport.onPeerJoin((peerId) => {
  console.log('Peer joined:', peerId);

  // Update player list
  if (isHost) {
    // Host handles new player join
    runtime.submitAction('playerJoin', { playerId: peerId });
  }
});

onPeerLeave(handler)

Listen for peers disconnecting:

transport.onPeerLeave((peerId) => {
  console.log('Peer left:', peerId);

  // Handle player disconnect
  if (isHost) {
    // Host handles player leave
    runtime.submitAction('playerLeave', { playerId: peerId });
  }
});

getPlayerId()

Get your own unique player ID:

const myId = transport.getPlayerId();
console.log('My player ID:', myId);

getPeerIds()

Get all connected peer IDs (excluding self):

const peers = transport.getPeerIds();
console.log('Connected peers:', peers);

const allPlayers = [transport.getPlayerId(), ...peers];

isHost()

Check if you’re the host:

if (transport.isHost()) {
  console.log('I am the host');
  // Start game loop
  startGameLoop();
} else {
  console.log('I am a client');
}

Optional: Transport Metrics

Transports can implement TransportMetrics for observability:

interface TransportMetrics {
  getConnectionState(): ConnectionState;
  onConnectionChange(callback: (state: ConnectionState) => void): () => void;
  getPeerCount(): number;
  getMessageStats(): MessageStats;
  getLatencyMs?(): number;
  resetStats?(): void;
}

Example:

if (transport.metrics) {
  // Get connection state
  console.log('State:', transport.metrics.getConnectionState());

  // Listen for changes
  transport.metrics.onConnectionChange((state) => {
    if (state === 'connected') {
      console.log('Connected!');
    } else if (state === 'disconnected') {
      console.log('Disconnected!');
    }
  });

  // Get stats
  const stats = transport.metrics.getMessageStats();
  console.log(`Sent: ${stats.sent}, Received: ${stats.received}`);

  // Get latency (if supported)
  const latency = transport.metrics.getLatencyMs?.();
  console.log('Latency:', latency, 'ms');
}

Message Types

Transports send WireMessage objects:

interface WireMessage {
  type: 'state_sync' | 'action' | 'player_join' | 'player_leave' | 'event' | 'heartbeat';
  payload?: any;
  senderId?: string;
  timestamp?: number;
  [key: string]: any;  // Extensible
}

Message Flow

Client                          Host
  │                              │
  │  ─── action ────────────>    │  (Receive action from client)
  │                              │
  │                              │  (Apply action to state)
  │                              │
  │  <──── state_sync ──────     │  (Broadcast state patches)
  │                              │

Action message:

{
  type: 'action',
  payload: {
    actionName: 'move',
    input: { x: 100, y: 200 },
    context: { playerId: 'p1', targetId: 'p1', ... },
    actionSeed: 12345
  },
  senderId: 'player-1'
}

State sync message:

{
  type: 'state_sync',
  payload: {
    patches: [
      { op: 'replace', path: ['players', 'p1', 'x'], value: 100 },
      { op: 'replace', path: ['players', 'p1', 'y'], value: 200 }
    ]
  },
  senderId: 'host'
}

Choosing a Transport

Decision Tree

Need real network testing?
├─ No → Use LocalTransport (instant, easy)
└─ Yes
    ├─ Building for IDE? → Use IframeBridgeTransport
    └─ Building production game?
        ├─ P2P acceptable? → Use TrysteroTransport
        └─ Need dedicated server? → Implement WebSocket transport

Comparison Table

FeatureLocalTransportIframeBridgeTrysteroWebSocket (custom)
Latency0ms~1ms20-100ms10-50ms
PlayersUnlimited*2-42-8Unlimited
ServerNoneNoneNoneRequired
NAT IssuesNoNoYesNo
ScalabilityLowLowLowHigh
SetupEasyMediumMediumHard

*Same page only


Implementing a Custom Transport

You can implement any networking backend:

import type { Transport, WireMessage } from '@martini-kit/core';

class MyCustomTransport implements Transport {
  private messageHandlers: Set<(msg: WireMessage, senderId: string) => void> = new Set();
  private peerJoinHandlers: Set<(peerId: string) => void> = new Set();
  private peerLeaveHandlers: Set<(peerId: string) => void> = new Set();

  constructor(private config: { isHost: boolean; playerId: string }) {}

  send(message: WireMessage, targetId?: string): void {
    // Implement your sending logic
    if (targetId) {
      // Send to specific peer
      this.sendToSpecificPeer(targetId, message);
    } else {
      // Broadcast to all
      this.broadcastToAll(message);
    }
  }

  onMessage(handler: (message: WireMessage, senderId: string) => void): () => void {
    this.messageHandlers.add(handler);
    return () => this.messageHandlers.delete(handler);
  }

  onPeerJoin(handler: (peerId: string) => void): () => void {
    this.peerJoinHandlers.add(handler);
    return () => this.peerJoinHandlers.delete(handler);
  }

  onPeerLeave(handler: (peerId: string) => void): () => void {
    this.peerLeaveHandlers.add(handler);
    return () => this.peerLeaveHandlers.delete(handler);
  }

  getPlayerId(): string {
    return this.config.playerId;
  }

  getPeerIds(): string[] {
    // Return list of connected peer IDs (excluding self)
    return this.getConnectedPeers();
  }

  isHost(): boolean {
    return this.config.isHost;
  }

  // Implement your custom logic
  private sendToSpecificPeer(peerId: string, message: WireMessage) {
    // ...
  }

  private broadcastToAll(message: WireMessage) {
    // ...
  }

  private getConnectedPeers(): string[] {
    // ...
    return [];
  }
}

See Custom Transports Guide for details.


Best Practices

1. Handle Connection Failures

if (transport.metrics) {
  transport.metrics.onConnectionChange((state) => {
    if (state === 'disconnected') {
      // Show "Connection lost" UI
      showReconnectDialog();

      // Attempt reconnect
      setTimeout(() => attemptReconnect(), 3000);
    }
  });
}

2. Validate Peer Identity

transport.onPeerJoin((peerId) => {
  if (!isValidPeerId(peerId)) {
    console.warn('Invalid peer ID:', peerId);
    return;
  }

  // Handle valid peer
  handleNewPlayer(peerId);
});

3. Cleanup on Destroy

class GameScene extends Phaser.Scene {
  private unsubscribes: Array<() => void> = [];

  create() {
    // Store unsubscribe functions
    this.unsubscribes.push(
      transport.onMessage(this.handleMessage),
      transport.onPeerJoin(this.handlePeerJoin),
      transport.onPeerLeave(this.handlePeerLeave)
    );
  }

  shutdown() {
    // Cleanup all listeners
    this.unsubscribes.forEach(unsub => unsub());
    this.unsubscribes = [];
  }
}

4. Test with Different Transports

// Development: LocalTransport
const devTransport = new LocalTransport({ roomId: 'dev', isHost: true });

// Production: TrysteroTransport
const prodTransport = new TrysteroTransport({ roomId: 'prod', appId: 'my-app', isHost: true });

// Use same game code for both!
const runtime = new GameRuntime(game, devTransport, { isHost: true, playerIds: [...] });

5. Log Transport Metrics

if (process.env.NODE_ENV === 'development' && transport.metrics) {
  setInterval(() => {
    const stats = transport.metrics!.getMessageStats();
    const peers = transport.metrics!.getPeerCount();
    const latency = transport.metrics!.getLatencyMs?.() ?? 'N/A';

    console.log(`[Transport] Peers: ${peers}, Sent: ${stats.sent}, Received: ${stats.received}, Latency: ${latency}ms`);
  }, 5000);
}

Common Issues

Issue: “No peers found”

Cause: Room ID mismatch or timing issue

Solution:

// Ensure both host and clients use same roomId
const ROOM_ID = 'my-game-v1';

const transport = new LocalTransport({
  roomId: ROOM_ID,
  isHost: true
});

Issue: WebRTC connection fails

Cause: NAT traversal issues, firewall

Solution: Use STUN/TURN servers

const transport = new TrysteroTransport({
  roomId: 'my-room',
  appId: 'my-app',
  isHost: true,
  config: {
    iceServers: [
      { urls: 'stun:stun.l.google.com:19302' },
      {
        urls: 'turn:turnserver.com:3478',
        username: 'user',
        credential: 'pass'
      }
    ]
  }
});

Issue: Messages not received

Cause: Handler not registered or unsubscribed too early

Solution: Store unsubscribe functions, call on cleanup

const unsub = transport.onMessage(handler);

// Keep reference until cleanup
this.unsubscribes.push(unsub);

Next Steps