GodotヘッドレスサーバーとNakamaを統合する
はじめに
「Nakama」、すなわち仲間は、日本語で「友達」を意味し、マルチプレイヤー機能を示唆しています。NakamaはオープンソースのBaaSサーバーソフトウェアです。自分のサーバーにデプロイすることで、すぐに無料のBaaSフレームワークを利用できます。さらに、NakamaはUnity、Unreal、Godot、Cocos2d、Defoldなど、さまざまなクライアントSDKを提供しています。Nakamaを使用することで、マルチプレイヤーゲーム開発の時間を節約できます。

異なるマルチプレイヤーゲームは異なるゲームプレイを持っています。Nakamaは、プラグインシステムを使用してサーバーコードを書くことを可能にします。現在、Luaランタイム、Goランタイム、JavaScriptランタイムを提供しています。プラグインやモジュールを書くことができ、Nakamaはランタイムでそれらをロードします。たとえば、Nakamaが毎サーバーフレームで呼び出すmatchLoopコールバック内にシンプルなサーバーロジックを書くことができます。このプラグインシステムは、ターン制カードゲーム、シンプルな2Dゲーム、チャットルームなどの軽量なシナリオには十分です。物理学のような複雑なサーバーロジックはどうでしょうか?ゲームエンジンによってエクスポートされた専用サーバーとNakamaを組み合わせることはどうでしょうか?
実際、Nakamaはそれを可能にします。
物理演算をサーバー側で実行したい複雑なゲームプレイの場合(例:Unityのヘッドレスインスタンス)。Nakamaは、オーケストレーションレイヤーを介してこれらのヘッドレスインスタンスを管理し、マッチメイキング、マッチ完了時のプレイヤーの移動、マッチ結果の報告に使用できます。
ただし、ドキュメントではこのトピックについて詳しく説明されていません。具体的にどうするかの例やガイドはありません。
この記事では、Godotエンジンによってエクスポートされた専用サーバーとNakamaの統合について説明します。すべてのサーバーコードはTypeScriptで書かれています。この記事では、専用サーバーのエクスポートの詳細(その情報はGodot Docsで入手できます)、マルチプレイヤーゲーム開発の詳細、GodotサーバーのDocker化やNakamaサーバーのセットアップ方法については触れません。これらのトピックはこの記事の範囲を超えています。別のゲームエンジンを使用している場合でも、この記事は役立つはずです。
コミュニケーションの概要
私たちは、プレイヤーのデバイス上のクライアントとサーバー上のクライアントの2種類のNakamaクライアントを持っています。専用サーバーは本質的にNakamaのクライアントでもあります。すべての通信は4つの方法で実装されています:
- NakamaクライアントとNakamaサーバー間のRPC呼び出し;
- NakamaクライアントとNakamaサーバー間のマッチ状態メッセージのブロードキャスト;
- GodotクライアントとGodotサーバー間のRPC呼び出し;
- NakamaクライアントとNakamaサーバー間のNakama HTTP API呼び出し。

