🕹️

Rustでサーバーいらずのオンラインゲームを作ろう

に公開

はじめに

今回はP2P通信を使用してサーバーいらずの対戦型ブロック崩しを作ったので、その紹介と解説をします。
ゲームプレイGIF

https://github.com/yadokani389/online-breakout

記事を書いた時点でのソース

記事を書いた時点でのソースです。
参考にしてください。
https://github.com/yadokani389/online-breakout/tree/0cb742a9d69715fd07ecacde792c3e038c516769

概要

Bevyで制作したゲームです。
ホストになり表示されたIDを相手に送り、入力してもらうと対戦が開始します。

特徴としてシグナリングサーバーが不要で、
さらにGitHub Pagesにデプロイすればpeer間で通信しているだけなので完全無料でサービスを提供することができます。

https://yadokani389.github.io/online-breakout/
このリンクから遊ぶことができます!

技術スタック

それぞれ軽く説明します。

Bevy

https://bevyengine.org/
Rust製のゲームエンジンです。
特徴はECSシステムと、ソースコードで完結していることです。
個人的にマウスとGUIアプリはあまり好きではないので好きなポイントです。
Bevy自体にはオンラインゲームを作るための機能はないです。

GGRS

https://github.com/gschup/ggrs
GGPOのRust実装(reimagination)です。
P2P通信を用いたロールバックを実現するための機能が提供されています。

Matchbox

https://github.com/johanhelsing/matchbox
WebRTCを用いたpeer-to-peer通信のためのライブラリです。
WebRTCはピア間の直接通信を可能にしますが、それらの接続を確立するためにはシグナリングサーバーが必要です。

Iroh

https://github.com/n0-computer/iroh
パブリックキーによる接続が可能なP2P通信ライブラリです。
QUICプロトコルを基盤とし、直接接続を可能な限り確立し、必要に応じてリレーサーバーを利用する設計となっています。


GGRSとBevyがあればできるんじゃないの?と思われるかもしれません。
GGRSはUDPサポートは組み込まれていますが、ブラウザでは動作しません。
ブラウザで動くと試す敷居が下がりますので重要なファクターです。
そこでMatchboxを使い、WebRTCで2つのブラウザ間のUDPのような接続を実現します。
今回は、シグナリングサーバーを使わずに、irohを使ってピア同士でハンドシェイクを行います。

実装

Bevy、GGRS、MatchboxについてはMatchboxの作者のjohanhelsingさんがとても分かりやすい記事を書いているので参考にしてください。
https://johanhelsing.studio/posts/extreme-bevy

今回はブロック崩しのゲーム部分の詳細には触れず、johanhelsingさんの記事に書いていないIrohとの統合と他のゲーム開発にも役立つtipsに焦点を当てて解説します。

signaller

matchbox_socketSignallerを実装すると自分でシグナリングを制御できます。
ですが、シグナリングサーバーなんて何をしているかわからないですよね。
なんとIrohを使用したSignallerのexampleがあります!(神)
https://github.com/johanhelsing/matchbox/tree/29643f4ede8343f41ee4e3154b432be97b2ac0c5/examples/custom_signaller

このexampleをBevyで実装する必要があります。
ここで問題となるのはasyncな関数が含まれていることです。

案1

Bevy標準のTaskを使う。
参考: https://github.com/bevyengine/bevy/blob/25bfa80e60e886031dd6eb1a3fe2ea548fc0a6c6/examples/async_tasks/async_compute.rs

direct_message.rsiroh_gossip_signaller.rsは先程のexampleからコピーしておきます。
少々変更しましたが、ほとんどそのままです。
taskを生成してIrohGossipSignallerBuilder::new().awaitしてみると...

#[derive(Component)]
pub struct IrohTask(Task<IrohGossipSignallerBuilder>);

fn spawn_signaller_task(mut commands: Commands) {
    let thread_pool = AsyncComputeTaskPool::get();
    let task = thread_pool.spawn(async move { IrohGossipSignallerBuilder::new().await.unwrap() });
    commands.spawn(IrohTask(task));
}

fn handle_signaller_task(mut commands: Commands, mut task_query: Query<(Entity, &mut IrohTask)>, args: Res<Args>) {
    for (entity, mut task) in task_query.iter_mut() {
        if let Some(signaller) = block_on(future::poll_once(&mut task.0)) {
            info!("Iroh signaller ready");
            let socket: MatchboxSocket = WebRtcSocketBuilder::new(args.iroh)
                .signaller_builder(Arc::new(signaller))
                .add_unreliable_channel()
                .into();
            commands.insert_resource(socket);
            commands.entity(entity).despawn();
        }
    }
}
thread 'Async Compute Task Pool (2)' panicked at /home/USER/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.35.0/src/discovery/pkarr.rs:161:27:
there is no reactor running, must be called from the context of a Tokio 1.x runtime

