Netcode for GameObjectsの入門 で、2つのウィンドウのキャラクターが同期して動いた瞬間は感動的です。でも次の瞬間、現実が来ます——「これ、どうやって 友達の家と つなぐの?」。IPアドレスを教えても、つながらない。localhostで完璧に動くコードが、インターネットの前で沈黙する。
原因はコードではなく ネットワークの構造 にあります。この記事では、Unity Gaming Services(UGS)の Relay(通信の中継)と Lobby(合言葉で入れる部屋)を使って、 ルーターの設定なしで友達と遊べる 最小のオンライン協力セッションを組み上げます。
この記事でわかること
- localhostでは動くのに インターネットでつながらない理由(NAT)
- Lobby・Relay・NGO の役割分担
- UGSの初期化と 匿名サインイン
- Hostの Relay割り当てとjoinコード 発行、Clientの参加
- 合言葉(ロビーコード) で入れる部屋と、ハートビート
- 実践:合言葉で2人協力部屋を完成させる
- 友達が 切断した時 の後始末
動作確認環境: Unity 6 (6000.0)・Netcode for GameObjects 2.x・Relay 1.1+・Lobby 1.2+(2026-07時点。UGSのAPIは更新が活発なため、動かない場合は各パッケージの公式ドキュメントを確認してください)
なぜインターネットではつながらないのか
localhost(127.0.0.1)やLAN内の接続は、いわば 同じ家の中の会話 です。ところがインターネット越しの接続では、あなたと友達の間に、それぞれの家の ルーター(NAT) が立ちはだかります。
NATは「家の中から外への通信」は通しますが、 「外から突然やってくる接続」は不審者として遮断します。NGOのHostはサーバーとして接続を「待ち受ける」側なので、友達のClientからの接続はあなたの家のルーターで止まる——これが「IPを教えてもつながらない」の正体です。

解決策が Relay(中継) です。HostもClientも、Unityが運用する中継サーバーへ 自分から外向きに 接続します。外向きの通信はNATが通すので、ルーターの設定は一切不要。両者の通信はすべて中継サーバー経由で流れます。ポート開放を友達に頼む必要も、固定IPも要りません。
3つの道具の役割分担
オンラインセッションには3つの道具が登場します。名前が似ていて混乱しやすいので、役割を先に固定しましょう。

