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.
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.
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.
constMatchInit: 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
constisPrivate=params.isPrivate==="true";// Define the match state
conststate: 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
constlabel=JSON.stringify({isPrivate: state.isPrivate.toString(),playerCount: state.playerCount,requiredPlayerCount: state.requiredPlayerCount});return{state,tickRate,label};};
func(m*LobbyMatch)MatchInit(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,paramsmap[string]interface{})(interface{},int,string){isPrivate:=falseifval,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)iferr!=nil{logger.Error("error marshaling json")returnnil,tickRate,""}labelJson:=string(bytes)returnstate,tickRate,labelJson}
localM={}functionM.match_init(context,initial_state)localis_private=falseifinitial_state.is_privatethenis_private=initial_state.is_privateendlocalstate={players={},player_count=0,required_player_count=2,is_private=is_private,game_state=WAITING_FOR_PLAYERS,empty_ticks=0}-- Create the match labellocallabel=nk.json_encode({["isPrivate"]=state.is_private,["playerCount"]=state.player_count,["requiredPlayerCount"]=state.required_player_count})returnstate,tick_rate,labelend
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
constMatchJoinAttempt: 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
letaccept=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};};
func(m*LobbyMatch)MatchJoinAttempt(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,dispatcherruntime.MatchDispatcher,tickint64,stateinterface{},presenceruntime.Presence,metadatamap[string]string)(interface{},bool,string){matchState,ok:=state.(*LobbyMatchState)if!ok{logger.Error("state not a valid state object")returnnil}accept:=trueiflen(matchState.Players)>=matchState.RequiredPlayerCount{accept=false}matchState.Players[presence.GetUserId()]=PlayerState{Presence:nil,IsReady:false,}returnmatchState,accept,""}
Server
1
2
3
4
5
6
7
8
9
10
11
12
functionM.match_join_attempt(context,dispatcher,tick,state,presence,metadata)-- Accept new players unless the required amount has been fulfilledlocalaccept=trueif#state.players>=state.required_player_countthenaccept=falseend-- Reserve the presence in the matchstate.players[presence.user_id]={presence=presence,is_ready=false}returnstate,acceptend
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.
constMatchJoin: 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
constlabel=JSON.stringify({isPrivate: state.isPrivate.toString(),playerCount: state.playerCount,requiredPlayerCount: state.requiredPlayerCount});dispatcher.matchLabelUpdate(label);return{state};};
func(m*LobbyMatch)MatchJoin(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,dispatcherruntime.MatchDispatcher,tickint64,stateinterface{},presences[]runtime.Presence)interface{}{matchState,ok:=state.(*LobbyMatchState)if!ok{logger.Error("state not a valid state object")returnnil}// Populate the presence property for each player
fori:=0;i<len(presences);i++{matchState.Players[presences[i].GetUserId()].Presence=presences[i]matchState.PlayerCount++}// If the match is full then update the state
ifmatchState.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)iferr!=nil{logger.Error("error marshaling json")returnmatchState}dispatcher.MatchLabelUpdate(string(bytes))returnmatchState}
Server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
functionM.match_join(context,dispatcher,tick,state,presences)-- Populate the presence property for each playerfor_,presenceinipairs(presences)dostate.players[presence.user_id].presence=presencestate.player_count=state.player_count+1end-- If the match is full then update the stateifstate.player_count==state.required_player_countthenstate.game_state=WAITING_FOR_PLAYERS_READYend-- Update the match labellocallabel=nk.json_encode({["isPrivate"]=state.is_private,["playerCount"]=state.player_count,["requiredPlayerCount"]=state.required_player_count})dispatcher.match_label_update(label)returnstateend
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
constMatchLeave: 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(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,dispatcherruntime.MatchDispatcher,tickint64,stateinterface{},presences[]runtime.Presence)interface{}{matchState,ok:=state.(*LobbyMatchState)if!ok{logger.Error("state not a valid state object")returnnil}fori:=0;i<len(presences);i++{// Remove the player from the match state
delete(matchState.Players,presences[i].GetUserId())matchState.PlayerCount--}returnmatchState}
Server
1
2
3
4
5
6
7
8
9
functionM.match_leave(context,dispatcher,tick,state,presences)-- Removing leaving presences from match statefor_,presenceinipairs(presences)dostate.players[presence.user_id]=nilstate.player_count=state.player_count-1endreturnstateend
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
constMatchLoop: 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){returnnull;}return{state};};
func(m*LobbyMatch)MatchLoop(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,dispatcherruntime.MatchDispatcher,tickint64,stateinterface{},messages[]runtime.MatchData)interface{}{matchState,ok:=state.(*LobbyMatchState)if!ok{logger.Error("state not a valid state object")returnnil}// If the match is empty, increment the empty ticks
ifmatchState.playerCount==0{matchState.EmptyTicks++}else{matchState.EmptyTicks=0}// If the match has been empty for too long, end it
ifmatchState.EmptyTicks>maxEmptyTicks{returnnil}returnmatchState}
Server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
functionM.match_loop(context,dispatcher,tick,state,messages)-- If the match is empty, increment the empty ticksifstate.player_count>0thenstate.empty_ticks=state.empty_ticks+1elsestate.empty_ticks=0end-- If the match has been empty for too long, end itifstate.empty_ticks>max_empty_ticksthenreturnnilendreturnstateend
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
iferr:=initializer.RegisterMatch("LobbyMatch",func(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule)(runtime.Match,error){return&LobbyMatch{},nil});err!=nil{logger.Error("unable to register: %v",err)returnerr}
Server
1
-- To register the match handler in lua, name the file appropriately. e.g. LobbyHandler.lua
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
constOnRegisterMatchmakerMatched: 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
varmatchId=nk.matchCreate("LobbyMatch",{isPrivate: false});logger.debug(`Created LobbyMatch with ID: ${matchId}`);returnmatchId;};// 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
iferr:=initializer.RegisterMatchmakerMatched(func(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,entries[]runtime.MatchmakerEntry)(string,error){matchId,err:=nk.MatchCreate(ctx,"LobbyMatch",nil)iferr!=nil{return"",err}returnmatchId,nil});err!=nil{logger.Error("unable to register matchmaker matched hook: %v",err)returnerr}
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
constCreateMatchRpc: nkruntime.RpcFunction=function(ctx: nkruntime.Context,logger: nkruntime.Logger,nk: nkruntime.Nakama,payload: string){// Assume the match will be public by default
letisPrivate=false;// Get the isPrivate value from the payload if it exists
if(payload){constdata=JSON.parse(payload);if(data.isPrivate){isPrivate=data.isPrivate;}}// Create the match and return the match ID to the player
constmatchId=nk.matchCreate("LobbyMatch",{isPrivate});returnJSON.stringify({matchId});};// This happens inside the InitModule function
initializer.registerRpc("create-match",CreateMatchRpc);
// This happens inside the InitModule function
iferr:=initializer.RegisterRpc("create-match",func(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,payloadstring)(string,error){// Assume the match will be public by default
isPrivate:=false// Get the isPrivate value from the payload if it exists
vardatamap[string]interface{}iferr:=json.Unmarshal([]byte(payload),&data);err!=nil{logger.Error("error unmarshaling payload: %v",err)return"",err}ifval,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)iferr!=nil{return"",err}response:=map[string]interface{}{"matchId":matchId,}bytes,err:=json.Marshal(response)iferr!=nil{logger.Error("error marshaling response: %v",err)return"",err}returnstring(bytes),nil});err!=nil{logger.Error("unable to register create match rpc: %v",err)returnerr}
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 defaultlocalis_private=false-- Get the isPrivate value from the payload if it existslocaldata=nk.json_decode(payload)ifdata["isPrivate"]thenis_private=trueend-- Create the match and return the match ID to the playerlocalmatch_id=nk.match_create("LobbyMatch",{is_private=is_private})returnnk.json_encode({["matchId"]=match_id})end,"create-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=awaitsocket.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 presenceAddPresences(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.
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.
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
varallReady=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);}}});
fori:=0;i<len(messages);i++{// If the message is a Ready message, update the player's isReady status and broadcast it to other players
ifmessages[i].GetOpCode()==READY_OP_CODE{matchState.Players[messages[i].GetUserId()].IsReady=truedata:=map[string]interface{}{"userId":messages[i].GetUserId(),}bytes,err:=json.Marshal(data)iferr!=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:=truefor_,p:=rangematchState.Players{if!p.IsReady{allReady=falsebreak}}// If all players are ready, transition to InProgress state and broadcast the game starting event
ifallReady&&matchState.PlayerCount==matchState.RequiredPlayerCount{matchState.GameState=InProgressdispatcher.BroadcastMessage(GAME_STARTING_OP_CODE,nil,nil,nil,true)}}
for_,messageinipairs(messages)do-- If the message is a Ready message, update the player's isReady status and broadcast it to other playersifmessage.op_code==READY_OP_CODEthenstate.players[message.sender.user_id].is_ready=truedispatcher.broadcast_message(READY_OP_CODE,nk.json_encode({["userId"]=message.sender.user_id}))-- Check to see if all players are now readylocalall_ready=truefor_,playerinipairs(state.players)doifplayer.is_ready==falsethenall_ready=falseendend-- If all players are ready, transition to InProgress state and broadcast the game starting eventifall_readyandstate.player_count==state.required_player_countthenstate.game_state=IN_PROGRESSdispatcher.broadcast_message(GAME_STARTING_OP_CODE)endendend
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){constplayer=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:=rangematchState.Players{ifp.IsReady{data:=map[string]interface{}{"userId":p.Presence.GetUserId(),}bytes,err:=json.Marshal(data)iferr!=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 statusfor_,playerinipairs(state.players)doifplayer.is_readythendispatcher.broadcast_message(READY_OP_CODE,nk.json_encode({["userId"]=player.presence.user_id}))endend
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;// ...privatevoidOnReceivedMatchState(IMatchStatematchState){constlongReadyOpCode=1;if(matchState.OpCode==ReadyOpCode){varjson=Encoding.UTF8.GetString(matchState.State);vardata=json.FromJson<Dictionary<string,string>>();if(data.ContainsKey("userId")&&PlayerEntries.ContainsKey(data["userId"])){varuserId=data["userId"];// Update the user interface to show that this user is ready}}}
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.
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.
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.
constInviteFriendRpc: nkruntime.RpcFunction=function(ctx: nkruntime.Context,logger: nkruntime.Logger,nk: nkruntime.Nakama,payload: string){constdata=JSON.parse(payload);if(!data||!data.friendId||!data.matchId){logger.error('Invalid payload.');thrownewError('Invalid payload.');}constnotification: 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);
iferr:=initializer.RegisterRpc("invite-friend",func(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,payloadstring)(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)}vardatamap[string]interface{}iferr:=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)iferr!=nil{return"",runtime.NewError("unable to send friend invite",13)}return"",nil});err!=nil{logger.Error("unable to register create match rpc: %v",err)returnerr}
Server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
nk.register_rpc(function(context,payload)localdata=nk.json_decode(payload)ifdata==nilordata["friendId"]==nilordata["matchId"]==nilthenerror({"invalid payload",3})endlocalcontent={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:
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
privateasyncvoidOnReceivedNotification(IApiNotificationnotification){// Notification code 1 is a friend match inviteif(notification.Code==1){varcontent=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);}}}
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.
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.
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.