Creating a Lobby System #

This guide will show you how to implement a multiplayer lobby system using server authoritative runtime code. The lobby system will allow players to create and join public and private matches, invite friends to matches, and chat with other players in the lobby.

A fully functional lobby system with public/private matches, friend invites, lobby chat and ready up functionality
A fully functional lobby system with public/private matches, friend invites, lobby chat and ready up functionality

Prerequisites #

This guide assumes you are already familiar with and have set up a local Nakama instance.

Creating the match handler #

The first thing we need to do is setup a basic authoritative match handler using custom server runtime code.

Authoritative Match Handlers
To learn more about authoritative match handlers please see the authoritative multiplayer and match handler API documentation.

We’ll start by defining the server’s tick rate and how long we should keep the match active when there are no connected players.

Server
1
2
const tickRate = 10;
const maxEmptyTicks = tickRate * 10;// tickRate * seconds

Next we’ll define the shape of our match state, making sure to keep track of the following information:

  • players connected or connecting and their state
  • playerCount of connected players
  • requiredPlayerCount we expect in the match before it can begin
  • if the game isPrivate or public
  • the current gameState: either WaitingForPlayers, WaitingForPlayersReady or InProgress
  • how many emptyTicks the game has been active without any players

For each player we will keep track of their presence and their isReady state.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
interface LobbyMatchState extends nkruntime.MatchState {
  players: { [userId: string]: PlayerState },
  playerCount: number,
  requiredPlayerCount: number,
  isPrivate: boolean,
  gameState: GameState,
  emptyTicks: number
}

interface PlayerState {
  presence: nkruntime.Presence,
  isReady: boolean
}

enum GameState { WaitingForPlayers, WaitingForPlayersReady, InProgress }

Next we’ll implement some basic functionality necessary to get our authoritative match handler running. This includes handling players joining/disconnecting, ending the match if it has been empty for too long, and changing the match’s game state depending on how many players are connected and ready.

MatchInit #

The MatchInit function takes care of the setup and configuration of our match. Here we define the match’s initial state, give the match a label that will allow others to find it using the match listing API, and then return the state, tick rate, and label.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const MatchInit: nkruntime.MatchInitFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, params: {[key: string]: string}) {
  // Determine if the match should be private based on the passed in params
  const isPrivate = params.isPrivate === "true";

  // Define the match state
  const state: LobbyMatchState= {
    players: {},
    isPrivate,
    playerCount: 0,
    requiredPlayerCount: 2,
    gameState: GameState.WaitingForPlayers,
    emptyTicks: 0
  };

  // Update the match label to surface important information for players who are searching for a match
  const label = JSON.stringify({ isPrivate: state.isPrivate.toString(), playerCount: state.playerCount, requiredPlayerCount: state.requiredPlayerCount });

  return {
      state,
      tickRate,
      label
  };
};

MatchJoinAttempt #

In the MatchJoinAttempt function we decide if a player can join based on whether there are enough spots left in the match (determined by the requiredPlayerCount state variable).

If the player is allowed to join, we reserve their spot in the match by adding their user ID to the players dictionary.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const MatchJoinAttempt: nkruntime.MatchJoinAttemptFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher, tick: number, state: LobbyMatchState, presence: nkruntime.Presence, metadata: {[key: string]: any }) {
  // Accept new players unless the required amount has been fulfilled
  let accept = true;
  if (Object.keys(state.players).length >= state.requiredPlayerCount) {
    accept = false;
  }

  // Reserve the spot in the match
  state.players[presence.userId] = { presence: null, isReady: false };
  
  return {
      state,
      accept
  };
};

MatchJoin #

The MatchJoin function is called when 1 or more players have joined the match. Here we update the players dictionary with each player’s presence object and increment the match’s playerCount.

We’ll also use this as a point to check if the match is full. If it is, we can transition into the next state of the match which is the WaitingForPlayersReady state.