| 道具 | 役割 | たとえるなら |
|---|---|---|
| Lobby | 部屋の情報と合言葉を管理する | 会場の受付・看板 |
| Relay | 通信をNAT越しに中継する | 電話の交換局 |
| NGO | ゲーム内の位置や状態を同期する | 会話そのもの |
流れはこうです。 HostがRelayの中継枠を確保 → その入場券(joinコード)をLobbyの部屋に貼っておく → Clientは合言葉(ロビーコード)で部屋に入り、入場券を受け取ってRelayへ接続 → あとはNGOがいつも通り同期する。NGOのコード(NetworkVariableやRPC)は、ローカルのときと1文字も変わりません。変わるのは「接続の仕方」だけです。
準備:UGSの初期化と匿名サインイン
Package Managerから Relay と Lobby のパッケージを追加し、Project Settings > Services でプロジェクトをUGSにリンクします(Unityアカウントのプロジェクト作成が必要です。ダッシュボードでRelayとLobbyを有効化してください)。
コード側の最初の仕事は、UGSの初期化とサインインです。UGSのAPIはすべて async/await で書きます——コルーチンとasync/awaitの違い が曖昧な場合は先に一読を。
using Unity.Services.Authentication;
using Unity.Services.Core;
using UnityEngine;
public static class UgsBootstrap
{
public static async System.Threading.Tasks.Task EnsureSignedInAsync()
{
// 1. UGS全体を初期化(アプリ起動後に1回)
if (UnityServices.State != ServicesInitializationState.Initialized)
{
await UnityServices.InitializeAsync();
}
// 2. 匿名サインイン(アカウント登録なしでプレイヤーIDが発行される)
if (!AuthenticationService.Instance.IsSignedIn)
{
await AuthenticationService.Instance.SignInAnonymouslyAsync();
Debug.Log("サインイン完了: " + AuthenticationService.Instance.PlayerId);
}
}
}
匿名サインイン は 、メールアドレスもパスワードも要らない「その場で発行される使い捨てID」です。RelayもLobbyも、リクエストの主が誰かを識別するためにこのIDを要求します。個人開発のオンライン対応では、まず匿名で始めるのが定番です。
Host側:Relayを確保してjoinコードを作る
Hostの仕事は2つ。 中継枠の確保(allocation) と joinコードの発行 です。
using Unity.Netcode;
using Unity.Netcode.Transports.UTP;
using Unity.Networking.Transport.Relay;
using Unity.Services.Relay;
using Unity.Services.Relay.Models;
public static async System.Threading.Tasks.Task<string> StartHostWithRelayAsync(int maxClients)
{
// 1. Relayに「最大◯人ぶんの中継枠をください」と頼む
Allocation allocation = await RelayService.Instance.CreateAllocationAsync(maxClients);
// 2. この枠への入場券(joinコード)を発行してもらう
string joinCode = await RelayService.Instance.GetJoinCodeAsync(allocation.AllocationId);
// 3. NGOのトランスポートに「直接ではなくRelay経由で通信して」と設定する
var transport = NetworkManager.Singleton.GetComponent<UnityTransport>();
transport.SetRelayServerData(new RelayServerData(allocation, "dtls"));
// 4. あとはいつも通りHostを開始
NetworkManager.Singleton.StartHost();
return joinCode;
}
maxClients は Host自身を除いた 接続数です(2人协力なら1)。"dtls" は暗号化された通信方式の指定で、特別な理由がなければこのままで構いません。返ってきた joinCode は6文字ほどの英数字で、 これを知っている人だけがこの中継枠に入れます。
Client側:joinコードで参加する
Client側は、もらったjoinコードで同じ中継枠に参加するだけです。
public static async System.Threading.Tasks.Task JoinWithRelayAsync(string joinCode)
{
// 1. joinコードを使って、Hostが確保した中継枠に参加する
JoinAllocation joinAllocation = await RelayService.Instance.JoinAllocationAsync(joinCode);
// 2. トランスポートへRelay経由の設定を渡す(Client用のallocationを使う)
var transport = NetworkManager.Singleton.GetComponent<UnityTransport>();
transport.SetRelayServerData(new RelayServerData(joinAllocation, "dtls"));
// 3. いつも通りClientを開始
NetworkManager.Singleton.StartClient();
}

実はこの時点で、Discordなどでjoinコードを送り合えば もう友達と遊べます。ただ、毎回6文字のコードをコピペしてもらうのは少し無骨です。そこで、部屋の管理 をLobbyに任せます。
Lobby:joinコードを「合言葉」に変える
Lobbyは 部屋のデータベース です。部屋には名前・定員・任意のデータを持たせられ、 ロビーコード(合言葉) で参加できます。ここに「RelayのjoinコードをメモしたLobby」を作れば、プレイヤー同士はロビーコードだけ共有すればよくなります。
Lobbyには1つだけ独特の作法があります。 ハートビート です。放置された部屋が溜まらないよう、一定時間pingが来ない部屋は自動削除されます。Hostは部屋が生きている間、定期的に「まだいるよ」と打ち続ける必要があります(15〜20秒間隔が目安)。
実践:合言葉で入れる2人協力部屋
部品を1つのマネージャーにまとめます。「部屋を作る」を押すとロビーコードが画面に出て、友達がそれを入力すると同じゲームに入ってくる——オンライン協力の骨格そのものです。

