コンテンツにスキップ

マルチプレイヤーアーキテクチャ

En Parlant~ のマルチプレイヤーは、WebSocketリレーサーバーを使用して2人のプレイヤーをリアルタイムに接続します。ピアツーピアは使用せず、すべての通信はリレーを経由します。これによりネットワーキングがシンプルに保たれ、ファイアウォールやNATを越えても確実に動作します。

Player A (host) Relay Server Player B (joiner)
| | |
|--- create_game(name) ----------->| |
|<-- game_created(code) -----------| |
| |<--- join_game(code, name) -----|
|<-- game_joined(name) ------------|--- game_joined(name) -------->|
| | |
|--- game_move(uci, times) ------->|--- game_move(uci, times) --->|
|<-- game_move(uci, times) --------|<-- game_move(uci, times) ----|
| | |
|--- heartbeat ------------------>|--- peer_heartbeat ----------->|

リレーはシンプルな転送レイヤーです。ルームをメモリ上に保持し、各ルーム内の2人のプレイヤー間でイベントをルーティングし、クリーンアップを処理します。サーバー側にはゲームロジックや手の検証はなく、クライアントが権限を持ちます。

  • フロントエンド(クライアント): socket.io-client — 標準の Socket.IO JavaScript クライアント
  • バックエンド(リレー): socketioxideAxum 上に構築された Rust 製 Socket.IO サーバー
  • プロトコル: WebSocket 上の Socket.IO(必要に応じて HTTP ロングポーリングへの自動フォールバック付き)

Socket.IO が生の WebSocket よりも選ばれた理由は、自動再接続、ルーム/ネームスペース管理、構造化されたイベントハンドリングをすぐに利用できるためです。

ホストが Multiplayer をクリックし、表示名を入力します。クライアントは名前を含む create_game イベントを送信します。サーバーは以下を行います:

  1. 一意の6文字のルームコードを生成
  2. ルームを作成し、ホストを最初のプレイヤーとして追加
  3. game_created(code) で応答し、ホストがコードを共有できるようにする

参加者がルームコードと表示名を入力します。クライアントは join_game(code, name) を送信します。サーバーは以下を行います:

  1. コードでルームを検索
  2. 参加者を2人目のプレイヤーとして追加
  3. 両方のプレイヤーに game_joined(name) を送信 — それぞれが相手の表示名を受け取る

両方のプレイヤーがルームに入ると:

  • ホストは常に白、参加者は常に黒でプレイします。 これは交渉ではなく、参加順で決定されます。
  • 手は UCI 形式(例: e2e4)で、両プレイヤーの持ち時間とともに送信されます。
  • リレーは各 game_move イベントを対戦相手に転送します。両プレイヤーは確認として自分の手も受け取ります。

ゲーム終了のシナリオを処理するイベントがいくつかあります:

  • 投了 — 対戦相手に転送され、投了ダイアログがトリガーされる
  • 引き分け提案 — 対戦相手に転送され、受諾または無視できる
  • 引き分け受諾 — 両プレイヤーに転送され、引き分けとしてゲームが終了する

ゲーム終了後、いずれかのプレイヤーが ready イベントを送信してリマッチの意思を示すことができます。両プレイヤーが ready を送信すると、クライアントは盤面をリセットし、色を交換します(またはそのまま維持 — クライアント側で決定)。

ルームコードは6文字で、口頭で共有しやすいよう XX-XX-XX の形式にフォーマットされています。文字セットは視覚的に紛らわしい文字を除外しています:

  • 0O なし(ゼロと文字の O)
  • 1IL なし(数字の1と大文字の I と大文字の L)

これにより、ボイスチャットでコードを読み上げたり、友人のメッセージからコードを入力したりする際の「それはゼロ?それとも O?」という問題を回避できます。

マルチプレイヤー接続では、プレイヤーが切断されたことを検出する必要があります — ネットワーク切断、アプリの終了、ノートパソコンのスリープなど原因はさまざまです。En Parlant~ はこのためにハートビートシステムを使用しています:

  1. 各クライアントが 5秒 ごとにサーバーへ heartbeat イベントを送信
  2. サーバーはハートビートを確認し、対戦相手に peer_heartbeat として転送
  3. クライアントは対戦相手から最後に peer_heartbeat を受信した時刻を追跡
  4. isPeerAlive(timeoutMs) 関数が、対戦相手の最後のハートビートが許容閾値内かどうかを確認

これにより UI の接続状態インジケーターが駆動されます。ハートビートが届かなくなると、プレイヤーは対戦相手が切断された可能性があることを確認でき、待機するかゲームを離れるかを選択できます。

リレーサーバーはメモリリークを防ぐため、アイドル状態のルームを自動的に削除します:

  • ルームは 30分間 アクティビティがないとアイドルとみなされる
  • クリーンアップタスクが 60秒 ごとに実行され、アイドル閾値を超えたルームをすべて削除
  • ルームがクリーンアップされると、残っている接続はすべて切断される

永続ストレージはありません。サーバーが再起動すると、すべてのルームが消えます。これは意図的な設計です — リレーはステートレスかつエフェメラルです。進行中のゲームは再開が必要になりますが、実際にはこのケースはまれです。

デフォルトのリレーは Fly.io 上で実行され、自動 TLS による低遅延の WebSocket 接続を提供します。独自のリレーを実行する手順については、マルチプレイヤーサーバーセットアップガイドを参照してください。

開発中にマルチプレイヤーをテストするには:

  1. リレーをクローンして実行します:

    Terminal window
    git clone https://github.com/DarrellThomas/en-parlant-relay.git
    cd en-parlant-relay
    cargo run

    サーバーはポート 3210 で起動します。

  2. En Parlant~ で、リレーサーバーの URL を ws://localhost:3210 に変更します。

  3. アプリを2つのインスタンスで開いて(またはアプリ1つと開発モード1つで)、両プレイヤーをシミュレートします。

手、ハートビート、およびすべてのイベントは本番リレーとまったく同じように動作します — 違いは接続 URL のみです。