実装を見ていないのでなんとも言えませんが、BevyのTaskはTokio runtimeではないようですね。

案2

Bevy tokioなどで調べてみたら、いい感じのプラグインを見つけました。
https://github.com/EkardNT/bevy-tokio-tasks

fn start_matchbox_socket(runtime: ResMut<TokioTasksRuntime>, args: Res<Args>) {
    let iroh_address = args.iroh.clone();
    runtime.spawn_background_task(|mut ctx| async move {
        let signaller_builder = IrohGossipSignallerBuilder::new().await.unwrap();
        let socket: MatchboxSocket = WebRtcSocketBuilder::new(iroh_address)
            .signaller_builder(Arc::new(signaller_builder))
            .add_unreliable_channel()
            .into();
        info!("Starting matchbox socket");
        ctx.run_on_main_thread(move |ctx| {
            ctx.world.insert_resource(socket);
        })
        .await;
    });
}
2025-05-23T13:31:01.288397Z  INFO online_breakout::game::online::iroh_gossip_signaller: Creating new IrohGossipSignallerBuilder
2025-05-23T13:31:01.291484Z  INFO online_breakout::game::online::iroh_gossip_signaller: Iroh ID: 4819cd21a373fc38a0462cb4f9b915dedb25c50a062aabc27b2528329c674baa
2025-05-23T13:31:01.291520Z  INFO online_breakout::game::online::iroh_gossip_signaller: Matchbox ID: e8a3b5a3-35f6-475a-b014-c1d5f2e82c16
2025-05-23T13:31:01.694837Z  INFO ep{me=4819cd21a3}:magicsock:actor: iroh::magicsock: home is now relay https://aps1-1.relay.iroh.network./, was None
2025-05-23T13:31:01.695189Z  INFO online_breakout::game::online: Starting matchbox socket
2025-05-23T13:31:01.695372Z  INFO online_breakout::game::online::iroh_gossip_signaller: Creating new signaller
2025-05-23T13:31:01.695401Z  INFO online_breakout::game::online::iroh_gossip_signaller: Subscribing to gossip topic TopicId(5f5f6f6e6c696e655f627265616b6f75745f69726f685f67
6f737369705f5f5f) with bootstrap: []

thread 'IO Task Pool (2)' panicked at /home/USER/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-gossip-0.35.0/src/net.rs:351:20:
there is no reactor running, must be called from the context of a Tokio 1.x runtime

Tokio runtimeでIrohGossipSignallerBuilderのインスタンス生成には成功しました。
しかし、まだエラーは消えません。
bevy_matchboxのソースを追ってみると

https://github.com/johanhelsing/matchbox/blob/b5d8be77ff7f80db18b4c5532e28f6d9d6ad34da/bevy_matchbox/src/socket.rs#L86-L98

と、内部でBevyのTaskを使用しており、ここでエラーが起きているようです。
なのでMatchboxSocketは使わずに、message_loop_futをこのクロージャ内で実行してしまいます。

+ #[derive(Resource, Deref, DerefMut)]
+ pub struct IrohSocket(WebRtcSocket);

 fn start_matchbox_socket(runtime: ResMut<TokioTasksRuntime>, args: Res<Args>) {
    let iroh_address = args.iroh.clone();
     runtime.spawn_background_task(|mut ctx| async move {
         let signaller_builder = IrohGossipSignallerBuilder::new().await.unwrap();
-        let socket: MatchboxSocket = WebRtcSocketBuilder::new(iroh_address)
+        let (socket, message_loop_fut) = WebRtcSocketBuilder::new(iroh_address)
             .signaller_builder(Arc::new(signaller_builder))
             .add_unreliable_channel()
-            .into();
+            .build();
         info!("Starting matchbox socket");
         ctx.run_on_main_thread(move |ctx| {
-            ctx.world.insert_resource(socket);
+            ctx.world.insert_resource(IrohSocket(socket));
         })
         .await;
+        _ = message_loop_fut.await;
     });
 }

動きましたー!
ですがまだ終わってはいません...

現時点ではwasmがサポートされていないのです。
https://github.com/EkardNT/bevy-tokio-tasks/issues/18

案3

https://github.com/tekacs/bevy-wasm-tasks

wasm対応版のbevy-tokio-tasksです。
基にはしていますがAPIがかなり変更されています。

最終的なソースは以下のようになりました。

