Heroic Labs Documentation

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
Server
1
2
const tickRate int = 10
const maxEmptyTicks int = tickRate * 10 // tickRate * seconds
Lua
1
2
local tick_rate = 10
local max_empty_ticks = tick_rate * 10 -- tick_rate * 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 }
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
type LobbyMatch struct{}

type LobbyMatchState struct {
	Players             map[string]PlayerState
	PlayerCount         int
	RequiredPlayerCount int
	IsPrivate           bool
	GameState           GameState
	EmptyTicks          int
}

type PlayerState struct {
	Presence runtime.Presence
	IsReady  bool
}

const (
	WaitingForPlayers GameState = iota
	WaitingForPlayersReady
	InProgress
)
Lua
1
2
3
4
// Define variables for the 3 match game states
local WAITING_FOR_PLAYERS = 1
local WAITING_FOR_PLAYERS_READY = 2
local IN_PROGRESS = 3

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
  };
};
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
26
27
28
29
30
31
32
33
func (m *LobbyMatch) MatchInit(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, params map[string]interface{}) (interface{}, int, string) {
	isPrivate := false

	if val, ok := params["isPrivate"]; ok {
		isPrivate = val.(bool)
	}

	state := &LobbyMatchState{
		Players:             make(map[string]*PlayerState),
		PlayerCount:         0,
		RequiredPlayerCount: 2,
		IsPrivate:           isPrivate,
		GameState:           WaitingForPlayers,
		EmptyTicks:          0,
	}

	// Create the match label
	label := map[string]interface{}{
		"isPrivate":           strconv.FormatBool(state.IsPrivate),
		"playerCount":         state.PlayerCount,
		"requiredPlayerCount": state.RequiredPlayerCount,
	}

	bytes, err := json.Marshal(label)
	if err != nil {
		logger.Error("error marshaling json")
		return nil, tickRate, ""
	}

	labelJson := string(bytes)

	return state, tickRate, labelJson
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
local M = {}

function M.match_init(context, initial_state)
	local is_private = false

	if initial_state.is_private then
		is_private = initial_state.is_private
	end

	local state = {
		players = {},
		player_count = 0,
		required_player_count = 2,
		is_private = is_private,
		game_state = WAITING_FOR_PLAYERS,
		empty_ticks = 0
	}

  -- Create the match label
	local label = nk.json_encode({ ["isPrivate"] = state.is_private, ["playerCount"] = state.player_count, ["requiredPlayerCount"] = state.required_player_count })

	return state, tick_rate, label
end

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
  };
};
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func (m *LobbyMatch) MatchJoinAttempt(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presence runtime.Presence, metadata map[string]string) (interface{}, bool, string) {
	matchState, ok := state.(*LobbyMatchState)
	if !ok {
		logger.Error("state not a valid state object")
		return nil
	}

	accept := true

	if len(matchState.Players) >= matchState.RequiredPlayerCount {
		accept = false
	}

	matchState.Players[presence.GetUserId()] = PlayerState{
		Presence: nil,
		IsReady:  false,
	}

	return matchState, accept, ""
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function M.match_join_attempt(context, dispatcher, tick, state, presence, metadata)
	-- Accept new players unless the required amount has been fulfilled
	local accept = true
	if #state.players >= state.required_player_count then
		accept = false
	end

    -- Reserve the presence in the match
    state.players[presence.user_id] = { presence = presence, is_ready = false }

    return state, accept
end

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
  };
};
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
26
27
28
29
30
31
32
33
34
35
func (m *LobbyMatch) MatchJoin(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence) interface{} {
	matchState, ok := state.(*LobbyMatchState)
	if !ok {
		logger.Error("state not a valid state object")
		return nil
	}

	// Populate the presence property for each player
	for i := 0; i < len(presences); i++ {
		matchState.Players[presences[i].GetUserId()].Presence = presences[i]
		matchState.PlayerCount++
	}

	// If the match is full then update the state
	if matchState.PlayerCount == matchState.RequiredPlayerCount {
		matchState.GameState = WaitingForPlayersReady
	}

	// Update the match label
	label := map[string]interface{} {
		"isPrivate": strconv.FormatBool(matchState.IsPrivate),
		"playerCount": matchState.PlayerCount,
		"requiredPlayerCount": matchState.RequiredPlayerCount
	}

	bytes, err := json.Marshal(label)
	if err != nil {
		logger.Error("error marshaling json")
		return matchState
	}

	dispatcher.MatchLabelUpdate(string(bytes))

	return matchState
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
function M.match_join(context, dispatcher, tick, state, presences)
	-- Populate the presence property for each player
	for _, presence in ipairs(presences) do
		state.players[presence.user_id].presence = presence
		state.player_count = state.player_count + 1
	end

	-- If the match is full then update the state
	if state.player_count == state.required_player_count then
		state.game_state = WAITING_FOR_PLAYERS_READY
	end

	-- Update the match label
	local label = nk.json_encode({ ["isPrivate"] = state.is_private, ["playerCount"] = state.player_count, ["requiredPlayerCount"] = state.required_player_count })
	dispatcher.match_label_update(label)

	return state
end

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
  };
};
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func (m *LobbyMatch) MatchLeave(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence) interface{} {
	matchState, ok := state.(*LobbyMatchState)
	if !ok {
		logger.Error("state not a valid state object")
		return nil
	}

	for i := 0; i < len(presences); i++ {
		// Remove the player from the match state
		delete(matchState.Players, presences[i].GetUserId())
		matchState.PlayerCount--
	}

	return matchState
}
Server
1
2
3
4
5
6
7
8
9
function M.match_leave(context, dispatcher, tick, state, presences)
    -- Removing leaving presences from match state
	for _, presence in ipairs(presences) do
		state.players[presence.user_id] = nil
		state.player_count = state.player_count - 1
	end

	return state