Finally we’ll update the match’s label to reflect the updated player counts.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const MatchJoin: nkruntime.MatchJoinFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher, tick: number, state: LobbyMatchState, presences: nkruntime.Presence[]) {
  // Populate the presence property for each player
  presences.forEach(function (presence) {
    state.players[presence.userId].presence = presence;
    state.playerCount++;
  });
  
  // If the match is full then update the state
  if (state.playerCount === state.requiredPlayerCount) {
    state.gameState = GameState.WaitingForPlayersReady;
  }

  // Update the match label
  const label = JSON.stringify({ isPrivate: state.isPrivate.toString(), playerCount: state.playerCount, requiredPlayerCount: state.requiredPlayerCount });
  dispatcher.matchLabelUpdate(label);

  return {
      state
  };
};

MatchLeave #

The MatchLeave function is where we can remove player’s who have left the match (or disconnected) from the match state and perform any cleanup, such as decrementing the playerCount state.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const MatchLeave: nkruntime.MatchLeaveFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher, tick: number, state: LobbyMatchState, presences: nkruntime.Presence[]) {
  // Remove the player from match state
  presences.forEach(function (presence) {
    delete(state.players[presence.userId]);
    state.playerCount--;
  });

  return {
      state
  };
};

MatchLoop #

Inside our MatchLoop function is where we will check to see if the match is currently empty. If it is, we can increase the emptyTicks value (or reset it if there are connected players).

Once the emptyTicks value meets or exceeds our previously defined maxEmptyTicks value, we terminate the match by returning null.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const MatchLoop: nkruntime.MatchLoopFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher, tick: number, state: LobbyMatchState, messages: nkruntime.MatchMessage[]) {
   // If the match is empty, increment the empty ticks
  if (state.playerCount === 0) {
    state.emptyTicks++;
  } else {
    state.emptyTicks = 0;
  }

  // If the match has been empty for too long, end it
  if (state.emptyTicks >= maxEmptyTicks) {
    return null;
  }

  return {
      state
  };
};

MatchTerminate and MatchSignal #

The MatchTerminate and MatchSignal functions are not used in this example, but we must still define them and return the match’s state.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const MatchTerminate: nkruntime.MatchTerminateFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher, tick: number, state: LobbyMatchState, graceSeconds: number) {
  return {
      state
  };
};

const MatchSignal: nkruntime.MatchSignalFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher, tick: number, state: LobbyMatchState, data: string) {
  return {
      state,
      data
  };
};

Registering the match handler #

Last we’ll register the match handler functions with the ID "LobbyMatch". Any match created using this name will be bound to this match handler.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// This happens inside the InitModule function
initializer.registerMatch<LobbyMatchState>("LobbyMatch", {
  matchInit: MatchInit,
  matchJoinAttempt: MatchJoinAttempt,
  matchJoin: MatchJoin,
  matchLeave: MatchLeave,
  matchLoop: MatchLoop,
  matchSignal: MatchSignal,
  matchTerminate: MatchTerminate
});

Registering the matchmaker handler #

In order for our match handler to be used to create a match for players using the matchmaking service, we need to register a matchmaker matched handler and tell Nakama to create an instance of our lobby match.

Here we also pass in a parameter called isPrivate with a value of false. This is used in the MatchInit function defined above to determine whether or not the match should be public or private.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const OnRegisterMatchmakerMatched: nkruntime.MatchmakerMatchedFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, matches: nkruntime.MatchmakerResult[]) {
  // Create a public match and return it's match ID
  var matchId = nk.matchCreate("LobbyMatch", { isPrivate: false });
  logger.debug(`Created LobbyMatch with ID: ${matchId}`);

  return matchId;
};

// This happens inside the InitModule function
initializer.registerMatchmakerMatched(OnRegisterMatchmakerMatched);

Creating private/public matches #

Players can create a new match without waiting for others
Players can create a new match without waiting for others

Not all players will want to join a match using matchmaking. They may choose to create a match themselves. In order to facilitate this, we register an RPC that creates either a public or private match depending on the payload passed to it.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const CreateMatchRpc: nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string) {
  // Assume the match will be public by default
  let isPrivate = false;

  // Get the isPrivate value from the payload if it exists
  if (payload) {
    const data = JSON.parse(payload);
    if (data.isPrivate) {
      isPrivate = data.isPrivate;
    }
  }
  
  // Create the match and return the match ID to the player
  const matchId = nk.matchCreate("LobbyMatch", { isPrivate });
  return JSON.stringify({ matchId });
};

