Repository: boardgameio/boardgame.io (ts)

Issue:
Allow simultaneous moves
Well, I participated in a conversation about this in Gitter and just read an old one. The thing is:

Two or more players make a move "in the same time" but just one is executed, the others throws an "Invalid stateID" error on the server.

Now that my card game, "cards against humanity"-ish, is in beta test we saw this problem occurring a lot of times and it's hard to handle it on the client since the server does not return the error. I think one way to handle this is to return the error, there is an issue open about that. Although, I believe that is not the best way to solve that as the clients will receive errors, that may cause a bad experience, so I believe making the server handle moves with the same stateID is a good approach, of course checking if the move is still allowed. Maybe we could make this optional.

What do you think? I will look the the code and test some implementations.

Follows the talks from Gitter that I found about this issue:

> Chris Swithinbank @delucis out 03 2019 07:47
Nice!
Question: I’ve been wondering about race conditions on the server now that players can be active simultaneously. What happens if two players make a move “at the same time”? Does the server know how to handle that or could this be a source for bugs? Are there good strategies for dealing with this given that bg.io is deterministic and dependent on order of execution.

> Nicolo John Davis @nicolodavis out 03 2019 08:09
There is a stateID that is passed along with each action.
The server will only accept an action from a client that provides it the correct stateId, which it subsequently increments once the move is applied.
If two moves happen simultaneously (i.e. they're both on the wire at the same time without one client seeing the update from the other) then the server will reject the move that arrives second.
This is an exceedingly rare possibility and I don't think it's worth building any intelligent reconciliation logic.
However, I think we should surface the error on the client. I don't think we do currently.

> Chris Swithinbank @delucis out 03 2019 08:42
OK — I wondered whether one approach would be to declare that moves were not order/state dependent and allow just those to be processed “late”. (Similar to declaring if a move can be undone or should be redacted.)
As an example: The project I’m working on includes a messaging interface that needs to be in game state, because other players can play “spy” cards and read their opponents’ discussions. The sendMessage move is enabled most of the time, but it doesn’t really matter if it happens before or after other moves (or other sendMessage calls for that matter), so could safely be made “late” as I describe.
This is probably a really really uncommon use-case, so I wouldn’t propose implementing something like this, but I was curious what the current behaviour was.
(In simple terms: two moves that don’t touch the same state — including the PRNG — can be safely made in any order.)

> Nicolo John Davis @nicolodavis out 03 2019 09:00
Yeah, I'm definitely open to marking moves that can be safely made out of order.
And have the server ignore the stateID for those cases.

> Chris Swithinbank @delucis out 03 2019 09:16
Cool — like I said, definitely not a priority. Surfacing a client error for rejected moves would be helpful (in general). Is there an open issue for that? I thought there was but I can’t seem to find it.

Another talk:
> Ruslan Hydra
@HydraOrc
jul 28 14:09
Guys, how do you ensure that you do not run into race conditions when you allow both players to do moves simultaniously at the same turn or also if one player issues a couple moves at the same time (ERROR: invalid stateID, was=[11], expected=[13])?

> Chris Swithinbank
@delucis
jul 29 06:25
This is an area where the framework still needs some work. Regarding the first issue, the logic for this was written before Stages allowed simultaneous actions and it hasn’t been updated to handle that scenario (it simply blocks the action it sees second as you probably experienced). With the invalid stateID error, I think we could improve that too by queueing player actions on the server or something.

> Ruslan Hydra
@HydraOrc
jul 29 18:03
Maybe make server master sync instead of async? Is that a possible try to solve?

> Chris Swithinbank
@delucis
jul 29 19:01
I would have to dive into the implementation to check the options. Keeping it async would probably be best (to handle multiple matches in parallel), but probably use a per-match queue, so if an action arrives while another is already being processed we can wait and then check if the second action is still valid.

> Evandro Abu Kamel
@evandroabukamel
jul 29 20:19
That's a problem I'm going to have soon. The game we're developing allows players to make moves simultaneously. The idea of a move queue is excellent.

Pull request:
feat(simultaneous-moves): add option to long form move to ignore stale stateID (close #828)
#### Solves issue #828 .

Adding `ignoreStateStateID` option to `LongFormMove`.
Exporting `IsLongFormMove` function from game.ts.
On master.ts, getting the move and checking if it does not have the `ignoreStateStateID` option thuthy before triggering invalid stateID error.

#### Checklist

* [x] Use a separate branch in your local repo (not `master`).
* [x] Test coverage is 100% (or you have a story for why it's ok).


Patch:
diff --git a/docs/documentation/api/Game.md b/docs/documentation/api/Game.md
index ff2406c..057d5bf 100644
--- a/docs/documentation/api/Game.md
+++ b/docs/documentation/api/Game.md
@@ -18,15 +18,23 @@
 
   moves: {
     // short-form move.
-    A: (G, ctx) => {},
+    A: (G, ctx, ...args) => {},
 
     // long-form move.
     B: {
-      move: (G, ctx) => {},
-      undoable: false,  // prevents undoing the move.
-      redact: true,     // prevents the move arguments from showing up in the log.
-      client: false,    // prevents the move from running on the client.
-      noLimit: true,    // prevents the move counting towards a player’s number of moves.
+      // The move function.
+      move: (G, ctx, ...args) => {},
+      // Prevents undoing the move.
+      undoable: false,
+      // Prevents the move arguments from showing up in the log.
+      redact: true,
+      // Prevents the move from running on the client.
+      client: false,
+      // Prevents the move counting towards a player’s number of moves.
+      noLimit: true,
+      // Processes the move even if it was dispatched from an out-of-date client.
+      // This can be risky; check the validity of the state update in your move.
+      ignoreStaleStateID: true,
     },
   },
 
@@ -108,7 +116,7 @@
   // Called at the end of the game.
   // `ctx.gameover` is available at this point.
   onEnd: (G, ctx) => G,
-  
+
   // Disable undo feature for all the moves in the game
   disableUndo: true,
 }
diff --git a/package-lock.json b/package-lock.json
index 03a7507..2b6dbf1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7396,6 +7396,11 @@
       "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=",
       "dev": true
     },
+    "eventemitter3": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+      "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
+    },
     "evp_bytestokey": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