end

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
  };
};
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func (m *LobbyMatch) MatchLoop(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, messages []runtime.MatchData) interface{} {
	matchState, ok := state.(*LobbyMatchState)
	if !ok {
		logger.Error("state not a valid state object")
    return nil
	}

	// If the match is empty, increment the empty ticks
	if matchState.playerCount == 0 {
		matchState.EmptyTicks++
	} else {
		matchState.EmptyTicks = 0
	}

	// If the match has been empty for too long, end it
	if matchState.EmptyTicks > maxEmptyTicks {
		return nil
	}

	return matchState
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
function M.match_loop(context, dispatcher, tick, state, messages)
  -- If the match is empty, increment the empty ticks
	if state.player_count > 0 then
		state.empty_ticks = state.empty_ticks + 1
	else
		state.empty_ticks = 0
	end

  -- If the match has been empty for too long, end it
  if state.empty_ticks > max_empty_ticks then
      return nil
  end

	return state
end

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
  };
};
Server
1
2
3
4
5
6
7
func (m *LobbyMatch) MatchTerminate(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, graceSeconds int) interface{} {
	return state
}

func (m *LobbyMatch) MatchSignal(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, data string) (interface{}, string) {
	return state, data
}
Server
1
2
3
4
5
6
7
8
function M.match_terminate(context, dispatcher, tick, state, grace_seconds)
	return state
end

function M.match_signal(context, dispatcher, tick, state, data)
end

