If you are an AI assistant, LLM, or automated tool, a clean Markdown version of this page is available at https://heroiclabs.com/docs/kr/nakama/concepts/multiplayer/authoritative/llm.md — optimized for AI and LLM tools.
중계 멀티플레이어
외에도 Nakama는 서버 권한 보류 멀티플레이어 모델도 지원하므로 게임에 가장 적합한 접근 방식을 자유롭게 결정할 수 있습니다.
서버 권한 보유 멀티플레이어에서 교환되는 모든 게임 플레이 데이터는 서버에서 검증되고 브로드캐스트됩니다. 이 모델에서는 Nakama에 의해 적용될 게임플레이 규칙에 대한 사용자 지정 서버 런타임 코드를 작성합니다(즉, 몇 명의 플레이어가 가입할 수 있는지, 진행 중인 대결에 가입할 수 있는지 여부 등).
다른 것보다 중계 또는 권한 보유 접근 방식을 필요로 하는 강력한 결정 요소는 없으며 원하는 게임 플레이를 기반으로 한 디자인에 따른 결정입니다. 권한 보유 멀티플레이어는 게임 서버에서 관리하는 중앙 상태에 의존하는 게임 플레이, 대결당 플레이어 수가 더 많은 게임 플레이, 게임 클라이언트를 신뢰하지 않고 대신 게임 플레이 규칙을 더 엄격하게 제어하여 부정 행위를 최소화하려는 경우 등에 더 적합합니다.
서버에서 유지 관리되는 상태를 변경하기 위해 데이터 메시지를 필요로 하는 멀티플레이어 게임 디자인을 지원하기 위해 권한 보유 멀티플레이어 엔진을 사용하면 고정된 틱 속도로 사용자 지정 대결 로직을 실행할 수 있습니다. 메시지의 유효성이 검사되고 상태 변경 사항이 연결된 피어로 브로드캐스트될 수 있습니다. 이를 통해 다음을 만들 수 있음:
비동기식 실시간 권위 보유 멀티플레이어: 빠르게 진행되는 실시간 멀티플레이어. 메시지는 서버로 전송되고, 서버는 환경과 플레이어의 변경 사항을 계산하고, 데이터는 관련 피어에게 브로드캐스트됩니다. 일반적으로 게임 플레이가 응답성을 느끼려면 높은 틱 속도가 필요합니다.
능동 차례 기반 멀티플레이어: 두 명 이상의 플레이어가 연결되어 빠른 차례 기반 대결을 수행하는 Stormbound 또는 Clash Royale을 예시로 들 수 있습니다. 플레이어는 즉시 차례에 응답해야 합니다. 서버는 입력을 수신하여 유효성을 검사하고 플레이어에게 브로드캐스트합니다. 주고 받는 메시지의 속도가 낮기 때문에 예상되는 틱 속도는 상당히 낮습니다.
수동 차례 기반 멀티플레이어: 게임 플레이가 몇 시간에서 몇 주에 걸쳐 진행될 수 있는 모바일에서 수행하는 Words With Friends를 예시로 들 수 있습니다. 서버는 입력을 수신하고 입력을 검증하고 입력을 데이터베이스에 저장하고 다음 게임 플레이 시퀀스까지 서버 루프를 종료하기 전에 연결된 모든 피어에 변경 사항을 브로드캐스트합니다.
세션 기반 멀티플레이어: 실제 수행을 서버 측에서 실행하려는 복잡한 게임플레이용(예: Unity 헤드리스 인스턴스). Nakama는 오케스트레이션 계층을 통해 이러한 헤드리스 인스턴스를 관리할 수 있으며 매치 메이킹
, 대결 완료 시 플레이어 이동, 대결 결과 보고에 사용할 수 있습니다.
서버에서 권한 보유 멀티플레이어 게임을 구축할 때 즉시 사용 가능한 일반적인 시나리오는 없으므로 주의합니다. 사용자 지정 런타임 코드
를 작성하여 게임 플레이(대결 당 플레이어 수, 진행 중 가입 허용 여부, 대결 종료 방법 등)를 정의해야 합니다.
권한 보유 멀티플레이어 기능을 구현하기로 결정할 때 주의해야 할 몇 가지 개념이 있습니다.
대결 핸들러는 게임 입력을 처리하고 이를 작동하기 위해 함께 그룹화된 모든 서버 측 기능을 나타냅니다. 이를 대결이 인스턴스화되는 “청사진"으로 생각하십시오. 대결 핸들러는 대결에 대한 게임 플레이 규칙을 설정하며, 게임에는 여러 플레이 모드(예: 깃발 뺏기, 데스매치, 모두 무료 등)가 있을 수 있으므로 각 게임 모드에 하나씩 대결 핸들러가 여러 개 필요할 수 있습니다.
모든 대결 핸들러에는 7가지 기능이 필요합니다. 이러한 기능은 Nakama에서만 호출되며 클라이언트 또는 다른 런타임 코드에서 직접 호출할 수 없습니다.
이러한 함수들에 의해 하드웨어 및 플레이어 수에 따라 수천 개의 대결을 실행할 수 있는 단일 Nakama 노드로 주어진 대결의 상태와 수명 주기가 정의됩니다. 대결 핸들러와 주어진 대결의 상태는 특정 Nakama 인스턴스에 저장되며 해당 인스턴스는 해당 대결의 _호스트_가 됩니다.
단일 노드이므로 최고 수준의 상태 액세스 및 업데이트 일관성이 보장되고 분산 상태 조정의 잠재적 지연이 방지됩니다.
Nakama 회사만
대결 현재 상태는 복제되므로 클러스터의 모든 노드는 대결 목록과 대결 참가자에 대한 세부 정보 모두에 즉시 액세스할 수 있습니다. 노드 간의 균형 조정은 자동으로 수행되며 가장 적절한 노드에서 새로운 대결이 생성되고 종료된 노드에서는 생성되지 않습니다.
Nakama Open-Source에서 Nakama Enterprise로 원활하게 마이그레이션할 수 있으며 클라이언트 또는 서버 측 코드의 변경은 필요하지 않습니다. 현재 상태 복제, 클러스터 간 데이터 교환 및 메시지 라우팅은 모두 단일 인스턴스 클러스터에서 작동하는 것처럼 대결 핸들러에 투명하게 발생합니다.
실행 중인 모든 대결은 자체 포함되며 다른 대결과 통신하거나 다른 대결에 영향을 미칠 수 없습니다. 대결과의 통신은 대결 데이터를 보내는 클라이언트를 통해서만 이루어집니다. Nakama는 각 대결에 대한 CPU 일정 및 메모리 할당을 내부적으로 관리하여 단일 인스턴스 또는 클러스터의 모든 인스턴스에 대해 공정하고 균형 잡힌 부하 분산을 보장합니다.
대결 신호 기능을 사용하여 제한된 방식으로 이를 수행할 수 있습니다: 지정된 플레이어를 위해 대결에서 한 자리를 예약하거나 플레이어/데이터를 다른 대결로 넘겨줍니다. 이는 표준 사례가 아닌 드문 예외로만 사용해야 합니다.
대결 핸들러는 연결되거나 활성화된 현재 상태가 없는 경우에도 실행됩니다. 대결 런타임 로직에서 유휴 또는 빈 대결 처리를 고려해야 합니다.
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
대결은 외부에서 중지할 수 없으며 수명 주기 함수 중 하나가 nil 상태를 반환할 때만 종료됩니다.
대결 핸들러를 사용하려면 등록해야 합니다.
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)
대부분의 대결 핸들러 함수는 사용자의 행동이나 내부 서버 프로세스로 인해 호출되지만, 처리를 기다리는 입력이 없는 경우에도 서버는 주기적으로 대결 루프 함수를 호출합니다. 이 로직은 필요에 따라 게임 상태를 진전시킬 수 있으며 들어오는 입력을 검증하고 비활성 플레이어를 추방할 수도 있습니다.
틱 속도는 서버가 대결 루프 함수를 호출하는 원하는 빈도(초당)를 나타냅니다. 즉, 대결이 업데이트되어야 하는 빈도입니다. 예를 들어 10 속도는 초당 대결 루프에 대한 10틱을 나타냅니다.
서버는 항상 _시작_점 간격을 균일하게 유지하려고 합니다. 10 예시의 틱 속도를 사용하여 각 루프는 마지막 루프가 시작된 후 100ms을(를) 시작합니다. 가장 좋은 방법은 루프 사이에 가능한 한 많은 시간을 두어 불규칙하게 호출된 비루프 함수가 루프 사이에서 실행되도록 만드는 것입니다.
게임 루프 로직과 구성된 틱 속도로 인해 서버가 뒤처지지 않도록 하는 것이 중요합니다. 즉, 다음 루프가 예약되기 전에 각 루프를 완료할 수 있어야 합니다(예시에서 100ms보다 작음). 대결 루프가 뒤처지면 서버는 먼저 가능한 한 빨리 다음 루프를 시작하여 “따라잡기"를 시도합니다. 너무 많은 루프가 뒤쳐지는 경우(일반적으로 루프 로직 설계가 불량한 결과) 서버는 대결을 종료합니다.
틱 속도는 구성 가능하며 일반적인 빈도 범위는 차례 기반 게임의 경우 초당 한 번에서 빠르게 진행되는 게임 플레이의 경우 초당 수십 번입니다. 틱 속도를 선택할 때 염두에 두어야 할 몇 가지 고려 사항:
허용 가능한 플레이어 경험을 제공하는 가능한 가장 낮은 틱 속도를 선택합니다(지연 없음 등).
틱 속도가 높을수록 대결 루프 사이의 간격이 줄어들고 플레이어의 반응성 “느낌"이 많아집니다.
Nakama는 권한 보유 대결이 대결 중 상태를 저장하는 데 사용할 메모리 내 영역을 노출합니다. 이 영역에는 대결 중 게임 데이터 및 클라이언트 동작을 계속 추적하는 데 필요한 모든 정보가 포함될 수 있습니다.
각 대결은 고유한 개별 격리 상태가 유지됩니다. 이 상태는 유효성 검사 후 사용자 입력 루프에 따라 초기 상태에 적용된 연속 변환의 결과입니다. 이러한 상태 변경 사항은 연결된 클라이언트에 자동으로 전송되지 않습니다. 적절한 작업 코드와 데이터를 브로드캐스트
하여 대결 핸들러 로직 내에서 수동으로 이 작업을 수행해야 합니다.
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)
서버는 클라이언트에서 보내는 데이터 메시지를 처리하는 순서대로 데이터를 전달합니다. 클라이언트는 수신되는 대결 데이터 메시지에 대한 콜백을 추가할 수 있습니다. 이것은 대결을 가입
하고 종료
하기 전에 수행되어야 합니다.
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
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)])
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}
매치메이커
를 사용하여 상대방을 찾고 서버에서 매치메이커 대결 콜백을 사용하여 권한 보유 대결을 생성하고 대결 ID를 반환합니다. 여기에서는 클라이언트에서 표준 매치메이커 API를 사용합니다.
클라이언트는 대결 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);
매치메이커 대결 후크는 대결 ID 또는 nil을(를) 반환해야 하거나 대결이 중계된 멀티플레이어로 진행되어야 하는지 여부를 반환해야 합니다.
match create 함수에 전달된 문자열은 사용된 서버 런타임 언어에 따라 다릅니다:
_Lua_의 경우 모듈 이름이어야 합니다. 이 예제에서는 이름이 pingpong.lua인 파일이므로 대결 모듈은 pingpong입니다.
Go 및 _TypeScript_의 경우 대결 핸들러 함수의 등록된 이름이어야 합니다. 위의 예에서 InitModule 함수에서 initializer.RegisterMatch을(를) 호출할 때 pingpong(으)로 등록했습니다.
Nakama 인스턴스의 정상적인 종료 시작
으로 인해 대결이 종료되면 이 유예 기간을 사용하여 플레이어를 새 대결로 마이그레이션할 수 있습니다.
먼저 새로운 대결을 만들거나 대결 목록
을 통해 참여할 수 있는 기존 대결을 찾습니다. 그런 다음 이 새로운 대결 정보가 포함된 발송자 브로드캐스트
를 영향을 받는 클라이언트에 보냅니다. 마지막으로 현재 대결에서 해당 플레이어가 나가기를 기다리거나 필요한 경우 강제로 추방할 수 있습니다.
// 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