マッチの作成
これは、初期マッチデータをストレージに挿入する必要があるため、直接のクライアントHTTP API呼び出しの代わりにNakama RPCを呼び出すことで行います。たとえば、ルームの状態(ゲームサーバーはすでに開始されていますか?プレイヤーが準備できるのを待っていますか?)、コンテナのホストとポート、プレイヤーが定義したルーム名などです。データを挿入した後、サーバーランタイムで提供されるAPIを使用してマッチを作成し、マッチIDをクライアントに返してプレイヤーがマッチに参加できるようにします。
ユーザーIDはデフォルトのシステムユーザーID 00000000-0000-0000-0000-000000000000です。これらのオブジェクトは、プレイヤーによって変更されることはありません。
'room'はモジュール名であり、異なるモジュールを登録することもできます。上記で述べたように、Nakamaサーバーのセットアップ方法の詳細はこの記事には含まれていません。この点について不明な場合は、Nakamaのドキュメントを読むべきです。
// サーバーコード
export const createMatch: nkruntime.RpcFunction = function (
ctx: nkruntime.Context,
logger: nkruntime.Logger,
nk: nkruntime.Nakama,
payload: string
) {
try {
const match_name = JSON.parse(payload).match_name
const match_id = nk.matchCreate('room')
const newMatchInfo: TrodMatchInfo = {
host: '',
port: '',
matchId: match_id,
containerId: '',
status: 'preparing',
kickIds: [],
roomOwnerId: ctx.userId,
roomName: match_name,
}
nk.storageWrite([
{
collection: TrodMatchInfoCollectionKey,
key: match_id,
userId: SystemUserId,
value: newMatchInfo,
},
])
logger.info('新しいマッチ情報が書き込まれました')
return JSON.stringify({ match_id, match_name })
} catch (err) {
logger.error(err.message)
throw err
}
}
マッチループをルームハンドラーとして使用
マッチに参加した後、クライアントとサーバーはマッチ状態メッセージを介して通信できます。
サーバーコード
// サーバーコード
export 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 {
logger.debug('ルームマッチループが実行されました')
messages.forEach(function (message) {
logger.info('受信した %v から %v', message.data, message.sender.userId)
if (message.opCode === MatchOpCode.ReadyStateUpdatedYes) {
// ...
}
// ...
})
// ...
}
クライアントコード
# クライアントコード
# ...どこかで
Network.socket.connect("received_match_state", self, "_on_match_state")
# ...
func _on_match_state(match_state: NakamaRTAPI.MatchData):
if match_state.op_code == GameData.OpCode.ReadyStateUpdatedYes:
pass
# ...マッチ状態メッセージを処理
オペコードはクライアントとサーバーの両方で定義する必要があり、値は同じでなければなりません。以下は、私が定義したものです。
// サーバーコード
export enum MatchOpCode {
StartGameServer = 0,
GameServerInited,
StartGameServerFailed,
ReadyStateUpdatedYes,
ReadyStateUpdatedNo,
ReadyStatesBroadcast,
Kicked,
UsernameUpdated,
RoomNameUpdated,
GameServerStarting,
}
たとえば、プレイヤーがクライアントで準備ボタンを押すと、マッチ状態メッセージを発信します。
func _on_ready_button_clicked():
AudioManager.play_click_sound()
imready = not imready
var op_code = GameData.OpCode.ReadyStateUpdatedYes if imready else GameData.OpCode.ReadyStateUpdatedNo
yield(Network.socket.send_match_state_async(match_id, op_code, JSON.print({})), "completed")
そしてサーバーは他のクライアントにブロードキャストします
// server code inside matchLoop
if (message.opCode === MatchOpCode.ReadyStateUpdatedYes) {
state.readyStates[message.sender.userId] = true
readyStatesUpdated = true
} else if (message.opCode === MatchOpCode.ReadyStateUpdatedNo) {
state.readyStates[message.sender.userId] = false
readyStatesUpdated = true
}
//...
if (readyStatesUpdated) {
dispatcher.broadcastMessage(
message.opCode,
message.data,
null,
message.sender
)
}
その後、クライアントはローカルで準備状態を更新するか、UIに「私は準備ができています」と表示できます。
ルームオーナー(私の場合、ルームオーナーのみがゲームを開始できます)がスタートボタンを押すと、上記の例のように他のクライアントにメッセージをブロードキャストします。そして、もっと重要なのは、ゲームサーバーコンテナを作成して開始する必要があります。
サーバーコンテナの生成
ゲームサーバーをすでにDocker化していると仮定しますが、ゲームサーバーを実行する唯一の方法はDocker HTTP APIを通じてです。NakamaのJavaScriptランタイムはbashコマンドを実行することをサポートしていません。Nakamaはスケーラブルに設計されており、通常はコンテナ内で実行されるため、今後もサポートされないかもしれません。
使用可能なポートを見つける
実際、TypeScriptランタイムの制限のため、ポートが使用可能かどうかはわかりません。そこで、使用中のポートをストレージに保存します。コンテナを作成するたびに、保存されていないポート、他のサービスによって占有されていないポートのみを使用します。Nakamaを開始する前にこれらのポートを割り当てます(以下のretainedPorts)。
// server code
const canUsePort = function (
port: string,
logger: nkruntime.Logger,
nk: nkruntime.Nakama
): boolean {
try {
if (retainedPorts.indexOf(port) !== -1) {
return false
}
const objects = nk.storageRead([
{ collection: TrodPortsKey, key: port, userId: SystemUserId },
])
if (objects.length === 0) {
return true
}
return false
} catch (err) {
logger.error(err.message)
return false
}
}
上記のcanUsePortを使用して使用可能なポートを取得します。
// server code inside startGameServer function
let port = getRandomPort()
let triedTimes = 0
const tryingCount = 20
while (!canUsePort(port, logger, nk)) {
port = getRandomPort()
triedTimes++
if (triedTimes > tryingCount) {
throw new Error('使用可能なポートを割り当てられません')
}
}
この解決策は完璧ではありません。Nakamaを開始した後に他のサービスを開始する可能性があります。偶然にも、ポートが占有されているためにコンテナを作成できない場合があり、プレイヤーはエラーメッセージを見ることになります。
Godotサーバーにパラメータを渡す
コンテナ作成APIのコマンドラインを使用して、NakamaからGodotサーバーにパラメータを渡すことができます。これらのパラメータは、Godotのゲームエンジンが提供するAPIを通じてGodotサーバーによって取得できます。私の場合、以下の値を渡します:
// server code inside startGameServer function
const cmd = [
`--server-port=${port}`,
`--match-id=${matchId}`,
`--max-players=${maxPlayers}`,
`--imposters-count=${maxImposters}`,
]
const containerId = createContainer(
ImageName,
port,
cmd,
`game-server-${matchId}-${port}`,
options
)
nkオブジェクトを介してHTTP呼び出しを行う
// server code
export const createContainer = function (
image: string,
port: string,
cmd: string[],
name: string,
options: InternalCallOptions
) {
const { nk, logger, ctx } = options
const dockerAPIUrl = `${DockerAPI.scheme}${ctx.env['DOCKER_HOST']}:${DockerAPI.port}`
const url = `${dockerAPIUrl}/v1.41/containers/create?name=${name}`
const headers = { 'Content-Type': 'application/json' }
const body = JSON.stringify({
Image: image,
Tty: true,
ExposedPorts: {
[`${port}/tcp`]: {},
[`${port}/udp`]: {},
},
Cmd: cmd,
HostConfig: {
PortBindings: {
[`${port}/tcp`]: [
{
HostIP: '0.0.0.0',
HostPort: port,
},
],
[`${port}/udp`]: [
{
HostIP: '0.0.0.0',
HostPort: port,
},
],
},
},
})
try {
const response = nk.httpRequest(url, 'post', headers, body)
if (response.code === 201) {
const dockerCreateResult: { Id: string; Warnings: Array<any> } =
JSON.parse(response.body)
logger.info('コンテナ %q が作成されました', dockerCreateResult.Id)
return dockerCreateResult.Id
}
throw new Error(JSON.stringify(response.body))
} catch (err) {
logger.error('コンテナ作成中にエラーが発生しました, %q', err.message)
throw err
}
}
サーバーの初期化を待つ
Godotサーバーは、レベルリソースの読み込みなど、時間のかかるブートストラップ処理を行う場合があります。Godotサーバーは、プレイヤーが参加できる準備ができたときにNakamaに通知します。その後、プレイヤーは接続できます。
# client code in godot server
func _on_server_init_completed():
print("_on_server_init_completed")
if GameData.server_match_id == "":
push_error("サーバーは初期化されましたが、マッチIDがありません")
return
var result: NakamaAPI.ApiRpc = yield(Network.socket.rpc_async(
"game_server_inited",
JSON.print({ "match_id": GameData.server_match_id })
), "completed")
print("game_server_inited rpcが呼び出されました")
if result.is_exception():
push_error(result.get_exception().message)
ストレージ内のマッチ情報のステータスを変更し、その後matchLoop内でクライアントにメッセージをブロードキャストします。これが、Match State Messageを発信できる唯一の場所です。RPC関数からmatchLoopに情報を転送しています。この方法で多くの機能を実装する必要があります。プレイヤーをキックすることも含まれます。> 注意: NakamaのJavaScriptランタイムで開発する際は、GojaとNakamaによってもたらされる制限を理解する必要があります。例えば、イベントループはありません。JavaScriptランタイムはNakamaによってプールされるため、グローバル変数は使用できません。
// サーバーコード
export const gameServerInited: nkruntime.RpcFunction = function (
ctx: nkruntime.Context,
logger: nkruntime.Logger,
nk: nkruntime.Nakama,
payload: string
) {
try {
if (!isPrivateIP(ctx.clientIp)) {
throw new Error('ヘッドレスサーバーはNakamaによってのみ実行できます')
}
const obj = JSON.parse(payload)
const matchId = obj.match_id
const objects: nkruntime.StorageObject[] = nk.storageRead([
{
collection: TrodMatchInfoCollectionKey,
key: matchId,
userId: SystemUserId,
},
])
if (objects.length !== 1) {
throw new Error('matchinfosの読み取り結果が正しくありません')
}
const value = objects[0].value as TrodMatchInfo
value.status = 'inited'
nk.storageWrite([
{
collection: TrodMatchInfoCollectionKey,
key: matchId,
userId: SystemUserId,
value,
},
])
logger.info('ゲームサーバー %q が初期化されました', value.containerId)
} catch (err) {
logger.error(err.message)
throw err
}
}
マッチ情報の状態をストレージで確認し、必要に応じてプレイヤーのクライアントにGodotサーバーに接続するよう指示します。
// matchLoop内のサーバーコード
// ストレージから値としてマッチ情報を取得...
if (value.status === 'inited' && value.host && value.port) {
logger.info(JSON.stringify(value))
dispatcher.broadcastMessage(
MatchOpCode.GameServerInited,
JSON.stringify({
host: value.host,
port: Number(value.port),
})
)
value.status = 'broadcasted'
nk.storageWrite([
{
collection: TrodMatchInfoCollectionKey,
userId: SystemUserId,
key: ctx.matchId,
value,
},
])
logger.info('gameserverinitedがブロードキャストされました')
}
サーバーが準備完了の通知を受けた後にGodotサーバーに接続します。
# クライアントコード
# ...
elif match_state.op_code == GameData.OpCode.GameServerInited:
var result := JSON.parse(match_state.data)
if result.error_string == "":
print(result.result)
var host: String = result.result["host"]
var port: int = result.result["port"]
Network.room.connect_to_game_server(host, port)
else:
GameData.show_toast(result.error_string)
# ...
アイデンティティの検証
ユーザーがログインすると、クライアントHTTP APIを使用してそのトークンをストレージに保存します。プレイヤーがサーバーに接続すると、サーバーはプレイヤーのトークンを使ってNakamaにRPCを呼び出し、Nakamaはストレージ内のトークンを確認し、結果をサーバーに返します。具体的には:
- GodotクライアントがGodotサーバーに接続
- GodotサーバーがGodot RPC呼び出しを通じてユーザートークンを要求
- クライアントが応答
- GodotがNakama RPC呼び出しを通じてトークンを検証
# Godotサーバー内のクライアントコード
func _verify_peer_id(id: int) -> bool:
print("検証中 ", id)
if not _cached_player_session.has(id):
print("セッションがキャッシュされていないため、検証に失敗")
return false
var result: NakamaAPI.ApiRpc = yield(Network.socket.rpc_async("validate_token", JSON.print(({
"user_id": _cached_player_session[id]["user_id"],
"token": _cached_player_session[id]["token"],
}))), "completed")
if result.is_exception():
print("トークンが一致しないため、検証に失敗")
return false
return true
- 成功した場合、キャラクターをゲームワールドに追加
- 失敗またはタイムアウトした場合、クライアントピアとの接続を切断します。このクライアントとサーバー間の他のRPCはその間許可されません
注意すべき点は、呼び出し元がGodotサーバーであることを検証する必要があることです。悪意のあるGodotクライアントがGodotサーバー専用のRPCを呼び出すことを許可しません。これをどうやって行うかというと、ctx.clientIpをチェックしてLANからのみこのRPCを呼び出すことを許可します:
// サーバーコード
export const validateToken: nkruntime.RpcFunction = function (
ctx: nkruntime.Context,
logger: nkruntime.Logger,
nk: nkruntime.Nakama,
payload: string
) {
try {
if (!isPrivateIP(ctx.clientIp)) {
throw new Error('ヘッドレスサーバーはNakamaによってのみ実行できます')
}
const obj = JSON.parse(payload)
const userId = obj.user_id
const token = obj.token
if (!userId || !token) {
throw new Error('検証に失敗')
}
const objects = nk.storageRead([
{ collection: TrodTokensKey, key: userId, userId },
])
if (objects.length !== 1) {
throw new Error('検証に失敗')
}
if (objects[0].value['value'] !== token) {
throw new Error('検証に失敗')
}
logger.info('ユーザー %q を検証', userId)
return JSON.stringify({
success: true,
})
} catch (err) {
logger.error(err)
throw err
}
}
ゲーム終了とゲーム結果の収集
プレイヤーがNakamaに参加した後、すべての通信はGodotクライアントとGodotサーバー間で行われるべきです。Nakamaの有無にかかわらず、この部分に違いはありません。
ゲームが結果を生成したとき、GodotサーバーはNakamaを通じてNakama RPC呼び出しを行い、通知できます。これは何度も示した通りです。Nakamaは、ユースケースに応じてゲーム記録をストレージに保存できます。
最も重要なのは、適切なタイミングでDocker HTTP APIを通じてコンテナを削除し、ストレージからポートを削除することです。
// サーバーコード
export const removeContainer = function (
id: string,
options: InternalCallOptions
) {
const { nk, logger, ctx } = options
const dockerAPIUrl = `${DockerAPI.scheme}${ctx.env['DOCKER_HOST']}:${DockerAPI.port}`
const url = `${dockerAPIUrl}/v1.41/containers/${id}?force=true`
const headers = { 'Content-Type': 'application/json' }
try {
logger.info(`removeContainer ${id}`)
const response = nk.httpRequest(url, 'delete', headers)
if (response.code !== 204 && response.code !== 404) {
const body: { message: string } = JSON.parse(response.body)
throw new Error(body.message)
}
logger.info('container %q removed', id)
} catch (err) {
logger.error('error occurred while removing container, %q', err.message)
throw err
}
}
注意: Nakamaは3.15.0以降、HTTP DELETEメソッドをサポートしています。
関連リンク
https://heroiclabs.comhttps://heroiclabs.com/docs/nakama/concepts/multiplayer/https://heroiclabs.com/docs/nakama/client-libraries/godot/index.htmlhttps://heroiclabs.com/docs/nakama/server-framework/typescript-runtime/https://forum.heroiclabs.com/t/documentation-for-headless-instance-orchestration-layer/2638https://forum.heroiclabs.com/t/multiplayer-game-with-physics/270/2https://github.com/heroiclabs/nakama-docs/issues/161https://docs.godotengine.org/en/stable/tutorials/networking/high_level_multiplayer.htmlhttps://stackoverflow.com/questions/13969655/how-do-you-check-whether-the-given-ip-is-internal-or-not