권한 보유 멀티플레이어 #

중계 멀티플레이어 외에도 Nakama는 서버 권한 보류 멀티플레이어 모델도 지원하므로 게임에 가장 적합한 접근 방식을 자유롭게 결정할 수 있습니다.

서버 권한 보유 멀티플레이어에서 교환되는 모든 게임 플레이 데이터는 서버에서 검증되고 브로드캐스트됩니다. 이 모델에서는 Nakama에 의해 적용될 게임플레이 규칙에 대한 사용자 지정 서버 런타임 코드를 작성합니다(즉, 몇 명의 플레이어가 가입할 수 있는지, 진행 중인 대결에 가입할 수 있는지 여부 등).

다른 것보다 중계 또는 권한 보유 접근 방식을 필요로 하는 강력한 결정 요소는 없으며 원하는 게임 플레이를 기반으로 한 디자인에 따른 결정입니다. 권한 보유 멀티플레이어는 게임 서버에서 관리하는 중앙 상태에 의존하는 게임 플레이, 대결당 플레이어 수가 더 많은 게임 플레이, 게임 클라이언트를 신뢰하지 않고 대신 게임 플레이 규칙을 더 엄격하게 제어하여 부정 행위를 최소화하려는 경우 등에 더 적합합니다.

서버에서 유지 관리되는 상태를 변경하기 위해 데이터 메시지를 필요로 하는 멀티플레이어 게임 디자인을 지원하기 위해 권한 보유 멀티플레이어 엔진을 사용하면 고정된 틱 속도로 사용자 지정 대결 로직을 실행할 수 있습니다. 메시지의 유효성이 검사되고 상태 변경 사항이 연결된 피어로 브로드캐스트될 수 있습니다. 이를 통해 다음을 만들 수 있음:

  1. 비동기식 실시간 권위 보유 멀티플레이어: 빠르게 진행되는 실시간 멀티플레이어. 메시지는 서버로 전송되고, 서버는 환경과 플레이어의 변경 사항을 계산하고, 데이터는 관련 피어에게 브로드캐스트됩니다. 일반적으로 게임 플레이가 응답성을 느끼려면 높은 틱 속도가 필요합니다.
  2. 능동 차례 기반 멀티플레이어: 두 명 이상의 플레이어가 연결되어 빠른 차례 기반 대결을 수행하는 Stormbound 또는 Clash Royale을 예시로 들 수 있습니다. 플레이어는 즉시 차례에 응답해야 합니다. 서버는 입력을 수신하여 유효성을 검사하고 플레이어에게 브로드캐스트합니다. 주고 받는 메시지의 속도가 낮기 때문에 예상되는 틱 속도는 상당히 낮습니다.
  3. 수동 차례 기반 멀티플레이어: 게임 플레이가 몇 시간에서 몇 주에 걸쳐 진행될 수 있는 모바일에서 수행하는 Words With Friends를 예시로 들 수 있습니다. 서버는 입력을 수신하고 입력을 검증하고 입력을 데이터베이스에 저장하고 다음 게임 플레이 시퀀스까지 서버 루프를 종료하기 전에 연결된 모든 피어에 변경 사항을 브로드캐스트합니다.
  4. 세션 기반 멀티플레이어: 실제 수행을 서버 측에서 실행하려는 복잡한 게임플레이용(예: Unity 헤드리스 인스턴스). Nakama는 오케스트레이션 계층을 통해 이러한 헤드리스 인스턴스를 관리할 수 있으며 매치 메이킹, 대결 완료 시 플레이어 이동, 대결 결과 보고에 사용할 수 있습니다.

서버에서 권한 보유 멀티플레이어 게임을 구축할 때 즉시 사용 가능한 일반적인 시나리오는 없으므로 주의합니다. 사용자 지정 런타임 코드를 작성하여 게임 플레이(대결 당 플레이어 수, 진행 중 가입 허용 여부, 대결 종료 방법 등)를 정의해야 합니다.

권한 보유 멀티플레이어 기능을 구현하기로 결정할 때 주의해야 할 몇 가지 개념이 있습니다.

