In addition to relayed multiplayer, Nakama also supports the server-authoritative multiplayer model, giving you the freedom and flexibility to decide which approach is best for your game.
In server-authoritative multiplayer, all exchanges of gameplay data are validated and broadcast by the server. In this model you write custom server runtime code for the gameplay rules to be enforced by Nakama (i.e. how many players can join, whether matches can be joined in progress, etc.).
There are no strong determinative factors that necessitate the relayed or authoritative approach over the other, it is a design decision based on the desired gameplay. Authoritative multiplayer is more suitable for gameplay which depends on central state managed by the game server, gameplay with higher player counts per match, and where you don’t want to trust game clients and instead want stricter control over gameplay rules to minimize cheating, etc.
To support multiplayer game designs which require data messages to change state maintained on the server, the authoritative multiplayer engine enables you to run custom match logic with a fixed tick rate. Messages can be validated and state changes broadcast to connected peers. This enables you to build:
Asynchronous real-time authoritative multiplayer: Fast paced real-time multiplayer. Messages are sent to the server, server calculates changes to the environment and players, and data is broadcasted to relevant peers. This typically requires a high tick-rate for the gameplay to feel responsive.
Active turn-based multiplayer: Some examples are Stormbound or Clash Royale, games where two or more players are connected and are playing a quick turn-based match. Players are expected to respond to turns immediately. The server receives input, validates them and broadcast to players. The expected tick-rate is quite low as rate of message sent and received is low.
Passive turn-based multiplayer: A great example is Words With Friends on mobile where the gameplay can span several hours to weeks. The server receives input, validates them, stores them in the database and broadcast changes to any connected peers before shutting down the server loop until next gameplay sequence.
Session-based multiplayer: For complex gameplay where you want the physics running server-side (e.g. Unity headless instances). Nakama can manage these headless instances, via an orchestration layer, and can be used for matchmaking, moving players on match completion, and reporting the match results.
It is important to note that there are no out-of-the-box or generic scenarios when building your server-authoritative multiplayer game. You must define the gameplay - how many players per match, whether joining in progress is allowed, how the match ends, etc. - by writing custom runtime code.
There are several concepts to familiarize yourself with when deciding to implement the Authoritative Multiplayer feature.
Match handlers represent all server-side functions grouped together to handle game inputs and operate on them. Think of it as a “blueprint” from which a match is instantiated. Your match handler establishes the gameplay rules for the match and, because a game may have multiple modes of play (e.g. Capture the Flag, Deathmatch, Free for All, etc.), you may need multiple match handlers - one for each game mode.
There are 7 functions required in any match handler. These functions are called only by Nakama, they cannot be called directly by clients or other runtime code.
These functions define the state and lifecycle of a given match, with any single Nakama node capable of running thousands of matches depending on hardware and player count. The match handler and state of a given match is stored on a particular Nakama instance, with that instance becoming the host for that match.
A single node is responsible for this to ensure the highest level of consistency accessing and updating the state and to avoid potential delays reconciling distributed state.
Nakama Enterprise Only
Match presences are replicated so all nodes in a cluster have immediate access to both a list of matches and details about match participants. Balancing among nodes is done automatically, with new matches created on the most appropriate node and never on a node that has entered shutdown.
Migrating from Nakama Open-Source to Nakama Enterprise is seamless, and does not require any client or server-side code change. Presence replication, inter-cluster data exchange and message routing all happens transparently to your match handler as if it was operating on a single-instance cluster.
Every running match is self-contained, it cannot communicate with or affect any other matches. Communication with matches is done only via clients sending match data. Nakama internally manages the CPU scheduling and Memory allocation to each match ensuring fair and balance distribution of load on a single instance or all instances in the cluster.
The match signal function can be used to accomplish this in a limited manner: reserve a place in a match for a given player or handoff players/data to another match. This should only be used as rare exception and not standard practice.
Match handlers run even if there are no presences connected or active. You must account for the handling of idle or empty matches in your match runtime logic.
typeLobbyMatchstruct{}typeLobbyMatchStatestruct{presencesmap[string]runtime.PresenceemptyTicksint}func(m*LobbyMatch)MatchInit(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,paramsmap[string]interface{})(interface{},int,string){state:=&LobbyMatchState{emptyTicks:0,presences:map[string]runtime.Presence{},}tickRate:=1// 1 tick per second = 1 MatchLoop func invocations per second
label:=""returnstate,tickRate,label}func(m*LobbyMatch)MatchJoin(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,dispatcherruntime.MatchDispatcher,tickint64,stateinterface{},presences[]runtime.Presence)interface{}{lobbyState,ok:=state.(*LobbyMatchState)if!ok{logger.Error("state not a valid lobby state object")returnnil}fori:=0;i<len(presences);i++{lobbyState.presences[presences[i].GetSessionId()]=presences[i]}returnlobbyState}func(m*LobbyMatch)MatchLeave(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,dispatcherruntime.MatchDispatcher,tickint64,stateinterface{},presences[]runtime.Presence)interface{}{lobbyState,ok:=state.(*LobbyMatchState)if!ok{logger.Error("state not a valid lobby state object")returnnil}fori:=0;i<len(presences);i++{delete(lobbyState.presences,presences[i].GetSessionId())}returnlobbyState}func(m*LobbyMatch)MatchLoop(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,dispatcherruntime.MatchDispatcher,tickint64,stateinterface{},messages[]runtime.MatchData)interface{}{lobbyState,ok:=state.(*LobbyMatchState)if!ok{logger.Error("state not a valid lobby state object")returnnil}// If we have no presences in the match according to the match state, increment the empty ticks count
iflen(lobbyState.presences)==0{lobbyState.emptyTicks++}// If the match has been empty for more than 100 ticks, end the match by returning nil
iflobbyState.emptyTicks>100{returnnil}returnlobbyState}
constmatchInit=function(ctx: nkruntime.Context,logger: nkruntime.Logger,nk: nkruntime.Nakama,params:{[key: string]:string}):{state: nkruntime.MatchState,tickRate: number,label: string}{return{state:{presences:{},emptyTicks: 0},tickRate: 1,// 1 tick per second = 1 MatchLoop func invocations per second
label:''};};constmatchJoin=function(ctx: nkruntime.Context,logger: nkruntime.Logger,nk: nkruntime.Nakama,dispatcher: nkruntime.MatchDispatcher,tick: number,state: nkruntime.MatchState,presences: nkruntime.Presence[]):{state: nkruntime.MatchState}|null{presences.forEach(function(p){state.presences[p.sessionId]=p;});return{state};}constmatchLeave=function(ctx: nkruntime.Context,logger: nkruntime.Logger,nk: nkruntime.Nakama,dispatcher: nkruntime.MatchDispatcher,tick: number,state: nkruntime.MatchState,presences: nkruntime.Presence[]):{state: nkruntime.MatchState}|null{presences.forEach(function(p){delete(state.presences[p.sessionId]);});return{state};}constmatchLoop=function(ctx: nkruntime.Context,logger: nkruntime.Logger,nk: nkruntime.Nakama,dispatcher: nkruntime.MatchDispatcher,tick: number,state: nkruntime.MatchState,messages: nkruntime.MatchMessage[]):{state: nkruntime.MatchState}|null{// If we have no presences in the match according to the match state, increment the empty ticks count
if(state.presences.length===0){state.emptyTicks++;}// If the match has been empty for more than 100 ticks, end the match by returning null
if(state.emptyTicks>100){returnnull;}return{state};}
localM={}functionM.match_init(context,initial_state)localstate={presences={},empty_ticks=0}localtick_rate=1-- 1 tick per second = 1 MatchLoop func invocations per secondlocallabel=""returnstate,tick_rate,labelendfunctionM.match_join(context,dispatcher,tick,state,presences)for_,presenceinipairs(presences)dostate.presences[presence.session_id]=presenceendreturnstateendfunctionM.match_leave(context,dispatcher,tick,state,presences)for_,presenceinipairs(presences)dostate.presences[presence.session_id]=nilendreturnstateendfunctionM.match_loop(context,dispatcher,tick,state,messages)-- Get the count of presences in the matchlocaltotalPresences=0fork,vinpairs(state.presences)dototalPresences=totalPresences+1end-- If we have no presences in the match according to the match state, increment the empty ticks countiftotalPresences==0thenstate.empty_ticks=state.empty_ticks+1end-- If the match has been empty for more than 100 ticks, end the match by returning nilifstate.empty_ticks>100thenreturnnilendreturnstateend
Matches cannot be stopped from the outside and end only when one of the lifecycle functions returns a nil state.
In order to make the match handler available it must be registered.
Server
1
2
3
4
5
6
7
8
funcInitModule(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,initializerruntime.Initializer)error{iferr:=initializer.RegisterMatch("lobby",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}}
-- the name must be the same as the match handler file (e.g. lobby.lua)nk.register_matchmaker_matched(function(context,matched_users)localmatch_id,err=nk.match_create("lobby",{invited=matched_users})returnmatch_idend)
While most match handler functions are called due to user behavior or internal server processes, the server will periodically call the match loop function even when there is no input waiting to be processed. The logic is able to advance the game state as needed, and can also validate incoming input and kick inactive players.
Your tick rate represents the desired frequency (per second) at which the server calls the match loop function - i.e. how often the match should update. For example a rate of 10 represents 10 ticks to the match loop per second.
The server always tries to maintain even start point spacing. Using the tick rate of 10 example, each loop will start 100ms after the last one started. Best practice is to leave as much time as possible between loops, allowing for the irregularly called non-loop functions to execute in the gaps between loops.
It is important that your game loop logic and configured tick rate do not cause the server to fall behind - i.e. each loop must be able to finish before the next is scheduled (less than 100ms in our example). If the match loops do fall behind, the server will first try to “catch up” by starting the next loop as soon as possible. If too many loops fall behind - typically the result of poor loop logic design - the server will end the match.
Tick rate is configurable and typical frequencies range from once per second for turn-based games to dozens of times per second for fast-paced gameplay. Some considerations to keep in mind when choosing your tick rate:
Select the lowest possible tick rate that provides an acceptable player experience (no lag, etc.)
Higher tick rates mean less gaps between match loops, and more responsive “feel” for players
Always start with a low rate and increase in small increments (1-2) until the desired experience is achieved
The lower your tick rate then more matches than can be run concurrently per CPU core
Each match handler can have a different tick rate, such as for different game modes
Nakama exposes an in-memory region for authoritative matches to use for the duration of the match to store their state. This can include any information needed to keep track of game data and client behavior during the course of the match.
Each match maintains its own individual, isolated state. This of this state as the result of continuous transformations applied to an initial state based on the loop of user input after validation. Note that these changes in state are not automatically send to connected clients. You must do this manually within your match handler logic by broadcasting the appropriate op codes and data.
Unlike sending messages in relayed multiplayer, in authoritative matches received messages are not automatically rebroadcast to all other connected clients. Your match logic must explicitly call the broadcast function to send a message.
Each message contains an Op code as well as the payload.
Server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
constMATCH_START_OPCODE=7matchStartData:=&map[string]interface{}{"started":true,"roundTimer":100,}data,err:=json.Marshal(matchStartData)iferr!=nil{logger.Error("error marshaling match start data",err)returnnil}reliable:=truedispatcher.BroadcastMessage(MATCH_START_OPCODE,data,nil,nil,reliable)
The binary content (payload) in each data message should be as small as possible within the maximum transmission unit (MTU) of 1500 bytes. It is common to use JSON and preferable to use a compact binary format like Protocol Buffers or FlatBuffers.
When further reducing the message size and/or frequency is not possible, it is best to prioritize sending fewer messages. For example, 1 message of 1000 bytes per second is better than 5 messages of 200 bytes per second.
Client messages are buffered by the server in the order received and, when the next match loop runs, are handed off as a batch. Best practice is to try and maintain no more than 1 message per tick, per presence to the server, and the same from the server to each presence.
If there are too many messages for your configured tick rate some may be dropped by the server, and an error will be logged. To avoid continuously dropping messages, try:
Decreasing the message send rate from clients to the server
Increasing the tick rate so messages are consumed more often
An op code is a numeric identifier for the type of message sent. Op codes can provide insight into the purpose and content of a message before you decode it.
They can be used to define commands within the gameplay which belong to certain user actions, such as:
Initial state synchronization
Ready status
Ping / Pong
Game state update
Emote
Using bitwise operations to encode data, you can also include additional information in the Op code field.
The dispatcher type passed into the match handler functions enables you to send data from a match to one or more presences in that match.
The are two methods available for sending data, Broadcast and BroadcastDeferred.
Broadcast can be called multiple times per function, but best practice is to limit outgoing data to one message per presence in each loop. Using multiple calls per loop is only recommended if you need to send a different message to each presence.
There is only one difference between using Broadcast vs. BroadcastDeferred - where the former sends the data out immediately when called, the latter does not send the data until the end of the loop.
Keep in mind that if you are sending/broadcasting too much data and the downwards connection to the client is slower than the match data send rate, it can fill up the client connection’s send buffer queue and force the server to disconnect the connection to prevent memory overflows.
The server delivers data in the order it processes data messages from clients. A client can add a callback for incoming match data messages. This should be done before they join and leave a match.
Client
1
2
3
4
5
6
7
8
9
10
11
socket.onmatchdata=(result)=>{varcontent=result.data;switch(result.op_code){case101:console.log("A custom opcode.");break;default:console.log("User %o sent %o",result.presence.user_id,content);}};
Client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Use whatever decoder for your message contents.varenc=System.Text.Encoding.UTF8;socket.ReceivedMatchState+=newState=>{varcontent=enc.GetString(newState.State);switch(newState.OpCode){case101:Console.WriteLine("A custom opcode.");break;default:Console.WriteLine("User '{0}'' sent '{1}'",newState.UserPresence.Username,content);}};
Client
1
2
3
4
5
6
7
8
9
10
socket.onMatchData={matchDatainletcontent=String(data:matchData.data,encoding:.utf8)!switchmatchData.opCode{case101:print("A custom opcode.")default:print("User \(matchData.presence.userID) sent \(content)")}}
Client
1
2
3
4
5
6
7
8
9
10
11
socket.onMatchData.listen((matchData){finalcontent=utf8.decode(matchData.data);switch(matchData.opCode){case101:print('A custom opcode.');break;default:print('User ${matchData.presence.userId} sent $content');}});
Client
1
2
3
4
5
6
7
8
9
10
11
12
rtListener->setMatchDataCallback([](constNMatchData&data){switch(data.opCode){case101:std::cout<<"A custom opcode."<<std::endl;break;default:std::cout<<"User "<<data.presence.userId<<" sent "<<data.data<<std::endl;break;}});
Client
1
2
3
4
5
6
SocketListenerlistener=newAbstractSocketListener(){@OverridepublicvoidonMatchData(finalMatchDatamatchData){System.out.format("Received match data %s with opcode %d",matchData.getData(),matchData.getOpCode());}};
Client
1
2
3
4
5
6
func_ready():# First, setup the socket as explained in the authentication section.socket.connect("received_match_state",self,"_on_match_state")func_on_match_state(p_state:NakamaRTAPI.MatchData):print("Received match state with opcode %s, data %s"%[p_state.op_code,parse_json(p_state.data)])
Client
1
2
3
4
5
6
func_ready():# First, setup the socket as explained in the authentication section.socket.received_match_state.connect(self._on_match_state)func_on_match_state(p_state:NakamaRTAPI.MatchData):print("Received match state with opcode %s, data %s"%[p_state.op_code,JSON.parse_string(p_state.data)])
Use match labels to highlight what the match wants to advertise about itself to Nakama and your player base. This can include details like the game mode, whether it is open or closed, number of players, match status, etc.
Match labels can be either a simple string or JSON value. They are usable via the Match Listing API to filter matches.
Keep in mind that you can only use search queries for match labels with a JSON value. For match labels with a simple string value (e.g. "team-deathmatch"), you can only perform an exact match using the label parameter.
Indexed querying is both more effective and more useful in match listing and, for this reason, it is recommended and preferable to use JSON match labels. Some other best practices to keep in mind:
Match labels have a 2kb size limit
Update labels as infrequently as possible (i.e. no more than once per tick)
Label updates are processed in batches, resulting in a point-in-time view
You can use an RPC function which submits some user IDs to the server and will create a match.
A match ID will be created which could be sent out to the players with an in-app notification or push message (or both). This approach is great when you want to manually create a match and compete with specific users.
funcCreateMatchRPC(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,payloadstring)(string,error){params:=make(map[string]interface{})iferr:=json.Unmarshal([]byte(payload),¶ms);err!=nil{return"",err}modulename:="pingpong"// Name with which match handler was registered in InitModule, see example above.
ifmatchId,err:=nk.MatchCreate(ctx,modulename,params);err!=nil{return"",err}else{returnmatchId,nil}}// Register as RPC function, this call should be in InitModule.
iferr:=initializer.RegisterRpc("create_match_rpc",CreateMatchRPC);err!=nil{logger.Error("Unable to register: %v",err)returnerr}
Use the matchmaker to find opponents and use the matchmaker matched callback on the server to create an authoritative match and return a match ID. This uses the standard matchmaker API on the client.
The clients will receive the matchmaker callback as normal with a match ID.
Server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
localnk=require("nakama")localfunctionmakematch(context,matched_users)-- print matched usersfor_,userinipairs(matched_users)dolocalpresence=user.presencenk.logger_info(("Matched user '%s' named '%s'"):format(presence.user_id,presence.username))fork,vinpairs(user.properties)donk.logger_info(("Matched on '%s' value '%s'"):format(k,v))endendlocalmodulename="pingpong"localsetupstate={invited=matched_users}localmatchid=nk.match_create(modulename,setupstate)returnmatchidendnk.register_matchmaker_matched(makematch)
funcMakeMatch(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,entries[]runtime.MatchmakerEntry)(string,error){for_,e:=rangeentries{logger.Info("Matched user '%s' named '%s'",e.GetPresence().GetUserId(),e.GetPresence().GetUsername())fork,v:=rangee.GetProperties(){logger.Info("Matched on '%s' value '%v'",k,v)}}matchId,err:=nk.MatchCreate(ctx,"pingpong",map[string]interface{}{"invited":entries})iferr!=nil{return"",err}returnmatchId,nil}// Register as matchmaker matched hook, this call should be in InitModule.
iferr:=initializer.RegisterMatchmakerMatched(MakeMatch);err!=nil{logger.Error("Unable to register: %v",err)returnerr}
functionmatchmakerMatched(context: nkruntime.Context,logger: nkruntime.Logger,nk: nkruntime.Nakama,matches: nkruntime.MatchmakerResult[]):string{matches.forEach(function(match){logger.info("Matched user '%s' named '%s'",match.presence.userId,match.presence.username);Object.keys(match.properties).forEach(function(key){logger.info("Matched on '%s' value '%v'",key,match.properties[key])});});try{constmatchId=nk.matchCreate("pingpong",{invited: matches});returnmatchId;}catch(err){logger.error(err);throw(err);}}// ...
initializer.registerMatchmakerMatched(matchmakerMatched);
The matchmaker matched hook must return a match ID or nil if the match should proceed as relayed multiplayer.
The string passed into the match create function depends on the server runtime language used:
For Lua it should be the module name. In this example it is a file named pingpong.lua, so the match module is pingpong.
For Go and TypeScript it must be the registered name of a match handler function. In the example above we registered it as pingpong when invoking initializer.RegisterMatch in the InitModule function.
Players are not in the match until they join even after being matched by the matchmaker. This enables players to opt out of matches they decide not to play.
This can be done by clients in the same way as with relayed multiplayer. A full example of how to do this is covered here.
Users can leave a match at any point. This can be done by clients in the same way as with relayed multiplayer. A full example of how to do this is covered here.
When leaving a match, the LeaveMatch lifecycle match handler function is called and the reason for the leave is added: whether the player left the match or disconnected. In the case of disconnects you can decide to temporarily reserve their seat.
Remember that unlike relayed matches, authoritative matches do not end even if all players have left. This is normal and intended to allow you to support use cases where players are allowed to temporarily disconnect while the game world continues to advance.
Authoritative match handlers will only stop when any of the callbacks return a nil state. You can choose to do this at any point during the lifetime of the match, whether or not there are still players connected to it.
When a match is terminated due to the start of a graceful shutdown of a Nakama instance, this grace period can be used to migrate players to a new match.
First you will create a new match for them or find an existing match they can join via match listing. Then send a dispatcher broadcast with this new match info to the affected clients. Finally you can wait for them to leave their current match or, if necessary, forcibly kick them from it:
// Define an op code for sending a new match id to remaining presences
constnewMatchOpCode=999func(m*LobbyMatch)MatchTerminate(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,dispatcherruntime.MatchDispatcher,tickint64,stateinterface{},graceSecondsint)interface{}{logger.Debug("match will terminate in %d seconds",graceSeconds)varmatchIdstring// Find an existing match for the remaining connected presences to join
limit:=1authoritative:=truelabel:=""minSize:=2maxSize:=4query:="*"availableMatches,err:=nk.MatchList(ctx,limit,authoritative,label,minSize,maxSize,query)iferr!=nil{logger.Error("error listing matches",err)returnnil}iflen(availableMatches)>0{matchId=availableMatches[0].MatchId}else{// No available matches, create a new match instead
matchId,err=nk.MatchCreate(ctx,"match",nil)iferr!=nil{logger.Error("error creating match",err)returnnil}}// Broadcast the new match id to all remaining connected presences
data:=map[string]string{matchId:matchId,}dataJson,err:=json.Marshal(data)iferr!=nil{logger.Error("error marshaling new match message")returnnil}dispatcher.BroadcastMessage(newMatchOpCode,dataJson,nil,nil,true)returnstate}
// Define an op code for sending a new match id to remaining presences
constnewMatchOpCode=999;constmatchTerminate=function(ctx: nkruntime.Context,logger: nkruntime.Logger,nk: nkruntime.Nakama,dispatcher: nkruntime.MatchDispatcher,tick: number,state: nkruntime.MatchState,graceSeconds: number):{state: nkruntime.MatchState}|null{logger.debug(`Match will terminate in ${graceSeconds} seconds.`);letmatchId=null;// Find an existing match for the remaining connected presences to join
constlimit=1;constauthoritative=true;constlabel="";constminSize=2;constmaxSize=4;constquery="*";constavailableMatches=nk.matchList(limit,authoritative,label,minSize,maxSize,query);if(availableMatches.length>0){matchId=availableMatches[0].matchId;}else{// No available matches, create a new match instead
matchId=nk.matchCreate("match",{invited: state.presences});}// Broadcast the new match id to all remaining connected presences
dispatcher.broadcastMessage(newMatchOpCode,JSON.stringify({matchId}),null,null,true);return{state};}
-- Define an op code for sending a new match id to remaining presenceslocalnew_match_op_code=999functionM.match_terminate(context,dispatcher,tick,state,grace_seconds)localmessage="Server shutting down in "..grace_seconds.." seconds"localmatch_id-- Find an existing match for the remaining connected presences to joinlocallimit=1;localauthoritative=true;locallabel="";localmin_size=2;localmax_size=4;localquery="*";localavailable_matches=nk.match_list(limit,authoritative,label,min_size,max_size,query);if#available_matches>0thenmatch_id=available_matches[0].match_id;else-- No available matches, create a new match insteadmatch_id=nk.match_create("match",{invited=state.presences});end-- Broadcast the new match id to all remaining connected presencesdispatcher.broadcast_message(new_match_op_code,nk.json_encode({["matchId"]=match_id}))returnstateend
typeLobbyMatchStatestruct{presencesmap[string]runtime.Presencestartedbool}func(m*LobbyMatch)MatchInit(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,paramsmap[string]interface{})(interface{},int,string){state:=&LobbyMatchState{presences:map[string]runtime.Presence{},started:false,}tickRate:=1label:=""returnstate,tickRate,label}func(m*LobbyMatch)MatchLoop(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,dispatcherruntime.MatchDispatcher,tickint64,stateinterface{},messages[]runtime.MatchData)interface{}{lobbyState,ok:=state.(*LobbyMatchState)if!ok{logger.Error("state not a valid lobby state object")}if(len(lobbyState.presences)>2){lobbyState.started=true;}returnlobbyState}
localM={}functionM.match_init(context,initial_state)localstate={presences={},started=false}localtick_rate=1locallabel=""returnstate,tick_rate,labelendfunctionM.match_loop(context,dispatcher,tick,state,messages)-- Get the count of presences in the matchlocaltotalPresences=0fork,vinpairs(state.presences)dototalPresences=totalPresences+1endiftotalPresences>2thenstate.started=trueendreturnstateend