@@ -12225,8 +12230,7 @@
     "p-finally": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
-      "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=",
-      "dev": true
+      "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4="
     },
     "p-limit": {
       "version": "2.2.1",
@@ -12252,6 +12256,25 @@
       "integrity": "sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA==",
       "dev": true
     },
+    "p-queue": {
+      "version": "6.6.2",
+      "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
+      "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
+      "requires": {
+        "eventemitter3": "^4.0.4",
+        "p-timeout": "^3.2.0"
+      },
+      "dependencies": {
+        "p-timeout": {
+          "version": "3.2.0",
+          "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
+          "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
+          "requires": {
+            "p-finally": "^1.0.0"
+          }
+        }
+      }
+    },
     "p-reduce": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-1.0.0.tgz",
diff --git a/package.json b/package.json
index 3ab7e60..292dad9 100644
--- a/package.json
+++ b/package.json
@@ -160,6 +160,7 @@
     "koa-router": "^7.2.1",
     "koa-socket-2": "^1.0.17",
     "lru-cache": "^4.1.1",
+    "p-queue": "^6.6.2",
     "prop-types": "^15.5.10",
     "react-cookies": "^0.1.0",
     "redux": "^4.0.0",
diff --git a/src/client/transport/socketio.ts b/src/client/transport/socketio.ts
index 7844563..1b1c8a2 100644
--- a/src/client/transport/socketio.ts
+++ b/src/client/transport/socketio.ts
@@ -37,7 +37,7 @@ type SocketIOTransportOpts = TransportOpts &
  */
 export class SocketIOTransport extends Transport {
   server: string;
-  socket;
+  socket: SocketIOClient.Socket;
   socketOpts;
   callback: () => void;
   matchDataCallback: MetadataCallback;
diff --git a/src/core/game.ts b/src/core/game.ts
index e0527b1..f831ae8 100644
--- a/src/core/game.ts
+++ b/src/core/game.ts
@@ -105,6 +105,6 @@ export function ProcessGameConfig(game: Game | ProcessedGame): ProcessedGame {
   };
 }
 