대결 핸들러 #

대결 핸들러는 게임 입력을 처리하고 이를 작동하기 위해 함께 그룹화된 모든 서버 측 기능을 나타냅니다. 이를 대결이 인스턴스화되는 “청사진"으로 생각하십시오. 대결 핸들러는 대결에 대한 게임 플레이 규칙을 설정하며, 게임에는 여러 플레이 모드(예: 깃발 뺏기, 데스매치, 모두 무료 등)가 있을 수 있으므로 각 게임 모드에 하나씩 대결 핸들러가 여러 개 필요할 수 있습니다.

모든 대결 핸들러에는 7가지 기능이 필요합니다. 이러한 기능은 Nakama에서만 호출되며 클라이언트 또는 다른 런타임 코드에서 직접 호출할 수 없습니다.

  • 대결 초기화
  • 대결 가입 시도
  • 대결 가입
  • 대결 종료
  • 대결 루프
  • 대결 완료
  • 대결 신호

자세한 내용은 대결 핸들러대결 런타임 함수 참조를 참조하세요.

이러한 함수들에 의해 하드웨어 및 플레이어 수에 따라 수천 개의 대결을 실행할 수 있는 단일 Nakama 노드로 주어진 대결의 상태와 수명 주기가 정의됩니다. 대결 핸들러와 주어진 대결의 상태는 특정 Nakama 인스턴스에 저장되며 해당 인스턴스는 해당 대결의 _호스트_가 됩니다.

단일 노드이므로 최고 수준의 상태 액세스 및 업데이트 일관성이 보장되고 분산 상태 조정의 잠재적 지연이 방지됩니다.

Nakama 회사만

대결 현재 상태는 복제되므로 클러스터의 모든 노드는 대결 목록과 대결 참가자에 대한 세부 정보 모두에 즉시 액세스할 수 있습니다. 노드 간의 균형 조정은 자동으로 수행되며 가장 적절한 노드에서 새로운 대결이 생성되고 종료된 노드에서는 생성되지 않습니다.

Nakama Open-Source에서 Nakama Enterprise로 원활하게 마이그레이션할 수 있으며 클라이언트 또는 서버 측 코드의 변경은 필요하지 않습니다. 현재 상태 복제, 클러스터 간 데이터 교환 및 메시지 라우팅은 모두 단일 인스턴스 클러스터에서 작동하는 것처럼 대결 핸들러에 투명하게 발생합니다.

실행 중인 모든 대결은 자체 포함되며 다른 대결과 통신하거나 다른 대결에 영향을 미칠 수 없습니다. 대결과의 통신은 대결 데이터를 보내는 클라이언트를 통해서만 이루어집니다. Nakama는 각 대결에 대한 CPU 일정 및 메모리 할당을 내부적으로 관리하여 단일 인스턴스 또는 클러스터의 모든 인스턴스에 대해 공정하고 균형 잡힌 부하 분산을 보장합니다.

대결 신호 기능을 사용하여 제한된 방식으로 이를 수행할 수 있습니다: 지정된 플레이어를 위해 대결에서 한 자리를 예약하거나 플레이어/데이터를 다른 대결로 넘겨줍니다. 이는 표준 사례가 아닌 드문 예외로만 사용해야 합니다.

대결 핸들러는 연결되거나 활성화된 현재 상태가 없는 경우에도 실행됩니다. 대결 런타임 로직에서 유휴 또는 빈 대결 처리를 고려해야 합니다.

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
type LobbyMatch struct{}

type LobbyMatchState struct {
	presences  map[string]runtime.Presence
	emptyTicks int
}