// This happens inside the InitModule function
initializer.registerRpc("create-match", CreateMatchRpc);

Joining the match #

Whenever a player gets a match ID, either by receiving one from the matchmaker or as a response to creating a match via an RPC, we will join the match from the client.

1
match = await socket.JoinMatchAsync(matchId);

When we join the match, we will inspect the match’s existing presences and display them on the lobby screen.

1
2
// A function that loops through the presences and spawns a player game object for each presence
AddPresences(match.Presences);

We also subscribe to the match presence event so that we can update the display of players in the lobby whenever a player joins or leaves.

1
2
3
4
5
6
7
8
9
socket.ReceivedMatchPresence += OnReceivedMatchPresence;

// ...

private void OnReceivedMatchPresence(IMatchPresenceEvent presenceEvent)
{
    AddPresences(presenceEvent.Joins);
    RemovePresences(presenceEvent.Leaves);
}

Player “Ready” state #

Players can mark themselves as ready
Players can mark themselves as ready

There is one thing currently missing from our match handler, the ability for players to signal they’re ready to begin the match.

We achieve this by defining an Op Code that the player can send to the server which updates their isReady status and broadcasts it to all other connected players.

We’ll also define one to indicate that the game is starting.

Server
1
2
const READY_OP_CODE = 1;
const GAME_STARTING_OP_CODE = 2;

We’ll add the following to the start of the MatchLoop function to listen for the ready Op Code and broadcast it to other connected players.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
messages.forEach(function (message) {
  // If the message is a Ready message, update the player's isReady status and broadcast it to other players
  if (message.opCode === READY_OP_CODE) {
    state.players[message.sender.userId].isReady = true;
    dispatcher.broadcastMessage(READY_OP_CODE, JSON.stringify({ userId: message.sender.userId }));

    // Check to see if all players are now ready
    var allReady = true;
    Object.keys(state.players).forEach(function (userId) {
      if (!state.players[userId].isReady) {
        allReady = false;
      }
    });

    // If all players are ready, transition to InProgress state and broadcast the game starting event
    if (allReady && Object.keys(state.players).length === state.requiredPlayerCount) {
      state.gameState = GameState.InProgress;
      dispatcher.broadcastMessage(GAME_STARTING_OP_CODE);
    }
  }
});

We also need to let any newly joined players know if any currently connected players have already flagged themselves as ready. We’ll do this by adding the following to the MatchJoin function, just before the return statement.

Server
1
2
3
4
5
6
7
8
// For each "ready" player, let the joining players know about their status
Object.keys(state.players).forEach(function (key) {
  const player = state.players[key];

  if (player.isReady) {
    dispatcher.broadcastMessage(READY_OP_CODE, JSON.stringify({ userId: player.presence.userId }), presences);
  }
});

From the client side, we need to listen for any match data being received, interpret the message based on it’s Op Code, and handle it appropriately.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
socket.ReceivedMatchState += OnReceivedMatchState;

// ...

private void OnReceivedMatchState(IMatchState matchState)
{
  const long ReadyOpCode = 1;

  if (matchState.OpCode == ReadyOpCode)
  {
    var json = Encoding.UTF8.GetString(matchState.State);
    var data = json.FromJson<Dictionary<string, string>>();
    if (data.ContainsKey("userId") && PlayerEntries.ContainsKey(data["userId"]))
    {
      var userId = data["userId"];
      // Update the user interface to show that this user is ready
    }
  }
}

Lobby private chat #

Players can chat privately inside the lobby
Players can chat privately inside the lobby

We’ll use a room chat channel to provide a way for players in the lobby to talk to each other before the match begins.

From the client, we’ll use the current match ID to join the appropriate chat channel after they have joined the match.

1
channel = await socket.JoinChatAsync(match.Id, ChannelType.Room);

We’ll then allow players to send chat messages to this channel.

1
2
3
4
5
6
var messageContent = new Dictionary<string, string>
{
    {"message", LobbyChatInputField.text}
};

