簡単なゲームサーバーを一瞬で作るための技術

2022/02/13に公開

この記事で作るのは2人プレイのゲームのWebSocketサーバーです。マッチング機能とプレイヤー間通信機能の2機能を一瞬で作ってみます。
短い記事になると思います。なぜなら、書くコードは全て記事に載せますがどれも極めて少量ですし、コードは直感的なので説明も少なくなるはずだからです。

WebSocketサーバーを作る

今回はElixir製WebアプリケーションフレームワークPhoenixのChannels機能を使います。PhoenixはcowboyというHTTPサーバーライブラリの薄いラッパーですが、その薄い部分のおかげでRailsのような使い勝手になるのでおすすめです。
Phoenixを使わずにcowboyを直接使ってもこの記事と同じやり方で目的は達成できますがPhoenix ChannelsのAPIの方が直感的なのでこちらを使います。
ElixirやPhoenixのインストール手順は省きますが、こちらの通り二、三コマンドを叩くだけで全てインストールできます。インストールしたらmix phx.newコマンドを使ってプロジェクトを作成しましょう。
まずはプロジェクトのディレクトリに移動したら、WebSocket通信に必要なSocketモジュールとChannelモジュールを以下のコマンドで生成します。

mix phx.gen.socket User
mix phx.gen.channel Player

実行するとそれぞれ2、3行のコードを所定の場所にコピーして欲しいと言われるのでそのようにします。
これでWebSocketサーバーの準備が完了です。mix phx.serverでサーバーを起動すれば、クライアントからWebSocket接続ができる状態です。

Cizenとイベント

この記事ではCizenというライブラリを使用します。Cizenを使うと、パターンマッチでイベントの購読ができたり、サガと呼ばれるプロセスを組み合わせてアプリケーションを構築できたりします。
外部依存になるのでmix.exsファイルを開いて依存の部分に{:cizen, "~> 0.18"}を追加してください。
また、今回使用するイベントを先に定義しておきましょう。イベントとしてはどんなデータ構造も使えるので単に連想配列であるMapを使うだけでもいいのですが、コードの可読性が上がるのと、静的にフィールド名のチェックが行えるのでElixirの構造体の仕組みを使います。
プロジェクトのディレクトリのlib以下の適当な場所にevents.exファイルを作成して以下の内容を記述します。

# クライアントから送られてくるJSON
defmodule Input do
  defstruct [:id, :payload]
end

# クライアントに送信するJSON
defmodule Output do
  defstruct [:id, :payload]
end

PlayerChannelの処理

次はPhoenix Channelsの機能を使ってクライアントと通信する部分を書いていきます。ここがこの記事で一番複雑なところです。
さきほどのコマンドでPlayerChannelというモジュールが生成されました。それを以下のように書き換えます。

defmodule YourGameWeb.PlayerChannel do
  use YourGameWeb, :channel

  alias Cizen.{Dispatcher, Pattern, Saga}
  require Pattern

  @impl true
  def join(_topic, _payload, socket) do
   # Playerサガの開始
    {:ok, id} = Saga.start_link(%Player{}, return: :saga_id, lifetime: self())
    # 自分向けのOutputイベントを購読
    Dispatcher.listen(Pattern.new(%Output{id: ^id}))

    # PlayerのIDを保存しておく
    socket = assign(socket, :id, id)
    {:ok, socket}
  end

  # 購読しているOutputイベントが届いた時に呼ばれる
  @impl true
  def handle_info(%Output{payload: payload}, socket) do
   # クライアントにpayloadを送信する
    push(socket, "output", payload)
    {:noreply, socket}
  end

  # クライアントでchannel.push("input", json)とJSONを送信したときに呼ばれる
  @impl true
  def handle_in("input", json, socket) do
    # Inputイベントを発火
    Dispatcher.dispatch(%Input{id: socket.assigns.id, payload: json})
    {:noreply, socket}
  end
end

コメントである程度何をしているかがわかると思います。クライアントからChannelに接続する度にその接続に対応するPlayerChannelプロセスが起動されます。join/3[1]関数はプロセスの起動時に、handle_in/3関数はクライアントからのJSONの受け取り時に、handle_info/2関数は購読しているイベントの受け取り時に呼び出されます。
おおまかにやっていることは以下の4つです。

  1. 接続時に接続に対応するPlayerサガを起動する。
  2. Outputイベントを購読して、受け取ったらpayloadをクライアントに送信する。
  3. クライアントからJSONを受け取ったらInputイベントを発火する。

Playerサガを起動する時にlifetime: self()とすることで、WebSocket接続が切れた時にPlayerサガも終了するようにしています。また、return: :saga_idとしたため返り値はサガに対応する一意のサガIDになっています。この記事ではこのサガIDをプレイヤーのIDとして色々なところで使用します。

Playerサガの処理

次はPlayerサガを作ります。lib以下の適当な場所にplayer.exを作り、以下のコードを書きます。

defmodule Player do
  defstruct []
  use Cizen.Saga

  @impl true
  def on_start(_), do: nil
  
  @impl true
  def handle_event(_, _), do: nil
end

短いですね。要するになにもしないサガです。Elixirの裏で動くErlang VMでは軽量なプロセスを大量に起動してもサクサク動くので、このような何もしない無用なプロセスを起動してもパフォーマンスには影響を与えません[2]
ここで、後からも出てくるon_start/1handle_event/2を説明しておきます。名前の通りではありますが、on_start/1はサガの起動時に呼ばれる関数で、handle_event/2はサガがイベントを受け取った時に呼ばれる関数です。