func (m *LobbyMatch) MatchInit(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, params map[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 := ""
	return state, tickRate, label
}

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{} {
	lobbyState, ok := state.(*LobbyMatchState)
	if !ok {
		logger.Error("state not a valid lobby state object")
		return nil
	}

	for i := 0; i < len(presences); i++ {
		lobbyState.presences[presences[i].GetSessionId()] = presences[i]
	}

	return lobbyState
}

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{} {
	lobbyState, ok := state.(*LobbyMatchState)
	if !ok {
		logger.Error("state not a valid lobby state object")
		return nil
	}

	for i := 0; i < len(presences); i++ {
		delete(lobbyState.presences, presences[i].GetSessionId())
	}

	return lobbyState
}

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{} {
	lobbyState, ok := state.(*LobbyMatchState)
	if !ok {
		logger.Error("state not a valid lobby state object")
		return nil
	}

	// If we have no presences in the match according to the match state, increment the empty ticks count
	if len(lobbyState.presences) == 0 {
		lobbyState.emptyTicks++
	}

	// If the match has been empty for more than 100 ticks, end the match by returning nil
	if lobbyState.emptyTicks > 100 {
		return nil
	}

	return lobbyState
}
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
const matchInit = 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: ''
  };
};

const matchJoin = 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
  };
}

const matchLeave = 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
  };
}

