Router

The router handles all the custom logic required to mediate between the lobby, the game, and the player. Its interface required 3 important functions:

  public type GameLobbyInterface = actor {
    validateQueue : ([QueueEntry]) -> async QueueValidation;
    startGame : ([QueueEntry]) -> async Nat;
    gameResult : (Nat) -> async GameSessionResult;
  };

The router is meant to be a thin layer. In most cases it will be boilerplate code that can be easily adapted by reference to other projects.

Queue Validation

Players join the queue for a particular game room from the lobby. The lobby records their identity and their staked amount in a QueueEntry:

  public type QueueEntry = {
    player : Principal;
    amount : Nat;
  };

The current Queue for a given GameRoom is a list of QueueEntrys. The Lobby passes this list to the router, which examines them and decides whether the configuration is valid for a game.

  public type QueueValidation = {
    #Err;
    #OkReady : Nat;
    #OkNotReady : Nat;
  };

Both of the "Ok" statuses require return of a GameID to be used for session tracking. For our Tic-Tac-Toe game we check that everyone has committed the correct stakes (100 tokens), and that there are exactly 2 players.

  public func validateQueue(queue: [T.QueueEntry]) : async T.QueueValidation {
    var isValid = false;
    var isReady = false;
    var totalPlayers = 0;
    for (i in queue.keys()) {
      let queueEntry = queue[i];
      //This game requires 100 tokens committed
      if (queueEntry.amount != 100) {
        return #Err;
      };
      totalPlayers := totalPlayers + 1;
    };

    if (totalPlayers > 2) {
      return #Err;
    };
    if (totalPlayers == 2) {
      return #OkReady(mostRecentSession + 1);
    };
    return #OkNotReady(mostRecentSession + 1);
  };

We could alternatively just check that all of the staked amounts are equal, to allow for different tiers of game rooms in the lobby. When the router returns #ValidReady the lobby sends the instruction to start the game with the validated queue.

Game Session Creation

For session-based games this involves creating a new instance of the Game class. The queue is passed through to the game, and the instance is placed in the internal map HashMap<Nat, Game.Game> for the router.

  public func startGame(queue: [T.QueueEntry]) : async Nat {
    mostRecentSession := mostRecentSession + 1;
    let game = Game.Game(queue);
    gameSessions.put(mostRecentSession, game);
    return mostRecentSession;
  };

The game is to handle any initialization steps (player choices, custom skins voting for map, etc) before actually starting gameplay. Now the game can begin and the router has 2 important I/O functions during the session:

Game View

The game state can be encoded however the developer wants for integration on their front-end. For our game we've chosen a simple 9-character string representing the game board. The router looks up the matching game instance based on its ID, and retrieves the view:

  public func gameView(gameID : Nat) : async Text {
    switch (gameSessions.get(gameID)) {
        case (?game) {
            return await game.getBoard();
        };
        case (null) {
            return "";
        };
    };
    return "";
  };

For games without perfect knowledge, the router can support player-specific views by passing a Principal.

Input

In the other direction, commands from the player should be structured in whatever way makes most sense for the particular game. In this case the place function on the game class takes coordinates.


  public shared(msg) func input(gameID : Nat, x : Nat, y : Nat) : async T.TurnResult {
    switch (gameSessions.get(gameID)) {
        case (?game) {
            return await game.place(msg.caller, x, y);
        };
        case (null) {
            return #InvalidMove;
        };
    };
  }; 

The player takes their turn by sending an input to the game router, which passes it to the appropriate game instance. The router determined the identity of the user and passes it to the game object. The game is responsible for validating the turn.

Game Result

The previous 2 I/O methods are not specified by the architecture but particular to the game. Now we are back to a specific method used by the lobby. The lobby queries the router for the result of a given gameID:

  public func gameResult(gameID : Nat) : async T.GameSessionResult {
    switch (gameSessions.get(gameID)) {
        case (?game) {
            return await game.status();
        };
        case (null) {
          let result : T.GameSessionResult = {
              status = #Nonexistent;
              balances = [];
          };
          return result;
        };
    };
  };

An existing game session is either still ongoing, or it's ended.

  public type GameStatus = {
    #Nonexistent;
    #Active;
    #Complete;
  };

For games that have ended, the router is responsible for providing the final stakes that have been updated by gameplay.

  public type GameSessionResult = {
    status: GameStatus;
    balances: [QueueEntry];
  };

For this case, notice that we've punted responsibility for building the GameSessionResult object into the Game class itself. That need not always be the case. The game might have its own scoring or ranking system that isn't necessarily 1-to-1 with token balances. Then, the router would take responsibility for translating the game-specific ranking to a GameSessionResult object with the correct token balances.

The Lobby uses the GameSessionResult to credit token balances back to the players when the game has finished.

Last updated