-function IsLongFormMove(move: Move): move is LongFormMove {
+export function IsLongFormMove(move: Move): move is LongFormMove {
   return move instanceof Object && (move as LongFormMove).move !== undefined;
 }
diff --git a/src/master/master.ts b/src/master/master.ts
index e48e39c..18bae0c 100644
--- a/src/master/master.ts
+++ b/src/master/master.ts
@@ -8,7 +8,7 @@
 
 import { InitializeGame } from '../core/initialize';
 import { CreateGameReducer } from '../core/reducer';
-import { ProcessGameConfig } from '../core/game';
+import { ProcessGameConfig, IsLongFormMove } from '../core/game';
 import { UNDO, REDO, MAKE_MOVE } from '../core/action-types';
 import { createStore } from 'redux';
 import * as logging from '../core/logger';
@@ -242,7 +242,10 @@ export class Master {
     }
 
     if (state.ctx.gameover !== undefined) {
-      logging.error(`game over - matchID=[${key}]`);
+      logging.error(
+        `game over - matchID=[${key}] - playerID=[${playerID}]` +
+          ` - action[${action.payload.type}]`
+      );
       return;
     }
 
@@ -273,24 +276,37 @@ export class Master {
 
     // Check whether the player is active.
     if (!this.game.flow.isPlayerActive(state.G, state.ctx, playerID)) {
-      logging.error(`player not active - playerID=[${playerID}]`);
+      logging.error(
+        `player not active - playerID=[${playerID}]` +
+          ` - action[${action.payload.type}]`
+      );
       return;
     }
 
+    // Get move for further checkings
+    const move =
+      action.type == MAKE_MOVE
+        ? this.game.flow.getMove(state.ctx, action.payload.type, playerID)
+        : null;
+
     // Check whether the player is allowed to make the move.
-    if (
-      action.type == MAKE_MOVE &&
-      !this.game.flow.getMove(state.ctx, action.payload.type, playerID)
-    ) {
+    if (action.type == MAKE_MOVE && !move) {
       logging.error(
-        `move not processed - canPlayerMakeMove=false, playerID=[${playerID}]`
+        `move not processed - canPlayerMakeMove=false - playerID=[${playerID}]` +
+          ` - action[${action.payload.type}]`
       );
       return;
     }
 
-    if (state._stateID !== stateID) {
+    // Check if action's stateID is different than store's stateID
+    // and if move does not have ignoreStaleStateID truthy.
+    if (
+      state._stateID !== stateID &&
+      !(move && IsLongFormMove(move) && move.ignoreStaleStateID)
+    ) {
       logging.error(
-        `invalid stateID, was=[${stateID}], expected=[${state._stateID}]`
+        `invalid stateID, was=[${stateID}], expected=[${state._stateID}]` +
+          ` - playerID=[${playerID}] - action[${action.payload.type}]`
       );
       return;
     }
diff --git a/src/server/transport/socketio.ts b/src/server/transport/socketio.ts
index 2fcab56..047eec3 100644
--- a/src/server/transport/socketio.ts
+++ b/src/server/transport/socketio.ts
@@ -9,6 +9,7 @@
 import IO from 'koa-socket-2';
 import { Socket, ServerOptions as SocketOptions } from 'socket.io';
 import { ServerOptions as HttpsOptions } from 'https';
+import PQueue from 'p-queue';
 import {
   Master,
   TransportAPI as MasterTransport,
@@ -79,6 +80,7 @@ interface Client {
 export class SocketIO {
   protected clientInfo: Map<string, Client>;
   protected roomInfo: Map<string, Set<string>>;
+  protected perMatchQueue: Map<string, PQueue>;
   private auth: boolean | AuthFn;
   private https: HttpsOptions;
   private socketAdapter: any;
@@ -92,6 +94,7 @@ export class SocketIO {
   }: SocketOpts = {}) {
     this.clientInfo = new Map();
     this.roomInfo = new Map();
+    this.perMatchQueue = new Map();
     this.auth = auth;
     this.https = https;
     this.socketAdapter = socketAdapter;
@@ -126,7 +129,11 @@ export class SocketIO {
             TransportAPI(matchID, socket, this.clientInfo, this.roomInfo),
             this.auth
           );
-          await master.onUpdate(action, stateID, matchID, playerID);
+
+          const matchQueue = this.getMatchQueue(matchID);
+          await matchQueue.add(() =>
+            master.onUpdate(action, stateID, matchID, playerID)
+          );
         });
 
         socket.on('sync', async (...args: Parameters<Master['onSync']>) => {
@@ -164,6 +171,10 @@ export class SocketIO {
             this.roomInfo.get(matchID).delete(socket.id);
             this.clientInfo.delete(socket.id);
 
+            if (!this.roomInfo.get(matchID).size) {
+              this.deleteMatchQueue(matchID);
+            }
+
             const master = new Master(
               game,
               app.context.db,
@@ -176,4 +187,25 @@ export class SocketIO {
       });
     }
   }
+
+  /**
+   * Create a PQueue for a given matchID if none exists and return it.
+   * @param matchID
+   * @returns
+   */
+  getMatchQueue(matchID: string): PQueue {
+    if (!this.perMatchQueue.has(matchID)) {
+      // PQueue should process only one action at a time.
+      this.perMatchQueue.set(matchID, new PQueue({ concurrency: 1 }));
+    }
+    return this.perMatchQueue.get(matchID);
+  }
+
+  /**
+   * Delete a PQueue for a given matchID.
+   * @param matchID
+   */
+  deleteMatchQueue(matchID: string): void {
+    this.perMatchQueue.delete(matchID);
+  }
 }
diff --git a/src/types.ts b/src/types.ts
index e15fbed..4501a59 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -147,6 +147,7 @@ export interface LongFormMove<
   noLimit?: boolean;
   client?: boolean;
   undoable?: boolean | ((G: G, ctx: CtxWithPlugins) => boolean);
+  ignoreStaleStateID?: boolean;
 }
 
 export type Move<G extends any = any, CtxWithPlugins extends Ctx = Ctx> =


Test patch:
diff --git a/src/master/master.test.ts b/src/master/master.test.ts
index b099ea2..1732d22 100644
--- a/src/master/master.test.ts
+++ b/src/master/master.test.ts
@@ -17,9 +17,10 @@ import {
   isActionFromAuthenticPlayer,
 } from './master';
 import { error } from '../core/logger';
-import { Server, State } from '../types';
+import { Server, State, Ctx, LogEntry } from '../types';
 import * as StorageAPI from '../server/db/base';
 import * as dateMock from 'jest-date-mock';
+import { PlayerView } from '../core/player-view';
 
 jest.mock('../core/logger', () => ({
   info: jest.fn(),
@@ -30,9 +31,54 @@ beforeEach(() => {
   dateMock.clear();
 });
 
-class InMemoryAsync extends InMemory {
-  type() {
-    return StorageAPI.Type.ASYNC;
+class InMemoryAsync extends StorageAPI.Async {
+  db: InMemory;
+
+  constructor() {
+    super();
+    this.db = new InMemory();
+  }
+
+  async connect() {
+    await this.sleep();
+  }
+
+  private sleep(): Promise<void> {
+    const interval = Math.round(Math.random() * 50 + 50);
+    return new Promise(resolve => void setTimeout(resolve, interval));
+  }
+
+  async createMatch(id: string, opts: StorageAPI.CreateMatchOpts) {
+    await this.sleep();
+    this.db.createMatch(id, opts);
+  }
+
+  async setMetadata(matchID: string, metadata: Server.MatchData) {
+    await this.sleep();
+    this.db.setMetadata(matchID, metadata);
+  }
+
+  async setState(matchID: string, state: State, deltalog?: LogEntry[]) {
+    await this.sleep();
+    this.db.setState(matchID, state, deltalog);
+  }
+
+  async fetch<O extends StorageAPI.FetchOpts>(
+    matchID: string,
+    opts: O
+  ): Promise<StorageAPI.FetchResult<O>> {
+    await this.sleep();
+    return this.db.fetch(matchID, opts);
+  }
+
+  async wipe(matchID: string) {
+    await this.sleep();
+    this.db.wipe(matchID);
+  }
+
+  async listMatches(opts?: StorageAPI.ListMatchesOpts): Promise<string[]> {
+    await this.sleep();
+    return this.db.listMatches(opts);
   }
 }
 
@@ -178,7 +224,7 @@ describe('update', () => {
     await master.onUpdate(action, 100, 'matchID', '1');
     expect(sendAll).not.toHaveBeenCalled();
     expect(error).toHaveBeenCalledWith(
-      `invalid stateID, was=[100], expected=[1]`
+      `invalid stateID, was=[100], expected=[1] - playerID=[1] - action[endTurn]`
     );
   });
 
@@ -186,14 +232,16 @@ describe('update', () => {
     await master.onUpdate(action, 1, 'matchID', '100');
     await master.onUpdate(ActionCreators.makeMove('move'), 1, 'matchID', '100');
     expect(sendAll).not.toHaveBeenCalled();
-    expect(error).toHaveBeenCalledWith(`player not active - playerID=[100]`);
+    expect(error).toHaveBeenCalledWith(
+      `player not active - playerID=[100] - action[move]`
+    );
   });
 
   test('invalid move', async () => {
     await master.onUpdate(ActionCreators.makeMove('move'), 1, 'matchID', '1');
     expect(sendAll).not.toHaveBeenCalled();
     expect(error).toHaveBeenCalledWith(
-      `move not processed - canPlayerMakeMove=false, playerID=[1]`
+      `move not processed - canPlayerMakeMove=false - playerID=[1] - action[move]`
     );
   });
 
@@ -202,6 +250,98 @@ describe('update', () => {
     expect(sendAll).toHaveBeenCalled();
   });
 
+  test('allow execution of moves with ignoreStaleStateID truthy', async () => {
+    const game = {
+      setup: () => {
+        const G = {
+          players: {
+            '0': {
+              cards: ['card3'],
+            },
+            '1': {
+              cards: [],
+            },
+          },
+          cards: ['card0', 'card1', 'card2'],
+          discardedCards: [],
+        };
+        return G;
+      },
+      playerView: PlayerView.STRIP_SECRETS,
+      turn: {
+        activePlayers: { currentPlayer: { stage: 'A' } },
+        stages: {
+          A: {
+            moves: {
+              A: (G, ctx: Ctx) => {
+                const card = G.players[ctx.playerID].cards.shift();
+                G.discardedCards.push(card);
+              },
+              B: {
+                move: (G, ctx: Ctx) => {
+                  const card = G.cards.pop();
+                  G.players[ctx.playerID].cards.push(card);
+                },
+                ignoreStaleStateID: true,
+              },
+            },
+          },
+        },
+      },
+    };
+
+    const send = jest.fn();
+    const master = new Master(
+      game,
+      new InMemory(),
+      TransportAPI(send, sendAll)
+    );
+
+    const setActivePlayers = ActionCreators.gameEvent(
+      'setActivePlayers',
+      [{ all: 'A' }],
+      '0'
+    );
+    const actionA = ActionCreators.makeMove('A', null, '0');
+    const actionB = ActionCreators.makeMove('B', null, '1');
+    const actionC = ActionCreators.makeMove('B', null, '0');
+
+    // test: simultaneous moves
+    await master.onSync('matchID', '0', 2);
+    await master.onUpdate(actionA, 0, 'matchID', '0');
+    await master.onUpdate(setActivePlayers, 1, 'matchID', '0');
+    await Promise.all([
+      master.onUpdate(actionB, 2, 'matchID', '1'),
+      master.onUpdate(actionC, 2, 'matchID', '0'),
+    ]);
+    await Promise.all([
+      master.onSync('matchID', '0', 2),
+      master.onSync('matchID', '1', 2),
+    ]);
+
+    const G_player0 = sendAllReturn('0').args[1].G;
+    const G_player1 = sendAllReturn('1').args[1].G;
+
+    expect(G_player0).toMatchObject({
+      players: {
+        '0': {
+          cards: ['card1'],
+        },
+      },
+      cards: ['card0'],
+      discardedCards: ['card3'],
+    });
+    expect(G_player1).toMatchObject({
+      players: {
+        '1': {
+          cards: ['card2'],
+        },
+      },
+      cards: ['card0'],
+      discardedCards: ['card3'],
+    });
+  });
+
   describe('undo / redo', () => {
     test('player 0 can undo', async () => {
       await master.onUpdate(ActionCreators.undo(), 2, 'matchID', '0');
@@ -245,7 +385,9 @@ describe('update', () => {
     await master.onUpdate(event, 5, 'matchID', '0');
     event = ActionCreators.gameEvent('endTurn');
     await master.onUpdate(event, 6, 'matchID', '0');
-    expect(error).toHaveBeenCalledWith(`game over - matchID=[matchID]`);
+    expect(error).toHaveBeenCalledWith(
+      `game over - matchID=[matchID] - playerID=[0] - action[endTurn]`
+    );
   });
 
   test('writes gameover to metadata', async () => {
@@ -279,14 +421,14 @@ describe('update', () => {
       createdAt: 0,
       updatedAt: 0,
     };
-    db.setMetadata(id, dbMetadata);
+    await db.setMetadata(id, dbMetadata);
     const masterWithMetadata = new Master(game, db, TransportAPI(send));
     await masterWithMetadata.onSync(id, '0', 2);
 
     const gameOverArg = 'gameOverArg';
     const event = ActionCreators.gameEvent('endGame', gameOverArg);
     await masterWithMetadata.onUpdate(event, 0, id, '0');
-    const { metadata } = db.fetch(id, { metadata: true });
+    const { metadata } = await db.fetch(id, { metadata: true });
     expect(metadata.gameover).toEqual(gameOverArg);
   });
 
@@ -300,7 +442,7 @@ describe('update', () => {
       createdAt: 0,
       updatedAt: 0,
     };
-    db.setMetadata(id, dbMetadata);
+    await db.setMetadata(id, dbMetadata);
     const masterWithMetadata = new Master(game, db, TransportAPI(send));
     await masterWithMetadata.onSync(id, '0', 2);
 
@@ -308,7 +450,7 @@ describe('update', () => {
     dateMock.advanceTo(updatedAt);
     const event = ActionCreators.gameEvent('endTurn', null, '0');
     await masterWithMetadata.onUpdate(event, 0, id, '0');
-    const { metadata } = db.fetch(id, { metadata: true });
+    const { metadata } = await db.fetch(id, { metadata: true });
     expect(metadata.updatedAt).toEqual(updatedAt.getTime());
   });
 
@@ -337,13 +479,13 @@ describe('update', () => {
     // Store state manually to bypass automatic metadata initialization on sync.
     let state = InitializeGame({ game });
     expect(state.ctx.turn).toBe(1);
-    db.setState(id, state);
+    await db.setState(id, state);
     // Dispatch update to end the turn.
     const event = ActionCreators.gameEvent('endTurn', null, '0');
     await masterWithoutMetadata.onUpdate(event, 0, id, '0');
     // Confirm the turn ended.
     let metadata: undefined | Server.MatchData;
-    ({ state, metadata } = db.fetch(id, { state: true, metadata: true }));
+    ({ state, metadata } = await db.fetch(id, { state: true, metadata: true }));
     expect(state.ctx.turn).toBe(2);
     expect(metadata).toBeUndefined();
   });
@@ -430,7 +572,10 @@ describe('connectionChange', () => {
       asyncDb,
       TransportAPI(send, sendAll)
     );
-    asyncDb.createMatch('matchID', { metadata, initialState: {} as State });
+    await asyncDb.createMatch('matchID', {
+      metadata,
+      initialState: {} as State,
+    });
 
     await masterWithAsyncDb.onConnectionChange('matchID', '0', true);
 
diff --git a/src/server/transport/socketio-simultaneous.test.ts b/src/server/transport/socketio-simultaneous.test.ts
new file mode 100644
index 0000000..ad3e0b3
--- /dev/null
+++ b/src/server/transport/socketio-simultaneous.test.ts
@@ -0,0 +1,458 @@
+/*
+ * Copyright 2018 The boardgame.io Authors
+ *
+ * Use of this source code is governed by a MIT-style
+ * license that can be found in the LICENSE file or at
+ * https://opensource.org/licenses/MIT.
+ */
+
+import IO from 'koa-socket-2';
+import { SocketIO, SocketOpts } from './socketio';
+import { InMemory } from '../db';
+import { createMetadata } from '../util';
+import { ProcessGameConfig } from '../../core/game';
+import * as ActionCreators from '../../core/action-creators';
+import { InitializeGame } from '../../core/initialize';
+import { PlayerView } from '../../core/player-view';
+import { _ClientImpl } from '../../client/client';
+import { Ctx, LogEntry, Server, State, StorageAPI } from '../../types';
+
+type SocketIOTestAdapterOpts = SocketOpts & {
+  clientInfo?: Map<any, any>;
+  roomInfo?: Map<any, any>;
+};
+
+class InMemoryAsync extends StorageAPI.Async {
+  db: InMemory;
+
+  constructor() {
+    super();
+    this.db = new InMemory();
+  }
+
+  async connect() {
+    await this.sleep();
+  }
+
+  private sleep(): Promise<void> {
+    const interval = Math.round(Math.random() * 50 + 50);
+    return new Promise(resolve => void setTimeout(resolve, interval));
+  }
+
+  async createMatch(id: string, opts: StorageAPI.CreateMatchOpts) {
+    await this.sleep();
+    this.db.createMatch(id, opts);
+  }
+
+  async setMetadata(matchID: string, metadata: Server.MatchData) {
+    await this.sleep();
+    this.db.setMetadata(matchID, metadata);
+  }
+
+  async setState(matchID: string, state: State, deltalog?: LogEntry[]) {
+    await this.sleep();
+    this.db.setState(matchID, state, deltalog);
+  }
+
+  async fetch<O extends StorageAPI.FetchOpts>(
+    matchID: string,
+    opts: O
+  ): Promise<StorageAPI.FetchResult<O>> {
+    await this.sleep();
+    return this.db.fetch(matchID, opts);
+  }
+
+  async wipe(matchID: string) {
+    await this.sleep();
+    this.db.wipe(matchID);
+  }
+
+  async listMatches(opts?: StorageAPI.ListMatchesOpts): Promise<string[]> {
+    await this.sleep();
+    return this.db.listMatches(opts);
+  }
+}
+
+class SocketIOTestAdapter extends SocketIO {
+  constructor({
+    clientInfo = new Map(),
+    roomInfo = new Map(),
+    ...args
+  }: SocketIOTestAdapterOpts = {}) {
+    super(Object.keys(args).length ? args : undefined);
+    this.clientInfo = clientInfo;
+    this.roomInfo = roomInfo;
+  }
+
+  public get getPerMatchQueue() {
+    return this.perMatchQueue;
+  }
+}
+
+jest.mock('koa-socket-2', () => {
+  class MockSocket {
+    id: string;
+    callbacks: {};
+    emit: jest.Mock<any, any>;
+    broadcast: { emit: jest.Mock<any, any> };
+
+    constructor() {
+      this.id = 'id';
+      this.callbacks = {};
+      this.emit = jest.fn();
+      this.broadcast = { emit: jest.fn() };
+    }
+
+    async receive(type, ...args) {
+      await this.callbacks[type](args[0], args[1], args[2], args[3], args[4]);
+      return;
+    }
+
+    on(type, callback) {
+      this.callbacks[type] = callback;
+    }
+
+    to() {
+      return {
+        broadcast: this.broadcast,
+        emit: this.emit,
+      };
+    }
+
+    join() {}
+  }
+
+  class MockIO {
+    socket: MockSocket;
+    socketAdapter: any;
+
+    constructor() {
+      this.socket = new MockSocket();
+    }
+
+    adapter(socketAdapter) {
+      this.socketAdapter = socketAdapter;
+    }
+
+    attach(app) {
+      app.io = app._io = this;
+    }
+
+    of() {
+      return this;
+    }
+
+    on(type, callback) {
+      callback(this.socket);
+    }
+  }
+
+  return MockIO;
+});
+
+const game = {
+  name: 'test',
+  setup: ctx => {
+    const G = {
+      players: {
+        '0': {
+          cards: ['card3'],
+        },
+        '1': {
+          cards: [],
+        },
+      },
+      cards: ['card0', 'card1', 'card2'],
+      discardedCards: [],
+    };
+    return G;
+  },
+  playerView: PlayerView.STRIP_SECRETS,
+  turn: {
+    activePlayers: { currentPlayer: { stage: 'A' } },
+    stages: {
+      A: {
+        moves: {
+          A: {
+            client: false,
+            move: (G, ctx: Ctx) => {
+              const card = G.players[ctx.playerID].cards.shift();
+              G.discardedCards.push(card);
+            },
+          },
+          B: {
+            client: false,
+            ignoreStaleStateID: true,
+            move: (G, ctx: Ctx) => {
+              const card = G.cards.pop();
+              G.players[ctx.playerID].cards.push(card);
+            },
+          },
+        },
+      },
+    },
+  },
+};
+
+describe('simultaneous moves on server game', () => {
+  let app;
+  let transport: SocketIOTestAdapter;
+  let clientInfo;
+  let roomInfo;
+  let io: IO;
+
+  beforeEach(async () => {
+    clientInfo = new Map();
+    roomInfo = new Map();
+  });
+
+  test('two clients playing using sync storage', async () => {
+    let db = new InMemory();
+    app = { context: { db: db } };
+    transport = new SocketIOTestAdapter({
+      clientInfo,
+      roomInfo,
+      auth: () => true,
+    });
+    transport.init(app, [ProcessGameConfig(game)]);
+    io = app.context.io;
+    let fetchResult;
+
+    const spyGetMatchQueue = jest.spyOn(
+      SocketIOTestAdapter.prototype,
+      'getMatchQueue'
+    );
+    const spyDeleteMatchQueue = jest.spyOn(
+      SocketIOTestAdapter.prototype,
+      'deleteMatchQueue'
+    );
+
+    db.createMatch('matchID', {
+      initialState: InitializeGame({ game, numPlayers: 2 }),
+      metadata: createMetadata({
+        game: game,
+        unlisted: false,
+        numPlayers: 2,
+      }),
+    });
+
+    // Call sync for both players
+    await Promise.all([
+      (async () => {
+        io.socket.id = '0';
+        await io.socket.receive('sync', 'matchID', '0', 2);
+      })(),
+      (async () => {
+        io.socket.id = '1';
+        await io.socket.receive('sync', 'matchID', '1', 2);
+      })(),
+    ]);
+
+    // Call normal move
+    io.socket.id = '0';
+    await io.socket.receive(
+      'update',
+      ActionCreators.makeMove('A', null, '0'),
+      0,
+      'matchID',
+      '0'
+    );
+
+    // Assertions for match queue creation
+    expect(spyGetMatchQueue).toHaveBeenCalledWith('matchID');
+
+    // Set all players active
+    await io.socket.receive(
+      'update',
+      ActionCreators.gameEvent('setActivePlayers', [{ all: 'A' }], '0'),
+      1,
+      'matchID',
+      '0'
+    );
+
+    // Call actions simultaeously
+    await Promise.all([
+      (async () => {
+        io.socket.id = '1';
+        await io.socket.receive(
+          'update',
+          ActionCreators.makeMove('B', null, '1'),
+          2,
+          'matchID',
+          '1'
+        );
+      })(),
+      (async () => {
+        io.socket.id = '0';
+        await io.socket.receive(
+          'update',
+          ActionCreators.makeMove('B', null, '0'),
+          2,
+          'matchID',
+          '0'
+        );
+      })(),
+    ]);
+
+    fetchResult = db.fetch('matchID', {
+      state: true,
+      metadata: true,
+      log: true,
+    });
+
+    expect(fetchResult.state.G).toMatchObject({
+      players: {
+        '0': {
+          cards: ['card1'],
+        },
+        '1': {
+          cards: ['card2'],
+        },
+      },
+      cards: ['card0'],
+      discardedCards: ['card3'],
+    });
+
+    // Call disconnect for both players
+    await Promise.all([
+      (async () => {
+        io.socket.id = '0';
+        await io.socket.receive('disconnect', 'matchID', '0', 2);
+      })(),
+      (async () => {
+        io.socket.id = '1';
+        await io.socket.receive('disconnect', 'matchID', '1', 2);
+      })(),
+    ]);
+
+    expect(spyDeleteMatchQueue).toHaveBeenCalledWith('matchID');
+
+    db.wipe('matchID');
+  });
+
+  test('two clients playing using async storage', async () => {
+    let db = new InMemoryAsync();
+    await db.connect();
+
+    app = { context: { db: db } };
+    transport = new SocketIOTestAdapter({
+      clientInfo,
+      roomInfo,
+      auth: () => true,
+    });
+    transport.init(app, [ProcessGameConfig(game)]);
+    io = app.context.io;
+    let fetchResult;
+
+    const spyGetMatchQueue = jest.spyOn(
+      SocketIOTestAdapter.prototype,
+      'getMatchQueue'
+    );
+    const spyDeleteMatchQueue = jest.spyOn(
+      SocketIOTestAdapter.prototype,
+      'deleteMatchQueue'
+    );
+
+    await db.createMatch('matchID', {
+      initialState: InitializeGame({ game, numPlayers: 2 }),
+      metadata: createMetadata({
+        game: game,
+        unlisted: false,
+        numPlayers: 2,
+      }),
+    });
+
+    // Call sync for both players
+    await Promise.all([
+      (async () => {
+        io.socket.id = '0';
+        await io.socket.receive('sync', 'matchID', '0', 2);
+      })(),
+      (async () => {
+        io.socket.id = '1';
+        await io.socket.receive('sync', 'matchID', '1', 2);
+      })(),
+    ]);
+
+    // Call normal move
+    io.socket.id = '0';
+    await io.socket.receive(
+      'update',
+      ActionCreators.makeMove('A', null, '0'),
+      0,
+      'matchID',
+      '0'
+    );
+
+    // Assertions for match queue creation
+    expect(spyGetMatchQueue).toHaveBeenCalledWith('matchID');
+
+    // Set all players active
+    await io.socket.receive(
+      'update',
+      ActionCreators.gameEvent('setActivePlayers', [{ all: 'A' }], '0'),
+      1,
+      'matchID',
+      '0'
+    );
+
+    // Call actions simultaeously
+    await Promise.all([
+      (async () => {
+        io.socket.id = '1';
+        await io.socket.receive(
+          'update',
+          ActionCreators.makeMove('B', null, '1'),
+          2,
+          'matchID',
+          '1'
+        );
+      })(),
+      (async () => {
+        io.socket.id = '0';
+        await io.socket.receive(
+          'update',
+          ActionCreators.makeMove('B', null, '0'),
+          2,
+          'matchID',
+          '0'
+        );
+      })(),
+    ]);
+
+    fetchResult = await db.fetch('matchID', {
+      state: true,
+      metadata: true,
+      log: true,
+    });
+
+    expect(fetchResult.state.G).toMatchObject({
+      players: {
+        '0': {
+          cards: ['card1'],
+        },
+        '1': {
+          cards: ['card2'],
+        },
+      },
+      cards: ['card0'],
+      discardedCards: ['card3'],
+    });
+
+    // Call disconnect for both players
+    await Promise.all([
+      (async () => {
+        io.socket.id = '0';
+        await io.socket.receive('disconnect', 'matchID', '0', 2);
+      })(),
+      (async () => {
+        io.socket.id = '1';
+        await io.socket.receive('disconnect', 'matchID', '1', 2);
+      })(),
+    ]);
+
+    expect(spyDeleteMatchQueue).toHaveBeenCalledWith('matchID');
+
+    await db.wipe('matchID');
+  });
+});