【Unity】NGO+Relay+Lobbyで、友達とインターネット越しに遊ぶ

作成: 2026-07-16

Netcode for GameObjectsでローカルの同期はできた——でも友達の家とはつながらない。その壁の正体はNATで、越える道具がUnityのRelayとLobbyです。UGSの初期化と匿名サインイン、HostのRelay割り当てとjoinコード発行、Clientの参加、合言葉で入れる部屋(Lobby)の作成とハートビート、切断時の後始末まで、「友達と遊べる」最小のオンライン協力セッションを完成させます。

Netcode for GameObjectsの入門 で、2つのウィンドウのキャラクターが同期して動いた瞬間は感動的です。でも次の瞬間、現実が来ます——「これ、どうやって 友達の家と つなぐの?」。IPアドレスを教えても、つながらない。localhostで完璧に動くコードが、インターネットの前で沈黙する。

原因はコードではなく ネットワークの構造 にあります。この記事では、Unity Gaming Services(UGS)の Relay(通信の中継)と Lobby(合言葉で入れる部屋)を使って、 ルーターの設定なしで友達と遊べる 最小のオンライン協力セッションを組み上げます。

インターネット越しに2人のプレイヤーがつながるイメージ

この記事でわかること

  • 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は更新が活発なため、動かない場合は各パッケージの公式ドキュメントを確認してください)

Sponsored

なぜインターネットではつながらないのか

localhost(127.0.0.1)やLAN内の接続は、いわば 同じ家の中の会話 です。ところがインターネット越しの接続では、あなたと友達の間に、それぞれの家の ルーター(NAT) が立ちはだかります。

NATは「家の中から外への通信」は通しますが、 「外から突然やってくる接続」は不審者として遮断します。NGOのHostはサーバーとして接続を「待ち受ける」側なので、友達のClientからの接続はあなたの家のルーターで止まる——これが「IPを教えてもつながらない」の正体です。

直接接続とRelay経由の比較図。直接接続は相手の家のルーター(NAT)に遮断されるが、Relay経由では両者が外向きにUnityの中継サーバーへ接続するため、どちらのルーターも通過できる

解決策が Relay(中継) です。HostもClientも、Unityが運用する中継サーバーへ 自分から外向きに 接続します。外向きの通信はNATが通すので、ルーターの設定は一切不要。両者の通信はすべて中継サーバー経由で流れます。ポート開放を友達に頼む必要も、固定IPも要りません。

3つの道具の役割分担

オンラインセッションには3つの道具が登場します。名前が似ていて混乱しやすいので、役割を先に固定しましょう。

Lobby、Relay、NGOの責務分担図。Lobbyは部屋の看板と合言葉の管理、Relayは通信の中継、NGOはゲーム内の同期処理を担当し、この順に接続が進む
道具役割たとえるなら
Lobby部屋の情報と合言葉を管理する会場の受付・看板
Relay通信をNAT越しに中継する電話の交換局
NGOゲーム内の位置や状態を同期する会話そのもの

流れはこうです。 HostがRelayの中継枠を確保 → その入場券(joinコード)をLobbyの部屋に貼っておく → Clientは合言葉(ロビーコード)で部屋に入り、入場券を受け取ってRelayへ接続 → あとはNGOがいつも通り同期する。NGOのコード(NetworkVariableやRPC)は、ローカルのときと1文字も変わりません。変わるのは「接続の仕方」だけです。

準備:UGSの初期化と匿名サインイン

Package Managerから RelayLobby のパッケージを追加し、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を要求します。個人開発のオンライン対応では、まず匿名で始めるのが定番です。

Sponsored

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;
}

maxClientsHost自身を除いた 接続数です(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();
}
Host作成からClient接続までのシーケンス図。HostがRelayへ割り当てを作りjoinコードを受け取り、コードを共有されたClientがRelayへ参加して、以後の通信が中継サーバー経由で流れる

実はこの時点で、Discordなどでjoinコードを送り合えば もう友達と遊べます。ただ、毎回6文字のコードをコピペしてもらうのは少し無骨です。そこで、部屋の管理をLobbyに任せます。

Lobby:joinコードを「合言葉」に変える

Lobbyは 部屋のデータベース です。部屋には名前・定員・任意のデータを持たせられ、 ロビーコード(合言葉) で参加できます。ここに「RelayのjoinコードをメモしたLobby」を作れば、プレイヤー同士はロビーコードだけ共有すればよくなります。

Lobbyには1つだけ独特の作法があります。 ハートビート です。放置された部屋が溜まらないよう、一定時間pingが来ない部屋は自動削除されます。Hostは部屋が生きている間、定期的に「まだいるよ」と打ち続ける必要があります(15〜20秒間隔が目安)。

実践:合言葉で入れる2人協力部屋

部品を1つのマネージャーにまとめます。「部屋を作る」を押すとロビーコードが画面に出て、友達がそれを入力すると同じゲームに入ってくる——オンライン協力の骨格そのものです。

合言葉で部屋に入る場面図。ホスト側の画面に合言葉が表示され、離れた場所の友達がその合言葉を入力すると、2人のキャラクターが同じゲーム空間に立つ
// 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は参加者の離脱を表示し、部屋は開けたまま次を待つ。そして自分から抜けるときは、さっきの LeaveRoomLobbyを畳んでから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ウィンドウとはまったく違う感動があります。今週末、誰に合言葉を送りますか?