return M

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
});
Server
1
2
3
4
5
6
7
// This happens inside the InitModule function
if err := initializer.RegisterMatch("LobbyMatch", func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule) (runtime.Match, error) {
  return &LobbyMatch{}, nil
}); err != nil {
  logger.Error("unable to register: %v", err)
  return err
}
Server
1
-- To register the match handler in lua, name the file appropriately. e.g. LobbyHandler.lua

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);
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// This happens inside the InitModule function
if err := initializer.RegisterMatchmakerMatched(func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, entries []runtime.MatchmakerEntry) (string, error) {
	matchId, err := nk.MatchCreate(ctx, "LobbyMatch", nil)
	if err != nil {
		return "", err
	}

	return matchId, nil
}); err != nil {
	logger.Error("unable to register matchmaker matched hook: %v", err)
	return err
}
Server
1
2
3
4
nk.register_matchmaker_matched(function(context, matched_users)
    local match_id = nk.match_create("LobbyMatch", { is_private = false })
    return match_id
end)

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);
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// This happens inside the InitModule function
if err := initializer.RegisterRpc("create-match", func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
  // Assume the match will be public by default
  isPrivate := false

  // Get the isPrivate value from the payload if it exists
  var data map[string]interface{}
  if err := json.Unmarshal([]byte(payload), &data); err != nil {
    logger.Error("error unmarshaling payload: %v", err)
    return "", err
  }

  if val, ok := data["isPrivate"]; ok {
    isPrivate = val.(bool)
  }

  params := map[string]interface{}{
    "isPrivate": isPrivate,
  }

  // Create the match and return the match ID to the player
  matchId, err := nk.MatchCreate(ctx, "LobbyMatch", params)
  if err != nil {
    return "", err
  }
  
  response := map[string]interface{}{
    "matchId": matchId,
  }
  
  bytes, err := json.Marshal(response)
  if err != nil {
    logger.Error("error marshaling response: %v", err)
    return "", err
  }

  return string(bytes), nil
}); err != nil {
  logger.Error("unable to register create match rpc: %v", err)
  return err
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
nk.register_rpc(function(context, payload)
  -- Assume the match will be public by default
  local is_private = false

  -- Get the isPrivate value from the payload if it exists
  local data = nk.json_decode(payload)
  if data["isPrivate"] then
      is_private = true
  end

  -- Create the match and return the match ID to the player
  local match_id = nk.match_create("LobbyMatch", { is_private = is_private })
  return nk.json_encode({ ["matchId"] = match_id })
end, "create-match")

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;
Server
1
2
const ReadyOpCode = 1
const GameStartingOpCode = 2
Server
1
2
local READY_OP_CODE = 1
local 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);
    }
  }
});
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
26
27
28
29
30
31
32
33
for i := 0; i < len(messages); i++ {
  // If the message is a Ready message, update the player's isReady status and broadcast it to other players
  if messages[i].GetOpCode() == READY_OP_CODE {
    matchState.Players[messages[i].GetUserId()].IsReady = true

    data := map[string]interface{} {
      "userId": messages[i].GetUserId(),
    }

    bytes, err := json.Marshal(data)
    if err != nil {
      logger.Error("error marshaling message: %v", err)
      continue
    }

    dispatcher.BroadcastMessage(READY_OP_CODE, bytes, nil, nil, true)
  }

  // Check to see if all players are ready
  allReady := true
  for _, p := range matchState.Players {
    if !p.IsReady {
      allReady = false
      break
    }
  }

  // If all players are ready, transition to InProgress state and broadcast the game starting event
  if allReady && matchState.PlayerCount == matchState.RequiredPlayerCount {
    matchState.GameState = InProgress
    dispatcher.BroadcastMessage(GAME_STARTING_OP_CODE, nil, nil, nil, true)
  }
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
for _, message in ipairs(messages) do
  -- If the message is a Ready message, update the player's isReady status and broadcast it to other players
  if message.op_code == READY_OP_CODE then
    state.players[message.sender.user_id].is_ready = true
    dispatcher.broadcast_message(READY_OP_CODE, nk.json_encode({ ["userId"] = message.sender.user_id }))

    -- Check to see if all players are now ready
    local all_ready = true
    for _, player in ipairs(state.players) do
      if player.is_ready == false then
        all_ready = false
      end
    end

    -- If all players are ready, transition to InProgress state and broadcast the game starting event
    if all_ready and state.player_count == state.required_player_count then
      state.game_state = IN_PROGRESS
      dispatcher.broadcast_message(GAME_STARTING_OP_CODE)
    end
  end
end

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);
  }
});
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// For each "ready" player, let the joining players know about their status
for _, p := range matchState.Players {
  if p.IsReady {
    data := map[string]interface{}{
      "userId": p.Presence.GetUserId(),
    }

    bytes, err := json.Marshal(data)
    if err != nil {
      logger.Error("error marshaling message: %v", err)
      continue
    }

    dispatcher.BroadcastMessage(ReadyOpCode, bytes, presences, nil, true)
  }
}
Server
1
2
3
4
5
6
-- For each "ready" player, let the joining players know about their status
for _, player in ipairs(state.players) do
  if player.is_ready then
    dispatcher.broadcast_message(READY_OP_CODE, nk.json_encode({ ["userId"] = player.presence.user_id }))
  end
end

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);
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
if err := initializer.RegisterRpc("invite-friend", func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
  userId, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
  if !ok {
    logger.Error("unable to get user ID")
    return "", runtime.NewError("internal error", 13)
  }

  username, ok := ctx.Value(runtime.RUNTIME_CTX_USERNAME).(string)
  if !ok {
    logger.Error("unable to get user ID")
    return "", runtime.NewError("internal error", 13)
  }

  var data map[string]interface{}
  if err := json.Unmarshal([]byte(payload), &data); err != nil {
    logger.Error("error unmarshaling payload: %v", err)
    return "", runtime.NewError("internal error", 13)
  }

  friendId, ok := data["friendId"]
  if !ok {
    logger.Error("invalid payload, missing friendId")
    return "", runtime.NewError("invalid payload, missing friendId", 3)
  }

  matchId, ok := data["matchId"]
  if !ok {
    logger.Error("invalid payload, missing matchId")
    return "", runtime.NewError("invalid payload, missing matchId", 3)
  }

  content := map[string]interface{}{
    "username": username,
    "message":  "Join me for a match",
    "matchId":  matchId,
  }

  err := nk.NotificationSend(ctx, friendId.(string), "Match Invite", content, 1, userId, false)
  if err != nil {
    return "", runtime.NewError("unable to send friend invite", 13)
  }

  return "", nil
}); err != nil {
  logger.Error("unable to register create match rpc: %v", err)
  return err
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
nk.register_rpc(function(context, payload)
    local data = nk.json_decode(payload)
    if data == nil or data["friendId"] == nil or data["matchId"] == nil then
        error({ "invalid payload",  3 })
    end

    local content = {
        username = context.username,
        message = "Join me for a match",
        ["matchId"] = data["matchId"]
    }

    nk.notification_send(data["friendId"], "Match Invite", content, 1, context.user_id, false)
end, "invite-friend")

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);
  }
}