Repository: boardgameio/boardgame.io
Pull request #1002 (2021-09-02 22:12:22)

refactor(transport): Consolidate transport interface
This PR refactors the transport interface to simplify the task of implementing new transports. (I’ve developed it out of the attempt at a peer-to-peer transport — I think eventually I’ll publish the P2P transport as a separate package.) This refactor moves various logic common to all transport implementations to the base `Transport` class and moves responsibility for interacting with the client’s Redux store to the client itself, making the transport “thinner”, primarily responsible for passing the master’s output to the client. It also renames some of the class methods to hopefully clarify their purpose.

#### Breaking Changes

If anyone had figured out how to write a custom transport, it will definitely break. I don’t know how likely that is as we don’t export many of the things you’d ideally need access to to create a custom transport.

#### Checklist

- [x] Use a separate branch in your local repo (not `main`).
- [x] Test coverage is 100% (or you have a story for why it's ok).


delucis: Haha, I’m not sure if they were terrible 😄

My guess was just that the original class organically grew beyond its initial scope. I guess I should maybe also rename `subscribe` to `subscribeToConnectionStatus` while I’m at it.

Patch:
diff --git a/examples/react-web/src/index.html b/examples/react-web/src/index.html
index 5007aa4..0fece59 100644
--- a/examples/react-web/src/index.html
+++ b/examples/react-web/src/index.html
@@ -1,6 +1,7 @@
 <!DOCTYPE html>
 <html>
   <head>
+    <meta content="width=device-width,initial-scale=1" name="viewport">
     <link rel="stylesheet" href="//unpkg.com/docsify/lib/themes/vue.css">
   </head>
   <body class="ready sticky">
diff --git a/packages/internal.ts b/packages/internal.ts
index e458b58..2a7dff5 100644
--- a/packages/internal.ts
+++ b/packages/internal.ts
@@ -10,5 +10,13 @@ import { InitializeGame } from '../src/core/initialize';
 import { ProcessGameConfig } from '../src/core/game';
 import { CreateGameReducer } from '../src/core/reducer';
 import { Async, Sync } from '../src/server/db/base';
+import { Transport } from '../src/client/transport/transport';
 
-export { Async, Sync, ProcessGameConfig, InitializeGame, CreateGameReducer };
+export {
+  Async,
+  Sync,
+  Transport,
+  ProcessGameConfig,
+  InitializeGame,
+  CreateGameReducer,
+};
diff --git a/src/client/client.ts b/src/client/client.ts
index 630945d..fc5a976 100644
--- a/src/client/client.ts
+++ b/src/client/client.ts
@@ -23,6 +23,7 @@ import { PlayerView } from '../plugins/main';
 import type { Transport, TransportOpts } from './transport/transport';
 import { DummyTransport } from './transport/dummy';
 import { ClientManager } from './manager';
+import type { TransportData } from '../master/master';
 import type {
   ActivePlayersArg,
   ActionShape,
@@ -182,7 +183,7 @@ export class _ClientImpl<G extends any = any> {
   }: ClientOpts) {
     this.game = ProcessGameConfig(game);
     this.playerID = playerID;
-    this.matchID = matchID;
+    this.matchID = matchID || 'default';
     this.credentials = credentials;
     this.multiplayer = multiplayer;
     this.debugOpt = debug;
@@ -291,7 +292,7 @@ export class _ClientImpl<G extends any = any> {
           !('clientOnly' in action) &&
           action.type !== Actions.STRIP_TRANSIENTS
         ) {
-          this.transport.onAction(baseState, action);
+          this.transport.sendAction(baseState, action);
         }
 
         return result;
@@ -321,9 +322,9 @@ export class _ClientImpl<G extends any = any> {
 
     if (!multiplayer) multiplayer = DummyTransport;
     this.transport = multiplayer({
+      transportDataCallback: (data) => this.receiveTransportData(data),
       gameKey: game,
       game: this.game,
-      store: this.store,
       matchID,
       playerID,
       credentials,
@@ -333,23 +334,77 @@ export class _ClientImpl<G extends any = any> {
 
     this.createDispatchers();
 
-    this.transport.subscribeMatchData((metadata) => {
-      this.matchData = metadata;
-      this.notifySubscribers();
-    });
-
     this.chatMessages = [];
     this.sendChatMessage = (payload) => {
-      this.transport.onChatMessage(this.matchID, {
+      this.transport.sendChatMessage(this.matchID, {
         id: nanoid(7),
         sender: this.playerID,
         payload: payload,
       });
     };
-    this.transport.subscribeChatMessage((message) => {
-      this.chatMessages = [...this.chatMessages, message];
-      this.notifySubscribers();
-    });
+  }
+
+  /** Handle incoming match data from a multiplayer transport. */
+  private receiveMatchData(matchData: FilteredMetadata): void {
+    this.matchData = matchData;
+    this.notifySubscribers();
+  }
+
+  /** Handle an incoming chat message from a multiplayer transport. */
+  private receiveChatMessage(message: ChatMessage): void {
+    this.chatMessages = [...this.chatMessages, message];
+    this.notifySubscribers();
+  }
+
+  /** Handle all incoming updates from a multiplayer transport. */
+  private receiveTransportData(data: TransportData): void {
+    const [matchID] = data.args;
+    if (matchID !== this.matchID) return;
+    switch (data.type) {
+      case 'sync': {
+        const [, syncInfo] = data.args;
+        const action = ActionCreators.sync(syncInfo);
+        this.receiveMatchData(syncInfo.filteredMetadata);
+        this.store.dispatch(action);
+        break;
+      }
+      case 'update': {
+        const [, state, deltalog] = data.args;
+        const currentState = this.store.getState();
+        if (state._stateID >= currentState._stateID) {
+          const action = ActionCreators.update(state, deltalog);
+          this.store.dispatch(action);
+        }
+        break;
+      }
+      case 'patch': {
+        const [, prevStateID, stateID, patch, deltalog] = data.args;
+        const currentStateID = this.store.getState()._stateID;
+        if (prevStateID !== currentStateID) break;
+        const action = ActionCreators.patch(
+          prevStateID,
+          stateID,
+          patch,
+          deltalog
+        );
+        this.store.dispatch(action);
+        // Emit sync if patch apply failed.
+        if (this.store.getState()._stateID === currentStateID) {
+          this.transport.requestSync();
+        }
+        break;
+      }
+      case 'matchData': {
+        const [, matchData] = data.args;
+        this.receiveMatchData(matchData);
+        break;
+      }
+      case 'chat': {
+        const [, chatMessage] = data.args;
+        this.receiveChatMessage(chatMessage);
+        break;
+      }
+    }
   }
 
   private notifySubscribers() {
@@ -376,7 +431,7 @@ export class _ClientImpl<G extends any = any> {
   subscribe(fn: (state: ClientState<G>) => void) {
     const id = Object.keys(this.subscribers).length;
     this.subscribers[id] = fn;
-    this.transport.subscribe(() => this.notifySubscribers());
+    this.transport.subscribeToConnectionStatus(() => this.notifySubscribers());
 
     if (this._running || !this.multiplayer) {
       fn(this.getState());
diff --git a/src/client/transport/dummy.ts b/src/client/transport/dummy.ts
index e383ca3..b8814f0 100644
--- a/src/client/transport/dummy.ts
+++ b/src/client/transport/dummy.ts
@@ -8,11 +8,9 @@ import type { TransportOpts } from './transport';
 class DummyImpl extends Transport {
   connect() {}
   disconnect() {}
-  onAction() {}
-  onChatMessage() {}
-  subscribe() {}
-  subscribeChatMessage() {}
-  subscribeMatchData() {}
+  sendAction() {}
+  sendChatMessage() {}
+  requestSync() {}
   updateCredentials() {}
   updateMatchID() {}
   updatePlayerID() {}
diff --git a/src/client/transport/local.ts b/src/client/transport/local.ts
index d41f4ae..9a64de7 100644
--- a/src/client/transport/local.ts
+++ b/src/client/transport/local.ts
@@ -6,21 +6,18 @@
  * https://opensource.org/licenses/MIT.
  */
 
-import * as ActionCreators from '../../core/action-creators';
 import { InMemory } from '../../server/db/inmemory';
 import { LocalStorage } from '../../server/db/localstorage';
 import { Master } from '../../master/master';
 import type { TransportAPI, TransportData } from '../../master/master';
 import { Transport } from './transport';
-import type { TransportOpts, ChatCallback } from './transport';
+import type { TransportOpts } from './transport';
 import type {
   ChatMessage,
   CredentialedActionShape,
   Game,
-  LogEntry,
   PlayerID,
   State,
-  SyncInfo,
 } from '../../types';
 import { getFilterPlayerView } from '../../master/filter-player-view';
 
@@ -62,7 +59,6 @@ type LocalMasterOpts = LocalOpts & {
  */
 export class LocalMaster extends Master {
   connect: (
-    matchID: string,
     playerID: PlayerID,
     callback: (data: TransportData) => void
   ) => void;
@@ -101,7 +97,7 @@ export class LocalMaster extends Master {
     const storage = persist ? new LocalStorage(storageKey) : new InMemory();
     super(game, storage, transportAPI);
 
-    this.connect = (matchID, playerID, callback) => {
+    this.connect = (playerID, callback) => {
       clientCallbacks[playerID] = callback;
     };
 
@@ -140,7 +136,6 @@ type LocalTransportOpts = TransportOpts & {
  */
 export class LocalTransport extends Transport {
   master: LocalMaster;
-  chatMessageCallback: ChatCallback;
 
   /**
    * Creates a new Mutiplayer instance.
@@ -152,15 +147,9 @@ export class LocalTransport extends Transport {
   constructor({ master, ...opts }: LocalTransportOpts) {
     super(opts);
     this.master = master;
-    this.isConnected = true;
   }
 
-  /**
-   * Called when any player sends a chat message and the
-   * master broadcasts the update to other clients (including
-   * this one).
-   */
-  onChatMessage(matchID: string, chatMessage: ChatMessage) {
+  sendChatMessage(matchID: string, chatMessage: ChatMessage): void {
     const args: Parameters<Master['onChatMessage']> = [
       matchID,
       chatMessage,
@@ -169,53 +158,11 @@ export class LocalTransport extends Transport {
     this.master.onChatMessage(...args);
   }
 
-  /**
-   * Called when another player makes a move and the
-   * master broadcasts the update to other clients (including
-   * this one).
-   */
-  async onUpdate(matchID: string, state: State, deltalog: LogEntry[]) {
-    const currentState = this.store.getState();
-
-    if (matchID == this.matchID && state._stateID >= currentState._stateID) {
-      const action = ActionCreators.update(state, deltalog);
-      this.store.dispatch(action);
-    }
-  }
-
-  /**
-   * Called when the client first connects to the master
-   * and requests the current game state.
-   */
-  onSync(matchID: string, syncInfo: SyncInfo) {
-    if (matchID == this.matchID) {
-      const action = ActionCreators.sync(syncInfo);
-      this.store.dispatch(action);
-    }
-  }
-
-  /**
-   * Called when an action that has to be relayed to the
-   * game master is made.
-   */
-  onAction(state: State, action: CredentialedActionShape.Any) {
+  sendAction(state: State, action: CredentialedActionShape.Any): void {
     this.master.onUpdate(action, state._stateID, this.matchID, this.playerID);
   }
 
-  /**
-   * Connect to the master.
-   */
-  connect() {
-    this.master.connect(this.matchID, this.playerID, (data) => {
-      switch (data.type) {
-        case 'sync':
-          return this.onSync(...data.args);
-        case 'update':
-          return this.onUpdate(...data.args);
-        case 'chat':
-          return this.chatMessageCallback(data.args[1]);
-      }
-    });
+  requestSync(): void {
     this.master.onSync(
       this.matchID,
       this.playerID,
@@ -224,56 +171,29 @@ export class LocalTransport extends Transport {
     );
   }
 
-  /**
-   * Disconnect from the master.
-   */
-  disconnect() {}
-
-  /**
-   * Subscribe to connection state changes.
-   */
-  subscribe() {}
-
-  subscribeMatchData() {}
-
-  subscribeChatMessage(fn: ChatCallback) {
-    this.chatMessageCallback = fn;
+  connect(): void {
+    this.setConnectionStatus(true);
+    this.master.connect(this.playerID, (data) => this.notifyClient(data));
+    this.requestSync();
   }
 
-  /**
-   * Dispatches a reset action, then requests a fresh sync from the master.
-   */
-  private resetAndSync() {
-    const action = ActionCreators.reset(null);
-    this.store.dispatch(action);
-    this.connect();
+  disconnect(): void {
+    this.setConnectionStatus(false);
   }
 
-  /**
-   * Updates the game id.
-   * @param {string} id - The new game id.
-   */
-  updateMatchID(id: string) {
+  updateMatchID(id: string): void {
     this.matchID = id;
-    this.resetAndSync();
+    this.connect();
   }
 
-  /**
-   * Updates the player associated with this client.
-   * @param {string} id - The new player id.
-   */
-  updatePlayerID(id: PlayerID) {
+  updatePlayerID(id: PlayerID): void {
     this.playerID = id;
-    this.resetAndSync();
+    this.connect();
   }
 
-  /**
-   * Updates the credentials associated with this client.
-   * @param {string|undefined} credentials - The new credentials to use.
-   */
-  updateCredentials(credentials?: string) {
+  updateCredentials(credentials?: string): void {
     this.credentials = credentials;
-    this.resetAndSync();
+    this.connect();
   }
 }
 
diff --git a/src/client/transport/socketio.ts b/src/client/transport/socketio.ts
index 199dca1..b48c7e4 100644
--- a/src/client/transport/socketio.ts
+++ b/src/client/transport/socketio.ts
@@ -10,15 +10,10 @@ import * as ioNamespace from 'socket.io-client';
 
 const io = ioNamespace.default;
 
-import * as ActionCreators from '../../core/action-creators';
 import type { Master } from '../../master/master';
 import { Transport } from './transport';
 import type { Operation } from 'rfc6902';
-import type {
-  TransportOpts,
-  MetadataCallback,
-  ChatCallback,
-} from './transport';
+import type { TransportOpts } from './transport';
 import type {
   CredentialedActionShape,
   FilteredMetadata,
@@ -48,9 +43,6 @@ export class SocketIOTransport extends Transport {
   server: string;
   socket: ioNamespace.Socket;
   socketOpts: ioNamespace.SocketOptions;
-  callback: () => void;
-  matchDataCallback: MetadataCallback;
-  chatMessageCallback: ChatCallback;
 
   /**
    * Creates a new Multiplayer instance.
@@ -64,28 +56,15 @@ export class SocketIOTransport extends Transport {
    * @param {string} numPlayers - The number of players.
    * @param {string} server - The game server in the form of 'hostname:port'. Defaults to the server serving the client if not provided.
    */
-  constructor({
-    socket,
-    socketOpts,
-    server,
-    ...opts
-  }: SocketIOTransportOpts = {}) {
+  constructor({ socket, socketOpts, server, ...opts }: SocketIOTransportOpts) {
     super(opts);
 
     this.server = server;
     this.socket = socket;
     this.socketOpts = socketOpts;
-    this.isConnected = false;
-    this.callback = () => {};
-    this.matchDataCallback = () => {};
-    this.chatMessageCallback = () => {};
   }
 
-  /**
-   * Called when an action that has to be relayed to the
-   * game master is made.
-   */
-  onAction(state: State, action: CredentialedActionShape.Any) {
+  sendAction(state: State, action: CredentialedActionShape.Any): void {
     const args: Parameters<Master['onUpdate']> = [
       action,
       state._stateID,
@@ -95,7 +74,7 @@ export class SocketIOTransport extends Transport {
     this.socket.emit('update', ...args);
   }
 
-  onChatMessage(matchID: string, chatMessage: ChatMessage) {
+  sendChatMessage(matchID: string, chatMessage: ChatMessage): void {
     const args: Parameters<Master['onChatMessage']> = [
       matchID,
       chatMessage,
@@ -104,10 +83,7 @@ export class SocketIOTransport extends Transport {
     this.socket.emit('chat', ...args);
   }
 
-  /**
-   * Connect to the server.
-   */
-  connect() {
+  connect(): void {
     if (!this.socket) {
       if (this.server) {
         let server = this.server;
@@ -136,20 +112,10 @@ export class SocketIOTransport extends Transport {
         patch: Operation[],
         deltalog: LogEntry[]
       ) => {
-        const currentStateID = this.store.getState()._stateID;
-        if (matchID === this.matchID && prevStateID === currentStateID) {
-          const action = ActionCreators.patch(
-            prevStateID,
-            stateID,
-            patch,
-            deltalog
-          );
-          this.store.dispatch(action);
-          // emit sync if patch apply failed
-          if (this.store.getState()._stateID === currentStateID) {
-            this.sync();
-          }
-        }
+        this.notifyClient({
+          type: 'patch',
+          args: [matchID, prevStateID, stateID, patch, deltalog],
+        });
       }
     );
 
@@ -159,26 +125,17 @@ export class SocketIOTransport extends Transport {
     this.socket.on(
       'update',
       (matchID: string, state: State, deltalog: LogEntry[]) => {
-        const currentState = this.store.getState();
-
-        if (
-          matchID == this.matchID &&
-          state._stateID >= currentState._stateID
-        ) {
-          const action = ActionCreators.update(state, deltalog);
-          this.store.dispatch(action);
-        }
+        this.notifyClient({
+          type: 'update',
+          args: [matchID, state, deltalog],
+        });
       }
     );
 
     // Called when the client first connects to the master
     // and requests the current game state.
     this.socket.on('sync', (matchID: string, syncInfo: SyncInfo) => {
-      if (matchID == this.matchID) {
-        const action = ActionCreators.sync(syncInfo);
-        this.matchDataCallback(syncInfo.filteredMetadata);
-        this.store.dispatch(action);
-      }
+      this.notifyClient({ type: 'sync', args: [matchID, syncInfo] });
     });
 
     // Called when new player joins the match or changes
@@ -186,60 +143,32 @@ export class SocketIOTransport extends Transport {
     this.socket.on(
       'matchData',
       (matchID: string, matchData: FilteredMetadata) => {
-        if (matchID == this.matchID) {
-          this.matchDataCallback(matchData);
-        }
+        this.notifyClient({ type: 'matchData', args: [matchID, matchData] });
       }
     );
 
     this.socket.on('chat', (matchID: string, chatMessage: ChatMessage) => {
-      if (matchID === this.matchID) {
-        this.chatMessageCallback(chatMessage);
-      }
+      this.notifyClient({ type: 'chat', args: [matchID, chatMessage] });
     });
 
     // Keep track of connection status.
     this.socket.on('connect', () => {
       // Initial sync to get game state.
-      this.sync();
-      this.isConnected = true;
-      this.callback();
+      this.requestSync();
+      this.setConnectionStatus(true);
     });
     this.socket.on('disconnect', () => {
-      this.isConnected = false;
-      this.callback();
+      this.setConnectionStatus(false);
     });
   }
 
-  /**
-   * Disconnect from the server.
-   */
-  disconnect() {
+  disconnect(): void {
     this.socket.close();
     this.socket = null;
-    this.isConnected = false;
-    this.callback();
-  }
-
-  /**
-   * Subscribe to connection state changes.
-   */
-  subscribe(fn: () => void) {
-    this.callback = fn;
+    this.setConnectionStatus(false);
   }
 
-  subscribeMatchData(fn: MetadataCallback) {
-    this.matchDataCallback = fn;
-  }
-
-  subscribeChatMessage(fn: ChatCallback) {
-    this.chatMessageCallback = fn;
-  }
-
-  /**
-   * Send a “sync” event to the server.
-   */
-  private sync() {
+  requestSync(): void {
     if (this.socket) {
       const args: Parameters<Master['onSync']> = [
         this.matchID,
@@ -251,40 +180,19 @@ export class SocketIOTransport extends Transport {
     }
   }
 
-  /**
-   * Dispatches a reset action, then requests a fresh sync from the server.
-   */
-  private resetAndSync() {
-    const action = ActionCreators.reset(null);
-    this.store.dispatch(action);
-    this.sync();
-  }
-
-  /**
-   * Updates the game id.
-   * @param {string} id - The new game id.
-   */
-  updateMatchID(id: string) {
+  updateMatchID(id: string): void {
     this.matchID = id;
-    this.resetAndSync();
+    this.requestSync();
   }
 
-  /**
-   * Updates the player associated with this client.
-   * @param {string} id - The new player id.
-   */
-  updatePlayerID(id: PlayerID) {
+  updatePlayerID(id: PlayerID): void {
     this.playerID = id;
-    this.resetAndSync();
+    this.requestSync();
   }
 
-  /**
-   * Updates the credentials associated with this client.
-   * @param {string|undefined} credentials - The new credentials to use.
-   */
-  updateCredentials(credentials?: string) {
+  updateCredentials(credentials?: string): void {
     this.credentials = credentials;
-    this.resetAndSync();
+    this.requestSync();
   }
 }
 
diff --git a/src/client/transport/transport.ts b/src/client/transport/transport.ts
index 1e3e8f6..12c6f4f 100644
--- a/src/client/transport/transport.ts
+++ b/src/client/transport/transport.ts
@@ -7,12 +7,12 @@
  */
 
 import type { ProcessGameConfig } from '../../core/game';
+import type { TransportData } from '../../master/master';
 import type {
   Game,
   PlayerID,
   CredentialedActionShape,
   State,
-  Store,
   SyncInfo,
   ChatMessage,
 } from '../../types';
@@ -22,7 +22,7 @@ export type MetadataCallback = (metadata: SyncInfo['filteredMetadata']) => void;
 export type ChatCallback = (message: ChatMessage) => void;
 
 export interface TransportOpts {
-  store?: Store;
+  transportDataCallback: (data: TransportData) => void;
   gameName?: string;
   gameKey?: Game;
   game?: ReturnType<typeof ProcessGameConfig>;
@@ -33,23 +33,26 @@ export interface TransportOpts {
 }
 
 export abstract class Transport {
-  protected store: Store;
   protected gameName: string;
   protected playerID: PlayerID | null;
   protected matchID: string;
   protected credentials?: string;
   protected numPlayers: number;
-  isConnected: boolean;
+  /** Callback to pass transport data back to the client. */
+  private transportDataCallback: (data: TransportData) => void;
+  /** Callback to let the client know when the connection status has changed. */
+  private connectionStatusCallback: () => void = () => {};
+  isConnected = false;
 
   constructor({
-    store,
+    transportDataCallback,
     gameName,
     playerID,
     matchID,
     credentials,
     numPlayers,
   }: TransportOpts) {
-    this.store = store;
+    this.transportDataCallback = transportDataCallback;
     this.gameName = gameName || 'default';
     this.playerID = playerID || null;
     this.matchID = matchID || 'default';
@@ -57,14 +60,36 @@ export abstract class Transport {
     this.numPlayers = numPlayers || 2;
   }
 
-  abstract onAction(state: State, action: CredentialedActionShape.Any): void;
+  /** Subscribe to connection state changes. */
+  subscribeToConnectionStatus(fn: () => void): void {
+    this.connectionStatusCallback = fn;
+  }
+
+  /** Transport implementations should call this when they connect/disconnect. */
+  protected setConnectionStatus(isConnected: boolean): void {
+    this.isConnected = isConnected;
+    this.connectionStatusCallback();
+  }
+
+  /** Transport implementations should call this when they receive data from a master. */
+  protected notifyClient(data: TransportData): void {
+    this.transportDataCallback(data);
+  }
+
+  /** Called by the client to connect the transport. */
   abstract connect(): void;
+  /** Called by the client to disconnect the transport. */
   abstract disconnect(): void;
-  abstract subscribe(fn: () => void): void;
-  abstract subscribeMatchData(fn: MetadataCallback): void;
+  /** Called by the client to dispatch an action via the transport. */
+  abstract sendAction(state: State, action: CredentialedActionShape.Any): void;
+  /** Called by the client to dispatch a chat message via the transport. */
+  abstract sendChatMessage(matchID: string, chatMessage: ChatMessage): void;
+  /** Called by the client to request a sync action from the transport. */
+  abstract requestSync(): void;
+  /** Called by the client to update the matchID it wants to connect to. */
   abstract updateMatchID(id: string): void;
+  /** Called by the client to update the playerID it is playing as. */
   abstract updatePlayerID(id: PlayerID): void;
+  /** Called by the client to update the credentials it is using. */
   abstract updateCredentials(credentials?: string): void;
-  abstract onChatMessage(matchID: string, chatMessage: ChatMessage): void;
-  abstract subscribeChatMessage(fn: ChatCallback): void;
 }
diff --git a/src/master/master.ts b/src/master/master.ts
index 229a979..a8fb1cc 100644
--- a/src/master/master.ts
+++ b/src/master/master.ts
@@ -54,6 +54,9 @@ type CallbackFn = (arg: {
   action?: ActionShape.Any | CredentialedActionShape.Any;
 }) => void;
 
+/**
+ * Data types that are shared across `TransportData` and `IntermediateTransportData`.
+ */
 type CommonTransportData =
   | {
       type: 'sync';
@@ -68,6 +71,10 @@ type CommonTransportData =
       args: [string, ChatMessage];
     };
 
+/**
+ * Final shape of data sent by the transport API
+ * to be received by clients/client transports.
+ */
 export type TransportData =
   | {
       type: 'update';
@@ -79,6 +86,10 @@ export type TransportData =
     }
   | CommonTransportData;
 
+/**
+ * Data type sent by a master to its transport API. The transport then transforms
+ * this into `TransportData` for each individual player it forwards it to.
+ */
 export type IntermediateTransportData =
   | {
       type: 'update';
@@ -90,6 +101,7 @@ export type IntermediateTransportData =
     }
   | CommonTransportData;
 
+/** API used by a master to emit data to any connected clients/client transports. */
 export interface TransportAPI {
   send: (
     playerData: { playerID: PlayerID } & IntermediateTransportData
diff --git a/src/types.ts b/src/types.ts
index b9837a8..9fe321d 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -481,6 +481,7 @@ export namespace ActionPayload {
 export type FilteredMetadata = {
   id: number;
   name?: string;
+  isConnected?: boolean;
 }[];
 
 export interface SyncInfo {


Test patch:
diff --git a/src/client/client.test.ts b/src/client/client.test.ts
index 743269e..cfc9630 100644
--- a/src/client/client.test.ts
+++ b/src/client/client.test.ts
@@ -12,7 +12,7 @@ import { CreateGameReducer } from '../core/reducer';
 import { InitializeGame } from '../core/initialize';
 import { Client, createMoveDispatchers } from './client';
 import { ProcessGameConfig } from '../core/game';
-import type { Transport } from './transport/transport';
+import { Transport } from './transport/transport';
 import { LocalTransport, Local } from './transport/local';
 import { SocketIOTransport, SocketIO } from './transport/socketio';
 import {
@@ -27,6 +27,7 @@ import Debug from './debug/Debug.svelte';
 import { error } from '../core/logger';
 import type { LogEntry, State, SyncInfo } from '../types';
 import type { Operation } from 'rfc6902';
+import type { TransportData } from '../master/master';
 
 jest.mock('../core/logger', () => ({
   info: jest.fn(),
@@ -166,35 +167,35 @@ describe('multiplayer', () => {
     });
 
     test('onAction called', () => {
-      jest.spyOn(client.transport, 'onAction');
+      jest.spyOn(client.transport, 'sendAction');
       const state = { G: {}, ctx: { phase: '' }, plugins: {} };
       const filteredMetadata = [];
       client.store.dispatch(sync({ state, filteredMetadata } as SyncInfo));
       client.moves.A();
-      expect(client.transport.onAction).toHaveBeenCalled();
+      expect(client.transport.sendAction).toHaveBeenCalled();
     });
 
     test('strip transients action not sent to transport', () => {
-      jest.spyOn(client.transport, 'onAction');
+      jest.spyOn(client.transport, 'sendAction');
       const state = { G: {}, ctx: { phase: '' }, plugins: {} };
       const filteredMetadata = [];
       client.store.dispatch(sync({ state, filteredMetadata } as SyncInfo));
       client.moves.Invalid();
-      expect(client.transport.onAction).not.toHaveBeenCalledWith(
+      expect(client.transport.sendAction).not.toHaveBeenCalledWith(
         expect.any(Object),
         { type: Actions.STRIP_TRANSIENTS }
       );
     });
 
     test('Sends and receives chat messages', () => {
-      jest.spyOn(client.transport, 'onAction');
+      jest.spyOn(client.transport, 'sendAction');
       client.updatePlayerID('0');
       client.updateMatchID('matchID');
-      jest.spyOn(client.transport, 'onChatMessage');
+      jest.spyOn(client.transport, 'sendChatMessage');
 
       client.sendChatMessage({ message: 'foo' });
 
-      expect(client.transport.onChatMessage).toHaveBeenCalledWith(
+      expect(client.transport.sendChatMessage).toHaveBeenCalledWith(
         'matchID',
         expect.objectContaining({ payload: { message: 'foo' }, sender: '0' })
       );
@@ -290,20 +291,20 @@ describe('multiplayer', () => {
   });
 
   describe('custom transport', () => {
-    class CustomTransport {
-      callback;
-
-      constructor() {
-        this.callback = null;
-      }
-
-      subscribeMatchData(fn) {
-        this.callback = fn;
+    class CustomTransport extends Transport {
+      connect() {}
+      disconnect() {}
+      sendAction() {}
+      sendChatMessage() {}
+      requestSync() {}
+      updateMatchID() {}
+      updatePlayerID() {}
+      updateCredentials() {}
+      setMetadata(metadata) {
+        this.notifyClient({ type: 'matchData', args: ['default', metadata] });
       }
-
-      subscribeChatMessage() {}
     }
-    const customTransport = () => new CustomTransport() as unknown as Transport;
+    const customTransport = (opts) => new CustomTransport(opts);
 
     let client;
 
@@ -320,12 +321,121 @@ describe('multiplayer', () => {
 
     test('metadata callback', () => {
       const metadata = { m: true };
-      client.transport.callback(metadata);
+      client.transport.setMetadata(metadata);
       expect(client.matchData).toEqual(metadata);
     });
   });
 });
 
+describe('receiveTransportData', () => {
+  let sendToClient: (data: TransportData) => void;
+  let client: ReturnType<typeof Client>;
+  let requestSync: jest.Mock;
+
+  beforeEach(() => {
+    requestSync = jest.fn();
+    client = Client({
+      game: {},
+      matchID: 'A',
+      debug: false,
+      // Use the multiplayer interface to extract the client callback
+      // and use it to send updates to the client directly.
+      multiplayer: ({ transportDataCallback }) => {
+        sendToClient = transportDataCallback;
+        return {
+          connect() {},
+          disconnect() {},
+          subscribe() {},
+          requestSync,
+        } as unknown as Transport;
+      },
+    });
+    client.start();
+  });
+
+  afterEach(() => {
+    client.stop();
+  });
+
+  test('discards update with wrong matchID', () => {
+    sendToClient({
+      type: 'sync',
+      args: ['wrongID', { state: { G: 'G', ctx: {} } } as SyncInfo],
+    });
+    expect(client.getState()).toBeNull();
+  });
+
+  test('applies sync', () => {
+    const state = { G: 'G', ctx: {} };
+    sendToClient({ type: 'sync', args: ['A', { state } as SyncInfo] });
+    expect(client.getState().G).toEqual(state.G);
+  });
+
+  test('applies update', () => {
+    const state1 = { G: 'G1', _stateID: 1, ctx: {} } as State;
+    const state2 = { G: 'G2', _stateID: 2, ctx: {} } as State;
+    sendToClient({ type: 'sync', args: ['A', { state: state1 } as SyncInfo] });
+    sendToClient({ type: 'update', args: ['A', state2, []] });
+    expect(client.getState().G).toEqual(state2.G);
+  });
+
+  test('ignores stale update', () => {
+    const state1 = { G: 'G1', _stateID: 1, ctx: {} } as State;
+    const state2 = { G: 'G2', _stateID: 0, ctx: {} } as State;
+    sendToClient({ type: 'sync', args: ['A', { state: state1 } as SyncInfo] });
+    sendToClient({ type: 'update', args: ['A', state2, []] });
+    expect(client.getState().G).toEqual(state1.G);
+  });
+
+  test('applies a patch', () => {
+    const state = { G: 'G1', _stateID: 1, ctx: {} } as State;
+    sendToClient({ type: 'sync', args: ['A', { state } as SyncInfo] });
+    sendToClient({
+      type: 'patch',
+      args: ['A', 1, 2, [{ op: 'replace', path: '/_stateID', value: 2 }], []],
+    });
+    expect(client.getState()._stateID).toBe(2);
+  });
+
+  test('ignores patch for different state ID', () => {
+    const state = { G: 'G1', _stateID: 1, ctx: {} } as State;
+    sendToClient({ type: 'sync', args: ['A', { state } as SyncInfo] });
+    sendToClient({
+      type: 'patch',
+      args: ['A', 2, 3, [{ op: 'replace', path: '/_stateID', value: 3 }], []],
+    });
+    expect(client.getState()._stateID).toBe(1);
+  });
+
+  test('resyncs after failed patch', () => {
+    const state = { G: 'G1', _stateID: 1, ctx: {} } as State;
+    sendToClient({ type: 'sync', args: ['A', { state } as SyncInfo] });
+    expect(requestSync).not.toHaveBeenCalled();
+    // Send bad patch.
+    sendToClient({
+      type: 'patch',
+      args: ['A', 1, 2, [{ op: 'replace', path: '/_stateIDD', value: 2 }], []],
+    });
+    // State is unchanged and the client requested to resync.
+    expect(client.getState()._stateID).toBe(1);
+    expect(requestSync).toHaveBeenCalled();
+  });
+
+  test('updates match metadata', () => {
+    expect(client.matchData).toBeUndefined();
+    const matchData = [{ id: 0 }];
+    sendToClient({ type: 'matchData', args: ['A', matchData] });
+    expect(client.matchData).toEqual(matchData);
+  });
+
+  test('appends a chat message', () => {
+    expect(client.chatMessages).toEqual([]);
+    const message = { id: 'x', sender: '0', payload: 'hi' };
+    sendToClient({ type: 'chat', args: ['A', message] });
+    expect(client.chatMessages).toEqual([message]);
+  });
+});
+
 describe('strip secret only on server', () => {
   let client0;
   let client1;
@@ -771,7 +881,7 @@ describe('subscribe', () => {
     client.subscribe(fn);
     client.start();
     fn.mockClear();
-    transport.callback();
+    (transport as any).connectionStatusCallback();
     expect(fn).toHaveBeenCalled();
     client.stop();
   });
diff --git a/src/client/transport/local.test.ts b/src/client/transport/local.test.ts
index 2ee3ece..961a6cb 100644
--- a/src/client/transport/local.test.ts
+++ b/src/client/transport/local.test.ts
@@ -6,16 +6,13 @@
  * https://opensource.org/licenses/MIT.
  */
 
-import { createStore } from 'redux';
 import { LocalTransport, LocalMaster, Local, GetBotPlayer } from './local';
-import { makeMove, gameEvent } from '../../core/action-creators';
-import { CreateGameReducer } from '../../core/reducer';
+import { gameEvent } from '../../core/action-creators';
 import { ProcessGameConfig } from '../../core/game';
-import { InitializeGame } from '../../core/initialize';
 import { Client } from '../client';
 import { RandomBot } from '../../ai/random-bot';
 import { Stage } from '../../core/turn-order';
-import type { ChatMessage, Game, State, Store, SyncInfo } from '../../types';
+import type { Game, State } from '../../types';
 
 const sleep = (ms = 500) => new Promise((resolve) => setTimeout(resolve, ms));
 
@@ -132,8 +129,16 @@ describe('Local', () => {
   test('transports for same game use shared master', () => {
     const gameKey = {};
     const game = ProcessGameConfig(gameKey);
-    const transport1 = Local()({ game, gameKey });
-    const transport2 = Local()({ game, gameKey });
+    const transport1 = Local()({
+      game,
+      gameKey,
+      transportDataCallback: () => {},
+    });
+    const transport2 = Local()({
+      game,
+      gameKey,
+      transportDataCallback: () => {},
+    });
     expect(transport1.master).toBe(transport2.master);
   });
 
@@ -141,16 +146,32 @@ describe('Local', () => {
     const gameKey = {};
     const game = ProcessGameConfig(gameKey);
     const bots = {};
-    const transport1 = Local({ bots })({ game, gameKey });
-    const transport2 = Local({ bots })({ game, gameKey });
+    const transport1 = Local({ bots })({
+      game,
+      gameKey,
+      transportDataCallback: () => {},
+    });
+    const transport2 = Local({ bots })({
+      game,
+      gameKey,
+      transportDataCallback: () => {},
+    });
     expect(transport1.master).toBe(transport2.master);
   });
 
   test('transports use different master for different bots', () => {
     const gameKey = {};
     const game = ProcessGameConfig(gameKey);
-    const transport1 = Local({ bots: {} })({ game, gameKey });
-    const transport2 = Local({ bots: {} })({ game, gameKey });
+    const transport1 = Local({ bots: {} })({
+      game,
+      gameKey,
+      transportDataCallback: () => {},
+    });
+    const transport2 = Local({ bots: {} })({
+      game,
+      gameKey,
+      transportDataCallback: () => {},
+    });
     expect(transport1.master).not.toBe(transport2.master);
   });
 
@@ -204,71 +225,45 @@ describe('Local', () => {
 });
 
 describe('LocalMaster', () => {
-  const game = {};
-  const master = new LocalMaster({ game });
-
-  const storeA = {
-    dispatch: jest.fn(),
-    getState: () => ({ _stateID: 0 }),
-  } as unknown as Store;
-  const storeB = {
-    dispatch: jest.fn(),
-    getState: () => ({ _stateID: 0 }),
-  } as unknown as Store;
-
-  const localA = new LocalTransport({ master, store: storeA, playerID: '0' });
-  const localB = new LocalTransport({ master, store: storeB, playerID: '1' });
+  let master: LocalMaster;
+  let player0Callback: jest.Mock;
+  let player1Callback: jest.Mock;
 
   beforeEach(() => {
-    storeA.dispatch = jest.fn();
-    storeB.dispatch = jest.fn();
+    master = new LocalMaster({ game: {} });
+    player0Callback = jest.fn();
+    player1Callback = jest.fn();
+    master.connect('0', player0Callback);
+    master.connect('1', player1Callback);
+    master.onSync('matchID', '0');
+    master.onSync('matchID', '1');
   });
 
-  test('connect', () => {
-    localA.connect();
-    localB.connect();
-    localA.subscribe();
-
-    expect(storeA.dispatch).toBeCalledWith(
-      expect.objectContaining({
-        type: 'SYNC',
-      })
+  test('sync', () => {
+    expect(player0Callback).toHaveBeenCalledWith(
+      expect.objectContaining({ type: 'sync' })
     );
-    expect(storeB.dispatch).toBeCalledWith(
-      expect.objectContaining({
-        type: 'SYNC',
-      })
+    expect(player1Callback).toHaveBeenCalledWith(
+      expect.objectContaining({ type: 'sync' })
     );
   });
 
   test('update', () => {
-    localA.onAction({ _stateID: 0 } as unknown as State, gameEvent('endTurn'));
-
-    expect(storeA.dispatch).toBeCalledWith(
-      expect.objectContaining({
-        type: 'UPDATE',
-      })
+    master.onUpdate(gameEvent('endTurn'), 0, 'matchID', '0');
+    expect(player0Callback).toBeCalledWith(
+      expect.objectContaining({ type: 'update' })
     );
-    expect(storeB.dispatch).toBeCalledWith(
-      expect.objectContaining({
-        type: 'UPDATE',
-      })
+    expect(player1Callback).toBeCalledWith(
+      expect.objectContaining({ type: 'update' })
     );
   });
 
   test('connect without callback', () => {
     expect(() => {
-      master.connect('matchID', '0', undefined);
+      master.connect('0', undefined);
       master.onSync('matchID', '0');
     }).not.toThrow();
   });
-
-  test('disconnect', () => {
-    expect(() => {
-      localA.disconnect();
-      localB.disconnect();
-    }).not.toThrow();
-  });
 });
 
 describe('LocalTransport', () => {
@@ -277,7 +272,6 @@ describe('LocalTransport', () => {
       connect: jest.fn(),
       onSync: jest.fn(),
     } as unknown as LocalMaster;
-    const store = { dispatch: () => {} } as unknown as Store;
     class WrappedLocalTransport extends LocalTransport {
       getMatchID() {
         return this.matchID;
@@ -286,94 +280,72 @@ describe('LocalTransport', () => {
         return this.playerID;
       }
     }
-    const m = new WrappedLocalTransport({ master, store });
+    const transport = new WrappedLocalTransport({
+      master,
+      transportDataCallback: () => {},
+    });
+    jest.spyOn(transport, 'requestSync');
 
     beforeEach(() => {
       jest.resetAllMocks();
     });
 
     test('matchID', () => {
-      m.updateMatchID('test');
-      expect(m.getMatchID()).toBe('test');
-      expect(master.connect).toBeCalled();
+      transport.updateMatchID('test');
+      expect(transport.getMatchID()).toBe('test');
+      expect(transport.requestSync).toBeCalled();
     });
 
     test('playerID', () => {
-      m.updatePlayerID('player');
-      expect(m.getPlayerID()).toBe('player');
+      transport.updatePlayerID('player');
+      expect(transport.getPlayerID()).toBe('player');
       expect(master.connect).toBeCalled();
     });
   });
 
   describe('multiplayer', () => {
-    const master = {
-      onSync: jest.fn(),
-      onUpdate: jest.fn(),
-      onChatMessage: jest.fn(),
-    } as unknown as LocalMaster;
-    class WrappedLocalTransport extends LocalTransport {
-      setStore(store: Store) {
-        this.store = store;
-      }
-    }
-    const m = new WrappedLocalTransport({ master });
-    const game = {};
-    let store: Store | null = null;
-
-    beforeEach(() => {
-      const reducer = CreateGameReducer({ game });
-      const initialState = InitializeGame({ game });
-      store = createStore(reducer, initialState);
-      m.setStore(store);
+    const game: Game = {
+      setup: () => ({ initial: true }),
+      moves: {
+        A: () => ({ A: true }),
+      },
+    };
+    const multiplayer = Local();
+    const matchID = 'local-multiplayer';
+    const client1 = Client({ game, multiplayer, matchID, playerID: '0' });
+    const client2 = Client({ game, multiplayer, matchID, playerID: '1' });
+
+    beforeAll(() => {
+      client1.start();
+      client2.start();
     });
 
-    test('returns a valid store', () => {
-      expect(store).not.toBe(undefined);
+    afterAll(() => {
+      client1.stop();
+      client2.stop();
     });
 
-    test('receive update', () => {
-      const restored = { restore: true } as unknown as State;
-      expect(store.getState()).not.toMatchObject(restored);
-      m.onUpdate('unknown matchID', restored, []);
-      expect(store.getState()).not.toMatchObject(restored);
-      m.onUpdate('default', restored, []);
-      expect(store.getState()).not.toMatchObject(restored);
-
-      // Only if the stateID is not stale.
-      restored._stateID = 1;
-      m.onUpdate('default', restored, []);
-      expect(store.getState()).toMatchObject(restored);
+    test('send/receive update', () => {
+      expect(client1.getState().G).toStrictEqual({ initial: true });
+      expect(client2.getState().G).toStrictEqual({ initial: true });
+      client1.moves.A();
+      expect(client1.getState().G).toStrictEqual({ A: true });
+      expect(client2.getState().G).toStrictEqual({ A: true });
     });
 
     test('receive sync', () => {
-      const restored = { restore: true } as unknown as State;
-      expect(store.getState()).not.toMatchObject(restored);
-      m.onSync('unknown matchID', { state: restored } as SyncInfo);
-      expect(store.getState()).not.toMatchObject(restored);
-      m.onSync('default', { state: restored } as SyncInfo);
-      expect(store.getState()).toMatchObject(restored);
-    });
-
-    test('send update', () => {
-      const action = makeMove('move');
-      const state = { _stateID: 0 } as unknown as State;
-      m.onAction(state, action);
-      expect(m.master.onUpdate).lastCalledWith(
-        action,
-        state._stateID,
-        'default',
-        null
-      );
+      const newClient = Client({ game, multiplayer, matchID });
+      newClient.start();
+      expect(newClient.getState().G).toStrictEqual({ A: true });
+      newClient.stop();
     });
 
     test('send chat-message', () => {
-      const msg: ChatMessage = {
-        id: '0',
-        sender: '0',
-        payload: { message: 'foo' },
-      };
-      m.onChatMessage('matchID', msg);
-      expect(m.master.onChatMessage).lastCalledWith('matchID', msg, undefined);
+      const payload = { message: 'foo' };
+      client1.sendChatMessage(payload);
+      expect(client2.chatMessages).toStrictEqual([
+        { id: expect.any(String), sender: '0', payload },
+      ]);
     });
   });
 });
diff --git a/src/client/transport/socketio.test.ts b/src/client/transport/socketio.test.ts
index b78042f..1f8dfc1 100644
--- a/src/client/transport/socketio.test.ts
+++ b/src/client/transport/socketio.test.ts
@@ -6,16 +6,11 @@
  * https://opensource.org/licenses/MIT.
  */
 
-import { createStore } from 'redux';
 import { SocketIOTransport } from './socketio';
 import type * as ioNamespace from 'socket.io-client';
 import { makeMove } from '../../core/action-creators';
-import { CreateGameReducer } from '../../core/reducer';
-import { InitializeGame } from '../../core/initialize';
-import * as Actions from '../../core/action-types';
 import type { Master } from '../../master/master';
-import type { ChatMessage, State, Store } from '../../types';
-import { error } from '../../core/logger';
+import type { ChatMessage, State } from '../../types';
 
 jest.mock('../../core/logger', () => ({
   info: jest.fn(),
@@ -46,9 +41,9 @@ class MockSocket {
 }
 
 test('defaults', () => {
-  const m = new SocketIOTransport();
-  expect(typeof m.callback).toBe('function');
-  m.callback();
+  const m = new SocketIOTransport({ transportDataCallback: () => {} });
+  expect(typeof (m as any).connectionStatusCallback).toBe('function');
+  (m as any).connectionStatusCallback();
 });
 
 class TransportAdapter extends SocketIOTransport {
@@ -67,16 +62,11 @@ class TransportAdapter extends SocketIOTransport {
   getCredentials() {
     return this.credentials;
   }
-
-  setStore(store: Store | { dispatch: () => void }) {
-    this.store = store as Store;
-  }
 }
 
 describe('update matchID / playerID / credentials', () => {
   const socket = new MockSocket();
-  const m = new TransportAdapter({ socket });
-  m.setStore({ dispatch: () => {} });
+  const m = new TransportAdapter({ socket, transportDataCallback: () => {} });
 
   beforeEach(() => (socket.emit = jest.fn()));
 
@@ -116,8 +106,9 @@ describe('connection status', () => {
       playerID: '0',
       gameName: 'foo',
       numPlayers: 2,
+      transportDataCallback: () => {},
     });
-    m.subscribe(onChangeMock);
+    m.subscribeToConnectionStatus(onChangeMock);
     m.connect();
   });
 
@@ -139,75 +130,68 @@ describe('connection status', () => {
     m.disconnect();
     expect(m.isConnected).toBe(false);
   });
+
+  test('doesn’t crash if syncing before connecting', () => {
+    const transportDataCallback = jest.fn();
+    const transport = new SocketIOTransport({ transportDataCallback });
+    transport.requestSync();
+    expect(transportDataCallback).not.toHaveBeenCalled();
+  });
 });
 
 describe('multiplayer', () => {
   const mockSocket = new MockSocket();
-  const m = new TransportAdapter({ socket: mockSocket });
-  m.connect();
-  const game = {};
-  let store = null;
-
-  beforeEach(() => {
-    const reducer = CreateGameReducer({ game });
-    const initialState = InitializeGame({ game });
-    store = createStore(reducer, initialState);
-    m.setStore(store);
+  const transportDataCallback = jest.fn();
+  const transport = new TransportAdapter({
+    socket: mockSocket,
+    transportDataCallback,
   });
+  transport.connect();
 
-  test('returns a valid store', () => {
-    expect(store).not.toBe(undefined);
-  });
+  beforeEach(jest.clearAllMocks);
 
   test('receive update', () => {
     const restored: { restore: boolean; _stateID?: number } = { restore: true };
-    expect(store.getState()).not.toMatchObject(restored);
-    mockSocket.receive('update', 'unknown matchID', restored);
-    expect(store.getState()).not.toMatchObject(restored);
-    mockSocket.receive('update', 'default', restored);
-    expect(store.getState()).not.toMatchObject(restored);
-
-    // Only if the stateID is not stale.
-    restored._stateID = 1;
     mockSocket.receive('update', 'default', restored);
-    expect(store.getState()).toMatchObject(restored);
+    expect(transportDataCallback).toHaveBeenCalledWith({
+      type: 'update',
+      args: ['default', restored, undefined],
+    });
   });
 
   test('receive sync', () => {
     const restored = { restore: true };
-    expect(store.getState()).not.toMatchObject(restored);
-    mockSocket.receive('sync', 'unknown matchID', { state: restored });
-    expect(store.getState()).not.toMatchObject(restored);
     mockSocket.receive('sync', 'default', { state: restored });
-    expect(store.getState()).toMatchObject(restored);
+    expect(transportDataCallback).toHaveBeenCalledWith({
+      type: 'sync',
+      args: ['default', { state: restored }],
+    });
   });
 
   test('receive matchData', () => {
-    let receivedMatchData: any;
-    m.subscribeMatchData((data) => (receivedMatchData = data));
     const matchData = [{ id: '0', name: 'Alice' }];
-    mockSocket.receive('matchData', 'unknown matchID', matchData);
-    expect(receivedMatchData).toBe(undefined);
     mockSocket.receive('matchData', 'default', matchData);
-    expect(receivedMatchData).toMatchObject(matchData);
+    expect(transportDataCallback).toHaveBeenCalledWith({
+      type: 'matchData',
+      args: ['default', matchData],
+    });
   });
 
   test('send update', () => {
     const action = makeMove(undefined, undefined, undefined);
     const state = { _stateID: 0 } as State;
-    m.onAction(state, action);
+    transport.sendAction(state, action);
     const args: UpdateArgs = [action, state._stateID, 'default', null];
     expect(mockSocket.emit).lastCalledWith('update', ...args);
   });
 
   test('receive chat-message', () => {
-    let receivedChatData;
-    m.subscribeChatMessage((data) => (receivedChatData = data));
     const chatData = { message: 'foo' };
-    mockSocket.receive('chat', 'unknown matchID', chatData);
-    expect(receivedChatData).toBe(undefined);
     mockSocket.receive('chat', 'default', chatData);
-    expect(receivedChatData).toMatchObject(receivedChatData);
+    expect(transportDataCallback).toHaveBeenCalledWith({
+      type: 'chat',
+      args: ['default', chatData],
+    });
   });
 
   test('send chat-message', () => {
@@ -216,68 +200,40 @@ describe('multiplayer', () => {
       sender: '0',
       payload: { message: 'foo' },
     };
-    m.onChatMessage('matchID', message);
+    transport.sendChatMessage('matchID', message);
     expect(mockSocket.emit).lastCalledWith(
       'chat',
       'matchID',
       message,
-      m.getCredentials()
+      transport.getCredentials()
     );
   });
 });
 
 describe('multiplayer delta state', () => {
   const mockSocket = new MockSocket();
-  const m = new TransportAdapter({ socket: mockSocket });
-  m.connect();
-  const game = { deltaState: true };
-  let store = null;
-
-  beforeEach(() => {
-    const reducer = CreateGameReducer({ game });
-    const initialState = InitializeGame({ game });
-    store = createStore(reducer, initialState);
-    m.setStore(store);
+  const transportDataCallback = jest.fn();
+  const transport = new TransportAdapter({
+    socket: mockSocket,
+    transportDataCallback,
   });
+  transport.connect();
 
-  test('returns a valid store', () => {
-    expect(store).not.toBe(undefined);
-  });
+  beforeEach(jest.clearAllMocks);
 
   test('receive patch', () => {
-    const originalState = JSON.parse(JSON.stringify(store.getState()));
-    mockSocket.receive(
-      'patch',
-      'unknown matchID',
-      0,
-      1,
-      [{ op: 'replace', path: '/_stateID', value: 1 }],
-      []
-    );
-    expect(store.getState()).toMatchObject(originalState);
-    mockSocket.receive(
-      'patch',
+    const patch1 = [
       'default',
       0,
       1,
       [{ op: 'replace', path: '/_stateID', value: 1 }],
-      []
-    );
-    expect(store.getState()._stateID).toBe(1);
-    mockSocket.receive(
-      'patch',
-      'default',
-      1,
-      2,
-      [{ op: 'replace', path: '/_stateIDD', value: 3 }],
-      []
-    );
-    expect(store.getState()._stateID).toBe(1);
-    const args: SyncArgs = ['default', null, undefined, 2];
-    expect(mockSocket.emit).lastCalledWith('sync', ...args);
-    expect(error).lastCalledWith(
-      'Patch [{"op":"replace","path":"/_stateIDD","value":3}] apply failed'
-    );
+      [],
+    ];
+    mockSocket.receive('patch', ...patch1);
+    expect(transportDataCallback).toHaveBeenCalledWith({
+      type: 'patch',
+      args: patch1,
+    });
   });
 });
 
@@ -287,7 +243,7 @@ describe('server option', () => {
 
   test('without protocol', () => {
     const server = hostname + ':' + port;
-    const m = new TransportAdapter({ server });
+    const m = new TransportAdapter({ server, transportDataCallback: () => {} });
     m.connect();
     expect(m.socket.io.engine.hostname).toEqual(hostname);
     expect(m.socket.io.engine.port).toEqual(port);
@@ -296,14 +252,20 @@ describe('server option', () => {
 
   test('without trailing slash', () => {
     const server = 'http://' + hostname + ':' + port;
-    const m = new SocketIOTransport({ server });
+    const m = new SocketIOTransport({
+      server,
+      transportDataCallback: () => {},
+    });
     m.connect();
     expect((m.socket.io as any).uri).toEqual(server + '/default');
   });
 
   test('https', () => {
     const serverWithProtocol = 'https://' + hostname + ':' + port + '/';
-    const m = new TransportAdapter({ server: serverWithProtocol });
+    const m = new TransportAdapter({
+      server: serverWithProtocol,
+      transportDataCallback: () => {},
+    });
     m.connect();
     expect(m.socket.io.engine.hostname).toEqual(hostname);
     expect(m.socket.io.engine.port).toEqual(port);
@@ -312,7 +274,10 @@ describe('server option', () => {
 
   test('http', () => {
     const serverWithProtocol = 'http://' + hostname + ':' + port + '/';
-    const m = new TransportAdapter({ server: serverWithProtocol });
+    const m = new TransportAdapter({
+      server: serverWithProtocol,
+      transportDataCallback: () => {},
+    });
     m.connect();
     expect(m.socket.io.engine.hostname).toEqual(hostname);
     expect(m.socket.io.engine.port).toEqual(port);
@@ -320,60 +285,9 @@ describe('server option', () => {
   });
 
   test('no server set', () => {
-    const m = new TransportAdapter();
+    const m = new TransportAdapter({ transportDataCallback: () => {} });
     m.connect();
     expect(m.socket.io.engine.hostname).not.toEqual(hostname);
     expect(m.socket.io.engine.port).not.toEqual(port);
   });
 });
-
-test('changing a matchID resets the state before resync', () => {
-  const m = new TransportAdapter();
-  const game = {};
-  const store = createStore(CreateGameReducer({ game }));
-  m.setStore(store);
-  const dispatchSpy = jest.spyOn(store, 'dispatch');
-
-  m.updateMatchID('foo');
-
-  expect(dispatchSpy).toHaveBeenCalledWith(
-    expect.objectContaining({
-      type: Actions.RESET,
-      clientOnly: true,
-    })
-  );
-});
-
-test('changing a playerID resets the state before resync', () => {
-  const m = new TransportAdapter();
-  const game = {};
-  const store = createStore(CreateGameReducer({ game }));
-  m.setStore(store);
-  const dispatchSpy = jest.spyOn(store, 'dispatch');
-
-  m.updatePlayerID('foo');
-
-  expect(dispatchSpy).toHaveBeenCalledWith(
-    expect.objectContaining({
-      type: Actions.RESET,
-      clientOnly: true,
-    })
-  );
-});
-
-test('changing credentials resets the state before resync', () => {
-  const m = new TransportAdapter();
-  const game = {};
-  const store = createStore(CreateGameReducer({ game }));
-  m.setStore(store);
-  const dispatchSpy = jest.spyOn(store, 'dispatch');
-
-  m.updateCredentials('foo');
-
-  expect(dispatchSpy).toHaveBeenCalledWith(
-    expect.objectContaining({
-      type: Actions.RESET,
-      clientOnly: true,
-    })
-  );
-});
diff --git a/src/client/transport/transport.test.ts b/src/client/transport/transport.test.ts
new file mode 100644
index 0000000..bb2a024
--- /dev/null
+++ b/src/client/transport/transport.test.ts
@@ -0,0 +1,22 @@
+import { Transport } from './transport';
+
+describe('Transport', () => {
+  class SimpleTransport extends Transport {
+    connect() {}
+    disconnect() {}
+    sendAction() {}
+    sendChatMessage() {}
+    requestSync() {}
+    updateMatchID() {}
+    updatePlayerID() {}
+    updateCredentials() {}
+    get(key: 'connectionStatusCallback') {
+      return this[key].bind(this);
+    }
+  }
+
+  test('base class sets up callbacks', () => {
+    const transport = new SimpleTransport({ transportDataCallback: () => {} });
+    expect(transport.get('connectionStatusCallback')()).toBeUndefined();
+  });
+});