Rustでサーバーいらずのオンラインゲームを作ろう
はじめに
今回はP2P通信を使用してサーバーいらずの対戦型ブロック崩しを作ったので、その紹介と解説をします。
記事を書いた時点でのソース
記事を書いた時点でのソースです。
参考にしてください。
https://github.com/yadokani389/online-breakout/tree/0cb742a9d69715fd07ecacde792c3e038c516769
概要
Bevyで制作したゲームです。
ホストになり表示されたIDを相手に送り、入力してもらうと対戦が開始します。
特徴としてシグナリングサーバーが不要で、
さらにGitHub Pagesにデプロイすればpeer間で通信しているだけなので完全無料でサービスを提供することができます。
https://yadokani389.github.io/online-breakout/
このリンクから遊ぶことができます!
技術スタック
それぞれ軽く説明します。
Bevy
特徴はECSシステムと、ソースコードで完結していることです。
個人的にマウスとGUIアプリはあまり好きではないので好きなポイントです。
Bevy自体にはオンラインゲームを作るための機能はないです。
GGRS
P2P通信を用いたロールバックを実現するための機能が提供されています。
Matchbox
WebRTCはピア間の直接通信を可能にしますが、それらの接続を確立するためにはシグナリングサーバーが必要です。
Iroh
QUICプロトコルを基盤とし、直接接続を可能な限り確立し、必要に応じてリレーサーバーを利用する設計となっています。
GGRSとBevyがあればできるんじゃないの?と思われるかもしれません。
GGRSはUDPサポートは組み込まれていますが、ブラウザでは動作しません。
ブラウザで動くと試す敷居が下がりますので重要なファクターです。
そこでMatchboxを使い、WebRTCで2つのブラウザ間のUDPのような接続を実現します。
今回は、シグナリングサーバーを使わずに、irohを使ってピア同士でハンドシェイクを行います。
実装
Bevy、GGRS、MatchboxについてはMatchboxの作者のjohanhelsingさんがとても分かりやすい記事を書いているので参考にしてください。
今回はブロック崩しのゲーム部分の詳細には触れず、johanhelsingさんの記事に書いていないIrohとの統合と他のゲーム開発にも役立つtipsに焦点を当てて解説します。
signaller
matchbox_socket
のSignaller
を実装すると自分でシグナリングを制御できます。
ですが、シグナリングサーバーなんて何をしているかわからないですよね。
なんと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.rs
とiroh_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などで調べてみたら、いい感じのプラグインを見つけました。
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のソースを追ってみると
と、内部で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がサポートされていないのです。
案3
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の実装が間違っている気がしたため、自分のフォークを使用しています。
具体的に
spawn_autoはfeatureから判断して自動的にspawn_tokioかspawn_wasmを呼び出す関数なのですが、wasmの時でもTaskにSendトレイトの実装を要求していて動かなかったのでfeature毎に関数を分けました。
メニュー
さっきから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