await socket.WriteChatMessageAsync(channel, messageContent.ToJson());

As well as being able to send messages, we also need to listen for incoming messages. To do this we’ll subscribe to the channel message event and handle it by instantiating a new chat message prefab and adding it to the chat box.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
socket.ReceivedChannelMessage += OnReceivedChannelMessage;

// ...

private void OnReceivedChannelMessage(IApiChannelMessage channelMessage)
{
  var chatMessage = Instantiate(LobbyChatMessagePrefab, LobbyChatContainer.transform).GetComponent<ChatMessage>();
  var content = JsonParser.FromJson<Dictionary<string, string>>(channelMessage.Content);
  chatMessage.Init(channelMessage.Username, content["message"]);
}

Inviting friends to the match #

A match invite notification pops up in-game
A match invite notification pops up in-game

To give our players the ability to invite their friends to the match, we first need to show the player their friends list. We’ll display this by using the list friends API.

1
var result = await client.ListFriendsAsync(session, null, 100);

For mutual friends (state 0) we’ll display a button that allows the player to invite their friend to the current match.

For this we’ll create an RPC that takes a friend’s user ID and sends them a notification with a code of 1 (we’ll define this as a match invite notification) which they can listen for on the client and then show a match invite in-game. This RPC expects a payload that contains a friendId and a matchId.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const InviteFriendRpc: nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string) {
  const data = JSON.parse(payload);
  if (!data || !data.friendId || !data.matchId) {
    logger.error('Invalid payload.');
    throw new Error('Invalid payload.');
  }

  const notification: nkruntime.NotificationRequest = {
    code: 1,
    content: { 
      username: ctx.username,
      message: 'Join me for a match',
      matchId: data.matchId
    },
    persistent: false,
    senderId: ctx.userId,
    subject: "Match Invite",
    userId: data.friendId
  };

  nk.notificationsSend([ notification ]);
};

// This happens inside the InitModule function
initializer.registerRpc("invite-friend", InviteFriendRpc);

On the client side we can listen for incoming notifications by subscribing to the event:

1
socket.ReceivedNotification += OnReceivedNotification;

Once a notification is received, we check to see if it is a match invite (code 1) and if so, extract the matchId and open an invite panel in-game.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
private async void OnReceivedNotification(IApiNotification notification)
{
    // Notification code 1 is a friend match invite
    if (notification.Code == 1)
    {
        var content = JsonParser.FromJson<Dictionary<string, string>>(notification.Content);
        if (content.ContainsKey("matchId"))
        {
            friendInviteMatchId = content["matchId"];
            FriendInviteMessage.text = $"{content["username"]} has invited you to a match!";
            FriendInvitePopupPanel.SetActive(true);
        }
    }
}

Finding public matches #

Players can find active public matches
Players can find active public matches

Along with the ability to matchmake and create private matches, we also want to allow players to find currently active public matches.

We’ll achieve this by using the match listing API. Below we pass in a query parameter that explicitly requires the match’s isPrivate value to be false.

1
var result = await client.ListMatchesAsync(session, 0, 100, 100, true, string.Empty, "+label.isPrivate:false");

We’ll use each match’s label to display important information to the player on the results screen, including how many players are currently in the match and how many players are required.

1
2
var matchLabelData = JsonParser.FromJson<Dictionary<string, string>>(match.Label);
label.text = $"{matchLabelData["playerCount"]}/{matchLabelData["requiredPlayerCount"]}";

Starting the match #

Finally, the match transitions from the lobby and into your game!
Finally, the match transitions from the lobby and into your game!

When the match is full and all players have indicated that they are ready, we can begin the match. We already handled the broadcasting of the GAME_START_OP_CODE and changing the match state to InProgress in the MatchLoop function above, so all that is left to do is listen for this message on the client and handle it appropriately.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private void OnReceivedMatchState(IMatchState matchState)
{
  // ...

  const long StartGameOpCode = 2;

  if (matchState.OpCode == StartGameOpCode)
  {
    LobbyPanel.SetActive(false);
    GamePanel.SetActive(true);
  }
}