fn start_matchbox_socket(tasks: Tasks, args: Res<Args>) {
    let iroh_address = args.iroh.clone();
    tasks.spawn_auto(async move |x| {
        let signaller_builder = IrohGossipSignallerBuilder::new().await.unwrap();
        let builder = WebRtcSocketBuilder::new(iroh_address)
            .signaller_builder(Arc::new(signaller_builder))
            .add_unreliable_channel();
        info!("Starting matchbox socket");
        let (socket, message_loop_fut) = builder.build();
        x.submit_on_main_thread(move |ctx| {
            ctx.world.insert_resource(IrohSocket(socket));
        });
        _ = message_loop_fut.await;
    });
}

Bevy 0.16の対応と、spawn_autoの実装が間違っている気がしたため、自分のフォークを使用しています。

具体的に

https://github.com/tekacs/bevy-wasm-tasks/blob/37eba561f78c1444bda226c9dfe02e598cc4deb8/src/lib.rs#L104-L120
spawn_autoはfeatureから判断して自動的にspawn_tokioかspawn_wasmを呼び出す関数なのですが、wasmの時でもTaskにSendトレイトの実装を要求していて動かなかったのでfeature毎に関数を分けました。
https://github.com/yadokani389/bevy-wasm-tasks/blob/ef22cf578d89180a9ff90f5e3de52b09f8115e2d/src/lib.rs#L104-L139

メニュー

さっきからRes<Args>という引数を取っていましたが、これはclapから取得したコマンドライン引数を管理する構造体で、synctestモードで起動するかとirohのpublic idを持っています。
リソースとして登録してメニューで変更しながら使っています。

#[derive(Parser, Resource, Debug, Clone)]
pub struct Args {
    #[clap(short, long)]
    pub synctest: bool,
    #[clap(short, long, default_value = "")]
    pub iroh: String,
}

fn get_args() -> args::Args {
    #[cfg(not(target_arch = "wasm32"))]
    {
        args::Args::parse()
    }

    #[cfg(target_arch = "wasm32")]
    {
        let mut args = args::Args::parse();
        // Get window.location object
        let window = web_sys::window().expect("no global `window` exists");
        let location = window.location();

        // Get the hash part of the URL (including the # symbol)
        if let Ok(hash) = location.hash() {
            args.iroh = hash.trim_start_matches('#').to_string();
        }

        args
    }
}

wasmの時はurlからpublic idを取得しています。

これだけでも一応遊べるのですが、現状public idはログからしか取得できず、更に毎回コマンド引数を変えたりurlを変えたりするのは面倒です。
なのでゲーム内でpublic idを表示し、入力できるロビーメニューを作っていきます。

要件

  • public idを表示して何らかの方法でコピーできる
  • ペーストを利用して入力ができる

Bevyの弱点としてUIが弱いことが挙げられます。
まず文字の表示はできますが選択ができず当然コピーもできません。
入力もbevy-text-editというプラグインはあるのですが、ペーストに対応していません。
なので今回はbevy_eguiを使います。
bevy_eguiはEgui integrationを提供するプラグインです。

このプラグインを使いEguiから文字を表示すれば、選択してコピーできます。
Eguiはペーストにも対応しているので、入力もできます。

ホストになるかクライアントになるかのボタンはBevyで作り、真ん中の入力ボックスはEguiで作りました。
ロビーメニュー画像

fn show_textbox(mut context: EguiContexts, mut args: ResMut<Args>) {
    egui::Area::new(egui::Id::new(0))
        .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
        .show(context.ctx_mut(), |ui| {
            ui.label("Enter Room ID:");
            ui.add(
                egui::TextEdit::singleline(&mut args.iroh)
                    .hint_text("x".repeat(64))
                    .font(egui::FontId::proportional(30.))
                    .desired_width(400.),
            );
        });
}

singlelineの引数にリソースを渡し、そのまま書き換えられるようにしています。

ボタンを押すとホストの場合は選択可能なpublic idが表示され、クライアントの場合はconnecting...のみ表示されます。

おわり

今回はBevy製のオンラインブロック崩しを作るにあたって遭遇した問題とその解決策を書いてみました。
実際のコードはGitHubを覗いてみてください。
そしてもしより良い書き方などがあればぜひ教えていただきたいです!

今回の記事とはあまり関係がないですが、Bevyでゲーム作るのはとても楽しいです。
初学者の方でもライフタイムがほとんど気にならず、ECSに慣れれば所有権もそこまで気にならないような気がするので、ぜひ触ってみてほしいです!
見た目で書いた通りに動くと楽しいのでモチベーションも保ちやすいと思います。

Discussion