// OnlineSession.cs — シーンに1つ置く(NetworkManagerと同じオブジェクトでOK)
using System.Collections;
using Unity.Netcode;
using Unity.Netcode.Transports.UTP;
using Unity.Networking.Transport.Relay;
using Unity.Services.Lobbies;
using Unity.Services.Lobbies.Models;
using Unity.Services.Relay;
using Unity.Services.Relay.Models;
using UnityEngine;
public class OnlineSession : MonoBehaviour
{
public string LobbyCode { get; private set; } // UIに表示する合言葉
private Lobby currentLobby;
// ---- Host: 部屋を作る ----
public async void CreateRoom()
{
await UgsBootstrap.EnsureSignedInAsync();
// Relayの中継枠を確保してjoinコードを得る(2人協力=Client1人)
Allocation allocation = await RelayService.Instance.CreateAllocationAsync(1);
string relayJoinCode = await RelayService.Instance.GetJoinCodeAsync(allocation.AllocationId);
// Relayのjoinコードをメモした部屋(Lobby)を作る
var options = new CreateLobbyOptions
{
Data = new System.Collections.Generic.Dictionary<string, DataObject>
{
{ "relayJoinCode", new DataObject(DataObject.VisibilityOptions.Member, relayJoinCode) }
}
};
currentLobby = await LobbyService.Instance.CreateLobbyAsync("CoopRoom", 2, options);
LobbyCode = currentLobby.LobbyCode; // ←これが友達に伝える合言葉
// 部屋が消えないようハートビートを打ち続ける
StartCoroutine(HeartbeatLoop());
// Relay経由でHost開始
var transport = NetworkManager.Singleton.GetComponent<UnityTransport>();
transport.SetRelayServerData(new RelayServerData(allocation, "dtls"));
NetworkManager.Singleton.StartHost();
Debug.Log("部屋を作りました。合言葉: " + LobbyCode);
}
// ---- Client: 合言葉で入る ----
public async void JoinRoom(string lobbyCode)
{
await UgsBootstrap.EnsureSignedInAsync();
// 合言葉で部屋に入り、メモしてあったRelayのjoinコードを受け取る
currentLobby = await LobbyService.Instance.JoinLobbyByCodeAsync(lobbyCode);
string relayJoinCode = currentLobby.Data["relayJoinCode"].Value;
// Relayへ参加してClient開始
JoinAllocation joinAllocation = await RelayService.Instance.JoinAllocationAsync(relayJoinCode);
var transport = NetworkManager.Singleton.GetComponent<UnityTransport>();
transport.SetRelayServerData(new RelayServerData(joinAllocation, "dtls"));
NetworkManager.Singleton.StartClient();
}
// ---- Host: 部屋の生存報告 ----
IEnumerator HeartbeatLoop()
{
while (currentLobby != null)
{
LobbyService.Instance.SendHeartbeatPingAsync(currentLobby.Id);
yield return new WaitForSeconds(15f);
}
}
// ---- 後始末(次の節で使う)----
public async void LeaveRoom()
{
if (currentLobby != null)
{
try
{
if (currentLobby.HostId == Unity.Services.Authentication.AuthenticationService.Instance.PlayerId)
{
await LobbyService.Instance.DeleteLobbyAsync(currentLobby.Id); // ホストは部屋ごと畳む
}
else
{
await LobbyService.Instance.RemovePlayerAsync(
currentLobby.Id,
Unity.Services.Authentication.AuthenticationService.Instance.PlayerId);
}
}
catch (LobbyServiceException e)
{
Debug.LogWarning("Lobbyの後始末に失敗: " + e.Message);
}
currentLobby = null;
}
if (NetworkManager.Singleton.IsListening)
{
NetworkManager.Singleton.Shutdown();
}
}
}
UIは最小で構いません。「部屋を作る」ボタンに CreateRoom を、合言葉の入力欄+「参加」ボタンに JoinRoom をつなぎ、LobbyCode をTextに表示するだけです。
テストは、ビルドを1つ作ってエディタと並べて動かします。 本当の検証は、友達に渡して別のネットワークから入ってもらうこと——ホスト画面に出た6文字を送って、相手のキャラクターがあなたの画面に現れたら、NAT越えは成功です。もし JoinLobbyByCodeAsync で例外が出るなら、合言葉の打ち間違い(LobbyCodeは大文字小文字を区別します)か、ハートビート切れで部屋が消えたかのどちらかがほとんどです。
ポイントは2つ。 NGOの同期コードは1文字も変えていない(変わったのは接続の入口だけ)。 プレイヤーに見せるのはロビーコードだけ(Relayのjoinコードは部屋のデータの中に隠れている)。
切断の後始末
オンラインの現実は「つなぐ」より「切れた後」です。友達が回線落ちしたとき、無言でキャラクターだけが固まるのは最悪の体験です。NGOの切断コールバックで検知して、表示と片付けを行います。
void OnEnable()
{
NetworkManager.Singleton.OnClientDisconnectCallback += HandleDisconnect;
}
void OnDisable()
{
if (NetworkManager.Singleton != null)
{
NetworkManager.Singleton.OnClientDisconnectCallback -= HandleDisconnect;
}
}
void HandleDisconnect(ulong clientId)
{
if (!NetworkManager.Singleton.IsHost)
{
// 自分がClientでこれが呼ばれた=Host側との接続が切れた
Debug.Log("ホストとの接続が切れました。タイトルへ戻ります");
LeaveRoom();
// ここでタイトルSceneへ遷移し、メッセージを表示する
}
else
{
// Hostとして、参加者の離脱を検知した
Debug.Log("プレイヤーが退出しました: " + clientId);
// 「フレンドが退出しました」の表示など
}
}
最低限のルールはこうです。 Clientはホスト切断=セッション終了として即タイトルへ(NGOのホストはサーバー兼任なので、続行はできません)。 Hostは参加者の離脱を表示し、部屋は開けたまま次を待つ。そして自分から抜けるときは、さっきの LeaveRoom で Lobbyを畳んでからShutdown——これを忘れると、誰もいない幽霊部屋がハートビート切れまで残り続けます。
おまけ:先に知っておくと良いこと
- 料金と無料枠: RelayもLobbyも同時接続数ベースの従量課金で、無料枠があります。開発と友達とのテストならまず収まりますが、 公開前に必ず公式の料金ページで現在の条件を確認 してください(数値は変わるため本文には書きません)
- リージョン: Relayの割り当てはリージョン(地域)を指定できます。未指定ならUGSが選びますが、日本のプレイヤー同士なら遅延の面でアジアリージョンの明示指定を検討する価値があります
- Unity 6の新しい道: Unity 6世代では、Lobby+Relay+NGOの配線を1つにまとめた Multiplayer Services(Sessions API) パッケージが登場しています。この記事の「素の配線」を理解しておくと、Sessionsが何を自動化してくれているのかが読めるようになります
- この先の学習: 動きの カクつきが気になり始めたら、補間・予測・ラグ補償の世界です。まずはNGOの
NetworkTransformの補間設定から触ってみてください
まとめ
- インターネットでつながらないのは NATが外からの接続を遮断する から。 Relay は両者が外向きに接続することでこれを回避する
- 役割は3つ: Lobby=部屋の看板、Relay=中継、NGO=同期。NGOの同期コードは一切変わらない
- Hostは allocation→joinコード→
SetRelayServerData→StartHost、Clientは joinコードで参加→StartClient - Lobbyで joinコードを部屋のデータに隠し、プレイヤーには合言葉だけ 共有する。Hostは ハートビート を忘れずに
- 切断の後始末 まで書いて完成:Clientはタイトルへ、Hostは表示して続行、抜けるときは部屋を畳む
合言葉を送った相手のキャラクターが、あなたの画面に初めて現れる瞬間——ローカルの2ウィンドウとはまったく違う感動があります。今週末、誰に合言葉を送りますか?