マッチング処理

次はマッチングをするためのMatchingサガを作りましょう。これはシングルトンなプロセスです。適当な場所にmatching.exを作り以下のコードを記述します。

defmodule Matching do
  defstruct []
  use Cizen.Saga

  alias Cizen.{Dispatcher, Pattern, Saga}
  require Pattern

  @impl true
  def on_start(_saga) do
   # 参加したいときのInputメッセージを購読
    Dispatcher.listen(Pattern.new(%Input{payload: %{"type" => "Join"}}))
    # まだだれもいないのでstateをnilにする
    nil
  end

  # 誰もマッチング待ちではない時(stateがnilの時)
  @impl true
  def handle_event(%Input{id: id, payload: %{"type" => "Join"}}, nil) do
    # マッチング待ちに入るためにstateをidにする
    id
  end

  # マッチング待ちの人がいる時(stateにIDが入っている時)
  @impl true
  def handle_event(%Input{id: id, payload: %{"type" => "Join"}}, other) do
    # Roomサガを起動
    {:ok, _} = Saga.start(%Room{id1: id, id2: other}, lifetime: id)

    # 双方のプレイヤーにマッチングに成功したことを伝える
    Dispatcher.dispatch(%Output{id: id, payload: %{"type" => "Joined"}})
    Dispatcher.dispatch(%Output{id: other, payload: %{"type" => "Joined"}})

    # マッチングしたのでstateをnilにする
    nil
  end
end

こんどはちゃんと処理がありますね。handle_event/2関数の第2引数はサガの状態が渡ってきます。サガの状態というのは、最後に呼ばれたon_start/1handle_event/2の返り値[3]です。このサガではサガの状態を

  1. マッチング待ちの人がいない時はnil
  2. マッチング待ちの人がいる時はその人のID

とすることでマッチングを行っています。
マッチングが成功した後はOutputイベントを使ってクライアントに参加できたことを伝えます(実際にクライアントに送信するのはOutputイベントを購読しているPlayerChannelです)。

プロセスリークを防ぐ

Roomサガを起動する時にlifetime: idを渡しています。これはidでもotherでも良いのですが、渡したIDのPlayerサガが終了した時(プレイヤーがWebSocket接続を終了した時)にRoomサガが終了するようになっています。こうすることで使われなくなったRoomサガが終了するのでメモリリークならぬプロセスリークを防いでいます。

プレイヤー間通信の実装

ほとんど最後の実装になりますが、Roomサガでプレイヤー間通信を実装します。適当な場所にroom.exを作って以下のように記述します。

defmodule Room do
  defstruct [:id1, :id2]
  use Cizen.Saga

  alias Cizen.{Dispatcher, Pattern}
  require Pattern

  @impl true
  def on_start(%Room{id1: id1, id2: id2} = saga) do
    # 二人のプレイヤーから送られてくるPushを購読
    Dispatcher.listen(Pattern.new(%Input{id: ^id1, payload: %{"type" => "Push"}}))
    Dispatcher.listen(Pattern.new(%Input{id: ^id2, payload: %{"type" => "Push"}}))
    saga
  end

  # プレイヤー1からPushを受け取った時
  @impl true
  def handle_event(%Input{
    id: id1,
    payload: %{"type" => "Push", payload: payload}
  }, %Room{id1: id1, id2: id2} = saga) do
    # プレイヤー2に転送
    Dispatcher.dispatch(%Output{
      id: id2,
      payload: %{"type" => "Push", "payload" => payload}
    })
    saga
  end

  # プレイヤー2からPushを受け取った時
  @impl true
  def handle_event(%Input{
    id: id2,
    payload: %{"type" => "Push", payload: payload}
  }, %Room{id1: id1, id2: id2} = saga) do
    # プレイヤー1に転送
    Dispatcher.dispatch(%Output{
      id: id1,
      payload: %{"type" => "Push", "payload" => payload}
    })
    saga
  end
end

これもまた意外と単純でした。やっていることはプレイヤー1からのPushをプレイヤー2に転送し、逆にプレイヤー2からのPushはプレイヤー1に転送しているだけです。パターンマッチをすることでプレイヤー1から来たかプレイヤー2から来たかを判別しています。Elixirのパターンマッチはこういう時に便利です。

最後に

実装はこれで終わりです。しかし最後に一つだけやることがあります。PlayerサガやRoomサガは起動するコードを書きましたが、Matchingサガだけは起動するコードを書いていません。そのため、lib以下の浅めのところにあるapplication.exを開いて以下を追加します。YourGameWeb.Endpointなどが入っている配列の中です。

%{
  id: Matching,
  start: {Cizen.Saga, :start_link, [%Matching{}]}
},

こうすることでMatchingサガはアプリケーションの起動時にシングルトンとして起動するようになります。これでゲームサーバーは完成です!

脚注
  1. Elixirでは関数を引数の個数で区別するのでこのように引数の数を後ろにつけて表現します。 ↩︎

  2. もちろん数百万プロセスとか起動すると影響が出てくるとおもいます。 ↩︎

  3. Elixirでは関数の最後の式が返り値になります。 ↩︎

Discussion