const matchLoop = 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) {
    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
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
48
local M = {}

function M.match_init(context, initial_state)
	local state = {
		presences = {},
		empty_ticks = 0
	}
	local tick_rate = 1 -- 1 tick per second = 1 MatchLoop func invocations per second
	local label = ""

	return state, tick_rate, label
end

function M.match_join(context, dispatcher, tick, state, presences)
	for _, presence in ipairs(presences) do
		state.presences[presence.session_id] = presence
	end

	return state
end

function M.match_leave(context, dispatcher, tick, state, presences)
	for _, presence in ipairs(presences) do
		state.presences[presence.session_id] = nil
	end

	return state
end

function M.match_loop(context, dispatcher, tick, state, messages)
  -- Get the count of presences in the match
  local totalPresences = 0
  for k, v in pairs(state.presences) do
    totalPresences = totalPresences + 1
  end

	-- If we have no presences in the match according to the match state, increment the empty ticks count
	if totalPresences == 0 then
		state.empty_ticks = state.empty_ticks + 1
	end

	-- If the match has been empty for more than 100 ticks, end the match by returning nil
	if state.empty_ticks > 100 then
		return nil
	end

	return state
end

대결은 외부에서 중지할 수 없으며 수명 주기 함수 중 하나가 nil 상태를 반환할 때만 종료됩니다.

대결 핸들러를 사용하려면 등록해야 합니다.

Server
1
2
3
4
5
6
7
8
func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
	if err := initializer.RegisterMatch("lobby", 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
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let InitModule: nkruntime.InitModule = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer) {
  initializer.registerMatch('lobby', {
    matchInit,
    matchJoinAttempt,
    matchJoin,
    matchLeave,
    matchLoop,
    matchSignal,
    matchTerminate
  });
}
Server
1
2
3
4
5
-- the name must be the same as the match handler file (e.g. lobby.lua)
nk.register_matchmaker_matched(function(context, matched_users)
    local match_id, err = nk.match_create("lobby", { invited = matched_users })
    return match_id
end)

틱 속도 #

대부분의 대결 핸들러 함수는 사용자의 행동이나 내부 서버 프로세스로 인해 호출되지만, 처리를 기다리는 입력이 없는 경우에도 서버는 주기적으로 대결 루프 함수를 호출합니다. 이 로직은 필요에 따라 게임 상태를 진전시킬 수 있으며 들어오는 입력을 검증하고 비활성 플레이어를 추방할 수도 있습니다.

틱 속도는 서버가 대결 루프 함수를 호출하는 원하는 빈도(초당)를 나타냅니다. 즉, 대결이 업데이트되어야 하는 빈도입니다. 예를 들어 10 속도는 초당 대결 루프에 대한 10틱을 나타냅니다.

서버는 항상 _시작_점 간격을 균일하게 유지하려고 합니다. 10 예시의 틱 속도를 사용하여 각 루프는 마지막 루프가 시작된100ms을(를) 시작합니다. 가장 좋은 방법은 루프 사이에 가능한 한 많은 시간을 두어 불규칙하게 호출된 비루프 함수가 루프 사이에서 실행되도록 만드는 것입니다.

게임 루프 로직과 구성된 틱 속도로 인해 서버가 뒤처지지 않도록 하는 것이 중요합니다. 즉, 다음 루프가 예약되기 전에 각 루프를 완료할 수 있어야 합니다(예시에서 100ms보다 작음). 대결 루프가 뒤처지면 서버는 먼저 가능한 한 빨리 다음 루프를 시작하여 “따라잡기"를 시도합니다. 너무 많은 루프가 뒤쳐지는 경우(일반적으로 루프 로직 설계가 불량한 결과) 서버는 대결을 종료합니다.

틱 속도는 구성 가능하며 일반적인 빈도 범위는 차례 기반 게임의 경우 초당 한 번에서 빠르게 진행되는 게임 플레이의 경우 초당 수십 번입니다. 틱 속도를 선택할 때 염두에 두어야 할 몇 가지 고려 사항:

  • 허용 가능한 플레이어 경험을 제공하는 가능한 가장 낮은 틱 속도를 선택합니다(지연 없음 등).
  • 틱 속도가 높을수록 대결 루프 사이의 간격이 줄어들고 플레이어의 반응성 “느낌"이 많아집니다.
  • 항상 낮은 속도로 시작하여 원하는 경험이 달성될 때까지 작은 증분(1-2)으로 높입니다
  • 틱 속도가 낮으면 CPU 코어당 동시에 실행할 수 있는 것보다 더 많은 일치가 생깁니다
  • 대결 핸들러마다 다양한 게임 모드에 대해 다양한 틱 속도가 가능합니다.

일치 상태 #

Nakama는 권한 보유 대결이 대결 중 상태를 저장하는 데 사용할 메모리 내 영역을 노출합니다. 이 영역에는 대결 중 게임 데이터 및 클라이언트 동작을 계속 추적하는 데 필요한 모든 정보가 포함될 수 있습니다.

각 대결은 고유한 개별 격리 상태가 유지됩니다. 이 상태는 유효성 검사 후 사용자 입력 루프에 따라 초기 상태에 적용된 연속 변환의 결과입니다. 이러한 상태 변경 사항은 연결된 클라이언트에 자동으로 전송되지 않습니다. 적절한 작업 코드와 데이터를 브로드캐스트하여 대결 핸들러 로직 내에서 수동으로 이 작업을 수행해야 합니다.

데이터 메시지 보내기 #

중계된 멀티플레이어에서 메시지를 보내는 것과 달리 권한 보유 대결에서 수신된 메시지는 연결된 다른 모든 클라이언트에 자동으로 다시 브로드캐스트되지 않습니다. 대결 논리에서 broadcast 함수를 명시적으로 호출하여 메시지를 보내야 합니다.

각 메시지에는 작업 코드와 페이로드가 포함됩니다.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const MATCH_START_OPCODE = 7

matchStartData := &map[string]interface{} {
  "started": true,
  "roundTimer": 100,
}

data, err := json.Marshal(matchStartData)
if err != nil {
  logger.Error("error marshaling match start data", err)
  return nil
}

reliable := true
dispatcher.BroadcastMessage(MATCH_START_OPCODE, data, nil, nil, reliable)
Server
1
2
3
4
5
6
7
8
const matchStartOpcode = 7

const matchStartData = {
  started: true,
  roundTimer: 100
};

dispatcher.broadcastMessage(matchStartOpcode, json.stringify(matchStartData), null, null, true);
Server
1
2
3
4
5
6
7
8
local match_start_opcode = 7

match_start_data = {
    started = true,
    round_timer = 100
}

dispatcher.broadcast_message(match_start_opcode, nk.json_encode(match_start_data), nil, nil)

각 데이터 메시지 내의 바이너리 컨텐츠(페이로드)는 1500 바이트라는 최대 전송 단위(MTU) 내에서 최대한 작아야 합니다. JSON을 사용하는 것이 일반적이며 프로토콜 버퍼 또는 FlatBuffers와 같은 컴팩트 바이너리 형식을 사용하는 것이 좋습니다.

메시지 크기 및/또는 빈도를 더 줄일 수 없는 경우 메시지를 더 적게 보내는 게 가장 좋습니다. 예를 들어, 초당 1000 바이트 메시지 1개가 초당 200 바이트 메시지 5개보다 낫습니다.

클라이언트 메시지는 수신된 순서대로 서버에 의해 버퍼링되고 다음 대결 루프가 실행될 때 배치로 전달됩니다. 모범 사례는 틱 당, 서버에 대한 현재 상태 당, 서버에서 각 현재 상태까지 동일한 메시지를 1개 이하로 유지하려고 시도하는 것입니다.

구성된 틱 속도에 대한 메시지가 너무 많으면 일부 메시지가 서버에 의해 삭제될 수 있으며 오류가 기록됩니다. 지속적인 메시지 삭제를 방지하려면 다음을 시도합니다:

  • 클라이언트에서 서버로의 메시지 전송 속도 감소
  • 메시지가 더 자주 소비되도록 틱 속도 증가
  • 버퍼 크기 늘리기

작업 코드 #

작업 코드는 전송된 메시지 유형의 숫자 식별자입니다. 메시지를 디코딩하기 전에 작업 코드를 통해 메시지의 목적과 내용을 쉽게 확인할 수 있습니다.

작업 코드를 사용하여 다음과 같은 특정 사용자 작업에 속하는 게임플레이 내에서 명령을 정의할 수 있습니다:

  • 초기 상태 동기화
  • 준비 상태
  • Ping / Pong
  • 게임 상태 업데이트
  • 이모티콘

비트 연산을 사용하여 데이터를 인코딩하면 작업 코드 필드에 추가 정보를 포함시킬 수도 있습니다.

구현에 관한 예는 Fish Game 튜토리얼을 참조하세요.

브로드캐스트 대 브로드캐스트 지연 #

대결 핸들러 함수에 전달된 dispatcher 유형을 사용하면 대결에서 _해당 대결_에 있는 하나 이상의 현재 상태로 데이터를 보낼 수 있습니다.

데이터를 보내는 데 사용할 수 있는 두 가지 방법 BroadcastBroadcastDeferred.

Broadcast 함수 당 여러 번 호출할 수 있지만 가장 좋은 방법은 나가는 데이터를 각 루프의 현재 상태 당 하나의 메시지로 제한하는 것입니다. 루프 당 여러 호출을 사용하는 것은 각 현재 상태에 다른 메시지를 보내야 하는 경우에만 권장됩니다.

BroadcastBroadcastDeferred의 한 가지 사용 상 차이점은 전자는 호출 시 즉시 데이터를 보내고 후자는 루프가 끝날 때까지 데이터를 보내지 않는 것입니다.

너무 많은 데이터를 전송/브로드캐스트하고 클라이언트에 대한 다운로드 연결이 대결 데이터 전송 속도보다 느린 경우, 클라이언트 연결의 전송 버퍼 큐를 채우고 메모리 오버플로를 방지하기 위해 서버가 연결을 끊도록 만들 수 있습니다.

데이터 메시지 수신 #

서버는 클라이언트에서 보내는 데이터 메시지를 처리하는 순서대로 데이터를 전달합니다. 클라이언트는 수신되는 대결 데이터 메시지에 대한 콜백을 추가할 수 있습니다. 이것은 대결을 가입하고 종료하기 전에 수행되어야 합니다.

Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
socket.onmatchdata = (result) => {
  var content = result.data;

  switch (result.op_code) {
    case 101:
      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.
var enc = System.Text.Encoding.UTF8;
socket.ReceivedMatchState += newState =>
{
    var content = enc.GetString(newState.State);

    switch (newState.OpCode)
    {
        case 101:
            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([](const NMatchData& data)
{
    switch (data.opCode)
    {
        case 101:
            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
SocketListener listener = new AbstractSocketListener() {
    @Override
    public void onMatchData(final MatchData matchData) {
        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
socket.on_matchdata(function(message)
  local data = json.decode(message.match_data.data)
  local op_code = tonumber(message.match_data.op_code)
end)

Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.

대결 레이블 #

대결 레이블을 사용하여 대결이 Nakama와 플레이어 기반에 광고하려는 내용을 강조 표시합니다. 여기에는 게임 모드, 열림 또는 닫힘 여부, 플레이어 수, 대결 상태 등과 같은 세부 정보가 포함될 수 있습니다.

대결 레이블은 단순 문자열 또는 JSON 값일 수 있습니다. 이런 레이블은 대결 목록 API를 통해 대결을 필터링하는 데 사용할 수 있습니다.

JSON 값이 있는 대결 레이블에 대해서만 **검색 쿼리**를 사용할 수 있습니다. 단순 문자열 값(예: "team-deathmatch")이 있는 대결 레이블의 경우 label 매개변수를 사용하여 정확한 일치만 수행할 수 있습니다.

인덱싱된 쿼리는 대결 목록에서 더 효과적이고 유용하므로 JSON 대결 레이블을 사용하는 것이 좋습니다. 주의해야 할 몇 가지 모범 사례:

  • 대결 레이블에는 2kb 크기 제한 있음
  • 레이블 가능한 업데이트하지 않음(즉, 틱 당 한 번 이하).
  • 레이블이 배치로 업데이트되어 특정 시점 보기가 생성됨

대결 관리 #

대결 생성 #

권한 보유 대결은 수동으로 또는 매치메이커를 통해 서버에서 생성할 수 있습니다.

수동 #

일부 사용자 ID를 서버에 제출하고 대결을 생성하는 RPC 함수를 사용할 수 있습니다.

인앱 알림이나 푸시 메시지(또는 둘 다)와 함께 플레이어에게 보낼 수 있는 대결 ID가 생성됩니다. 이 접근 방식은 수동으로 대결을 만들고 특정 사용자와 경쟁하려는 경우에 유용합니다.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
local nk = require("nakama")

local function create_match(context, payload)
  local modulename = "pingpong"
  local setupstate = { initialstate = payload }
  local matchid = nk.match_create(modulename, setupstate)

  return nk.json_encode({ matchid = matchid })
end

nk.register_rpc(create_match, "create_match_rpc")
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func CreateMatchRPC(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
    params := make(map[string]interface{})

    if err := json.Unmarshal([]byte(payload), &params); err != nil {
        return "", err
    }

    modulename := "pingpong" // Name with which match handler was registered in InitModule, see example above.

    if matchId, err := nk.MatchCreate(ctx, modulename, params); err != nil {
        return "", err
    } else {
        return matchId, nil
    }
}

// Register as RPC function, this call should be in InitModule.
if err := initializer.RegisterRpc("create_match_rpc", CreateMatchRPC); err != nil {
    logger.Error("Unable to register: %v", err)
    return err
}
Server
1
2
3
4
function rpcCreateMatch(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
  var matchId = nk.matchCreate('pingpong', payload);
  return JSON.stringify({ matchId });
}

매치 메이커 #

매치메이커를 사용하여 상대방을 찾고 서버에서 매치메이커 대결 콜백을 사용하여 권한 보유 대결을 생성하고 대결 ID를 반환합니다. 여기에서는 클라이언트에서 표준 매치메이커 API를 사용합니다.

클라이언트는 대결 ID가 있는 매치메이커 콜백을 정상적으로 수신합니다.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
local nk = require("nakama")

local function makematch(context, matched_users)
  -- print matched users
  for _, user in ipairs(matched_users) do
    local presence = user.presence
    nk.logger_info(("Matched user '%s' named '%s'"):format(presence.user_id, presence.username))
    for k, v in pairs(user.properties) do
      nk.logger_info(("Matched on '%s' value '%s'"):format(k, v))
    end
  end

  local modulename = "pingpong"
  local setupstate = { invited = matched_users }
  local matchid = nk.match_create(modulename, setupstate)
  return matchid
end

nk.register_matchmaker_matched(makematch)
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func MakeMatch(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, entries []runtime.MatchmakerEntry) (string, error) {
    for _, e := range entries {
        logger.Info("Matched user '%s' named '%s'", e.GetPresence().GetUserId(), e.GetPresence().GetUsername())

        for k, v := range e.GetProperties() {
            logger.Info("Matched on '%s' value '%v'", k, v)
        }
    }

    matchId, err := nk.MatchCreate(ctx, "pingpong", map[string]interface{}{"invited": entries})

    if err != nil {
        return "", err
    }

    return matchId, nil
}

// Register as matchmaker matched hook, this call should be in InitModule.
if err := initializer.RegisterMatchmakerMatched(MakeMatch); err != nil {
    logger.Error("Unable to register: %v", err)
    return err
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
function matchmakerMatched(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 {
    const matchId = nk.matchCreate("pingpong", { invited: matches });
    return matchId;
  } catch (err) {
    logger.error(err);
    throw (err);
  }
}

// ...

initializer.registerMatchmakerMatched(matchmakerMatched);

매치메이커 대결 후크는 대결 ID 또는 nil을(를) 반환해야 하거나 대결이 중계된 멀티플레이어로 진행되어야 하는지 여부를 반환해야 합니다.

match create 함수에 전달된 문자열은 사용된 서버 런타임 언어에 따라 다릅니다:

  • _Lua_의 경우 모듈 이름이어야 합니다. 이 예제에서는 이름이 pingpong.lua인 파일이므로 대결 모듈은 pingpong입니다.
  • Go 및 _TypeScript_의 경우 대결 핸들러 함수의 등록된 이름이어야 합니다. 위의 예에서 InitModule 함수에서 initializer.RegisterMatch을(를) 호출할 때 pingpong(으)로 등록했습니다.

대결 가입 #

플레이어는 매치메이커가 대결시킨 후에도 참가할 때까지 대결에 참가하는 것은 아닙니다. 이를 통해 플레이어는 플레이하지 않기로 결정한 대결에서 나올 수 있습니다.

이는 중계된 멀티플레이어와 동일한 방식으로 클라이언트에서 수행할 수 있습니다. 이 작업을 수행하는 방법에 대한 전체 예는 여기를 참조하세요.

대결 종료 #

사용자는 언제든지 대결을 종료할 수 있습니다. 이는 중계된 멀티플레이어와 동일한 방식으로 클라이언트에서 수행할 수 있습니다. 이 작업을 수행하는 방법에 대한 전체 예는 여기를 참조하세요.

대결을 탈퇴할 때 LeaveMatch 수명 대결 핸들러 함수가 호출되고 플레이어가 대결을 탈퇴했는지 아니면 연결이 끊겼는지에 대한 이유가 추가됩니다. 연결이 끊긴 경우 임시로 자리를 보존할 수 있습니다.

중계 대결과 달리, 권한 보유 대결의 경우 모든 플레이어가 탈퇴하여도 종료되지 않습니다. 이는 정상적인 현상이며 게임 세계가 계속 진행하는 동안 플레이어가 일시적으로 연결을 끊을 수 있는 사용 사례를 지원할 수 있도록 만든 것입니다.

권한 보유 대결 핸들러는 콜백 중 하나라도 nil 상태를 반환할 때만 중지됩니다. 게임에 연결된 플레이어가 있는지 여부에 관계없이 대결이 진행되는 동안 언제든지 이 작업을 수행하도록 선택할 수 있습니다.

대결 마이그레이션 #

Nakama 회사만

Nakama 인스턴스의 정상적인 종료 시작으로 인해 대결이 종료되면 이 유예 기간을 사용하여 플레이어를 새 대결로 마이그레이션할 수 있습니다.

먼저 새로운 대결을 만들거나 대결 목록을 통해 참여할 수 있는 기존 대결을 찾습니다. 그런 다음 이 새로운 대결 정보가 포함된 발송자 브로드캐스트를 영향을 받는 클라이언트에 보냅니다. 마지막으로 현재 대결에서 해당 플레이어가 나가기를 기다리거나 필요한 경우 강제로 추방할 수 있습니다.

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
// Define an op code for sending a new match id to remaining presences
const newMatchOpCode = 999

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{} {
	logger.Debug("match will terminate in %d seconds", graceSeconds)

	var matchId string

	// Find an existing match for the remaining connected presences to join
	limit := 1
	authoritative := true
	label := ""
	minSize := 2
	maxSize := 4
	query := "*"
	availableMatches, err := nk.MatchList(ctx, limit, authoritative, label, minSize, maxSize, query)
	if err != nil {
		logger.Error("error listing matches", err)
		return nil
	}

	if len(availableMatches) > 0 {
		matchId = availableMatches[0].MatchId
	} else {
		// No available matches, create a new match instead
		matchId, err = nk.MatchCreate(ctx, "match", nil)
		if err != nil {
			logger.Error("error creating match", err)
			return nil
		}
	}

	// Broadcast the new match id to all remaining connected presences
	data := map[string]string{
		matchId: matchId,
	}

	dataJson, err := json.Marshal(data)
	if err != nil {
		logger.Error("error marshaling new match message")
		return nil
	}
	
	dispatcher.BroadcastMessage(newMatchOpCode, dataJson, nil, nil, true)

	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
// Define an op code for sending a new match id to remaining presences
const newMatchOpCode = 999;

const matchTerminate = 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.`);

  let matchId = null;
  
  // Find an existing match for the remaining connected presences to join
  const limit = 1;
  const authoritative = true;
  const label = "";
  const minSize = 2;
  const maxSize = 4;
  const query = "*";
  const availableMatches = 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
  };
}
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
-- Define an op code for sending a new match id to remaining presences
local new_match_op_code = 999

function M.match_terminate(context, dispatcher, tick, state, grace_seconds)
	local message = "Server shutting down in " .. grace_seconds .. " seconds"

	local match_id

	-- Find an existing match for the remaining connected presences to join
	local limit = 1;
	local authoritative = true;
	local label = "";
	local min_size = 2;
	local max_size = 4;
	local query = "*";
	local available_matches = nk.match_list(limit, authoritative, label, min_size, max_size, query);

	if #available_matches > 0 then
		match_id = available_matches[0].match_id;
	else
		-- No available matches, create a new match instead
		match_id = nk.match_create("match", { invited = state.presences });
	end

	-- Broadcast the new match id to all remaining connected presences
	dispatcher.broadcast_message(new_match_op_code, nk.json_encode({ ["matchId"] = match_id }))

	return state
end

모범 사례 #

대결 상태 데이터 저장 #

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
type LobbyMatchState struct {
	presences  map[string]runtime.Presence
	started bool
}

func (m *LobbyMatch) MatchInit(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, params map[string]interface{}) (interface{}, int, string) {
	state := &LobbyMatchState{
		presences:  map[string]runtime.Presence{},
		started: false,
	}
	tickRate := 1
	label := ""
	return state, tickRate, label
}

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{} {
	lobbyState, ok := state.(*LobbyMatchState)
	if !ok {
		logger.Error("state not a valid lobby state object")
	}
	
  if (len(lobbyState.presences) > 2) {
		lobbyState.started = true;
	}

	return lobbyState
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const matchInit = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, params: {[key: string]: string}): {state: nkruntime.MatchState, tickRate: number, label: string} {
  return {
    state: { presences: {}, started: false },
    tickRate,
    label: ''
  };
};

const matchLoop = 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 (state.presences.length > 2) {
    state.started = true;
  }

  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
local M = {}

function M.match_init(context, initial_state)
	local state = {
		presences = {},
		started = false
	}
	local tick_rate = 1
	local label = ""

	return state, tick_rate, label
end

function M.match_loop(context, dispatcher, tick, state, messages)
  -- Get the count of presences in the match
  local totalPresences = 0
  for k, v in pairs(state.presences) do
    totalPresences = totalPresences + 1
  end

	if totalPresences > 2 then
		state.started = true
	end

	return state
end

Related Pages