🎮

WebRTC × Wasm × Ebitengineで作る対戦ゲームHit&Blowの技術解説

2025/03/09に公開

はじめに

みなさん、こんにちは!今回は私が一から作ったWebゲーム「Hit&Blow」の技術解説をします。このゲームはGoのWebAssemblyとWebRTCを駆使して作られており、ブラウザ上でリアルタイム対戦が楽しめる仕組みになっています。

まずは触ってみてください!
(対戦ゲームですが過疎でも1人で二つのタブで開けばマッチするはずです)
https://hit-and-blow.pages.dev/go/

プレイ動画

WebAssemblyとWebRTCという最新技術を組み合わせて、サーバレスな対戦ゲームを作ってみました。今回はその技術的な仕組みを詳しく解説していきます。特に以下のような点に焦点を当てます:

  • WebRTCを使ったP2P通信の実装方法
  • GoコードをWebAssemblyにコンパイルして活用する方法
  • Ebitengineを使った2Dゲーム開発の実践テクニック
  • Goのチャネルを活用した非同期処理アーキテクチャ

今回の記事では、次のような技術要素について詳しく解説します:

  • GoのWebAssemblyを使ったブラウザゲーム開発
  • WebRTCのDataChannelを活用したP2P通信対戦の実装
  • Goの並行処理(Channel)を活用したゲームアーキテクチャ
  • 時雨堂さんのAyame(Go製)を使ったsignaling
  • Ebitengineを使った2Dゲーム開発
  • WebSocketを使ったマッチング機能
  • Eloレーティングによるプレイヤーランキング

特にWebAssemblyとWebRTCを組み合わせたユースケースはあまり一般的ではないため、この記事が同様の技術スタックに興味を持つ方々の参考になれば幸いです。
https://github.com/ponyo877/ebiten-hit-and-blow

ゲーム概要:Hit&Blow

Hit&Blowは古典的な数当てゲームです。ルールは以下の通りです:
ヒットとブローの例
引用: ミックスじゅーちゅ | ヒットアンドブローゲーム

このシンプルなルールながら、論理的思考と推測能力が試されるゲームです。
(このゲームの改造版が昔ヌメロンというTVになっており、オリエンタルラジオのあっちゃんが無双してました)
https://www.fujitv.co.jp/b_hp/numer0n/

技術スタック概要

このゲームは以下の技術スタックで構成されています:

技術スタック

  1. フロントエンド

    • Go + WebAssembly(ブラウザで動作するゲームロジック)
    • Ebitengine(2Dゲームエンジン)
    • go-ayame(signalingからWebRTC DataChannelでの通信)
  2. バックエンド

    • WebSocketサーバ
      • 時雨堂 Ayame(WebRTCの接続時に行うsignaling)
      • ユーザのマッチング
    • 普通のHTTPサーバ
      • レーティング管理

この構成の最大の特徴は、WebAssemblyWebRTCを組み合わせることで、サーバを介さない直接的なP2P通信を実現している点です。これにより低遅延のゲームプレイを提供しつつ、サーバ負荷を大幅に削減しています。

アーキテクチャ解説

全体構成

全体構成

このゲームのアーキテクチャは大きく分けて3つの部分から構成されています:

  1. クライアント(WebAssembly):ブラウザ上で動作するゲームロジックとUI
  2. signaling・マッチングサーバ:プレイヤー同士をマッチングし、WebRTC接続を確立するために必要な情報をやり取り
  3. レーティングサーバ:プレイヤーのレートを計算・管理

特筆すべき点は、一度WebRTC接続が確立されると、ゲームのすべての通信はP2Pで行われるため、サーバに負荷をかけずにスケーラブルなマルチプレイヤーゲームを実現できることです。

Goチャネルを活用した非同期処理

ゲームのコア部分はGoのチャネルを活用した非同期処理で構成されています。特に通信部分とゲームロジック、UI描画部分を分離することで、コードの可読性と保守性を高めています。

// とても汚いですがこれらのチャネルを使って、P2P通信とゲームロジック、UI描画のイベントを連携
type Game struct {
    mode               Mode
    cch                chan struct{}  // 接続完了通知
    hch                chan *entity.Hand  // 手札
    gch                chan *entity.Guess  // 推測
    qch                chan *entity.QA  // 問答結果
    tch                chan bool  // ターン管理
    tich               chan int  // タイマー
    jch                chan entity.JudgeStatus  // 勝敗判定
    rch                chan *entity.Rating  // レーティング
    // その他UI関連フィールド...
}

WebRTC通信での相手のメッセージの受信やゲームの進行などで表示が変わる場合はGoのchannelを通じて通信部分とゲームロジック、UI描画部分間のやり取りをしています
例えば:

// マッチングが完了したら新しいゲームを開始
go func(ch chan struct{}) {
    <-ch  // 接続完了を待機
    g.changeMode = true
    g.mode = ModePlaying
}(g.cch)

// 対戦相手からの回答を受け取ってUI更新
go func(ch chan *entity.QA) {
    for {
        qa := <-ch  // 新しいQ&Aが届くのを待機
        // UIを更新
        g.historyBoard.EmHistory().Add(drawable.NewFeedback(gs, hs))
        g.changeHistoryBoard = true
    }
}(g.qch)

このような非同期処理によって、通信処理中もUIがブロックされることなく、スムーズなゲーム体験を実現しています。

WebRTC通信の実装

WebRTCはブラウザ上でクライアント間のP2P通信を実現することができます。
多くはビデオチャットなどに代表される動画や音声の配信などで使われますが、WebRTC Datachannelを使うとそれ以外も通信することができます、本ゲームではこのDatachannlを活用しています

WebRTCはとても複雑な仕様なのでここでは詳細に解説しません(私も詳しく分かっていません)
ここではざっくりと通信の確立(signaling)と確立後のメッセージの交換について記載しています

※ WebRTCについて詳しく知りたい方はVさんの記事などを参照ください!
https://gist.github.com/voluntas/b67af408b8950b568e750918920016d8

signalingとP2P接続の確立

signalingとP2P接続の確立

WebRTCの接続確立には、初期のsingnalingが必要です。このゲームでは時雨堂さんのAyameをsingnalingサーバとして利用し、このAyameとのやりとりをhakoberaさんのgo-ayameを使ってWebAssemblyから行なっています。
https://github.com/OpenAyame/ayame
https://github.com/hakobera/go-ayame

// conn/conn.go - WebRTC接続確立の流れ
if resMsg.Type == "MATCH" {
    // 1. signalingサーバへの接続
    conn = ayame.NewConnection(signalingURL.String(), resMsg.RoomID, ayame.DefaultOptions(), false, false)
    
    // 2. WebSocket接続が確立した時の処理
    conn.OnOpen(func(metadata *interface{}) {
        var err error
        // 3. データチャネルの作成
        dc, err = conn.CreateDataChannel("matchmaking-hit-and-blow", nil)
        if err != nil {
            log.Println("データチャネル作成エラー:", err)
            return
        }
        
        // 4. データチャネルイベントハンドラの設定
        dc.OnOpen(func() {
            log.Println("データチャネルが開きました")
            // 初期メッセージを送信
            sendInitialData(dc, myHand.Text())
        })
        
        dc.OnMessage(func(msg webrtc.DataChannelMessage) {
            // メッセージ受信ハンドラ
            handleGameMessage(string(msg.Data), myHand, board, gch, qch, tch, jch, tich)
        })
    })
    
    // 5. P2P接続が完了した時の処理
    conn.OnConnect(func() {
        logElem("[Sys]: マッチング成功!サーバを介さないP2P通信を開始します\n")
        cch <- struct{}{}  // 接続完了を通知
        conn.CloseWebSocketConnection()  // signaling接続を閉じる(以降はP2Pのみ)
    })
    
    // 6. ICE接続状態変化の監視
    conn.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
        log.Printf("ICE接続状態変化: %s\n", state.String())
        if state == webrtc.ICEConnectionStateFailed {
            // 接続失敗時の再接続処理
            log.Println("P2P接続に失敗しました。再接続を試みます...")
            // 再接続ロジック
        }
    })
    
    // 7. signaling開始
    if err := conn.Connect(); err != nil {
        log.Printf("signaling接続エラー: %v", err)
        return
    }
}

このコードでは、WebRTCの接続確立プロセスの全体像を示しています。WebSocketベースのsignalingサーバ(Ayame)を通じて初期接続情報を交換し、P2P接続を確立しています。接続が成功すると、signalingサーバとの接続は閉じられ、以降はP2Pのデータチャネルのみを通じて通信が行われます。

マッチングが成立すると、Ayameのsignalingサーバを通じてWebRTC接続を確立し、DataChannelを作成します。接続が完了するとsignalingサーバとの接続は閉じられ、以降はP2P通信のみで対戦が進行します。

ゲームデータの送受信

ゲームの流れ

ゲームデータは以下のようなJSON形式で送受信されます:

// conn/conn.go
type Message struct {
    Type   string `json:"type"`
    Turn   *int   `json:"turn,omitempty"`
    Hit    *int   `json:"hit,omitempty"`
    Blow   *int   `json:"blow,omitempty"`
    Guess  string `json:"guess,omitempty"`
    MyHand string `json:"my_hand,omitempty"`
}

主に以下のメッセージタイプでゲームを進行させています:

  • start: ゲーム開始、初期ターン情報
  • guess: プレイヤーの推測
  • answer: 推測に対する回答(Hit数/Blow数)
  • timeout: 制限時間超過
  • expose: ゲーム終了時に自分の手札を公開
// 相手の推測に対して回答を返す処理例
guess := entity.NewGuessFromText(message.Guess)
ans := board.CalcAnswer(guess)
hit, blow := ans.Hit(), ans.Blow()
ansMsg := Message{Type: "answer", Hit: &hit, Blow: &blow}
by, _ := json.Marshal(ansMsg)
if err := dc.SendText(string(by)); err != nil {
    log.Printf("failed to send ansMsg: %v", err)
    return
}

これらのメッセージのやり取りによって、最小限のデータ通信でゲームを進行させています。

Ebitengineによるゲーム描画

ゲームのUI部分はEbitengineという2Dゲームエンジンを使って実装しています。Ebitengineは軽量で使いやすく、Goのコードから直接2D描画ができるため、WebAssemblyとの相性も良好です。
https://ebitengine.org/ja/

Ebitengineの構造と特徴

Ebitengineは「更新(Update)」と「描画(Draw)」の2つの主要メソッドを持つインターフェースに基づいて動作します:

// Ebitengineのゲームインターフェース
type Game interface {
    Update() error              // ゲームロジックの更新
    Draw(screen *Image)         // 画面描画
    Layout(w, h int) (int, int) // レイアウト設定
}

この分離されたライフサイクルにより、ゲームロジックと描画ロジックを明確に分けることができ、コードの可読性と保守性が向上します。

レスポンシブなUIレイアウト

Hit&Blowでは、様々な画面サイズに対応するため、画面の比率に基づいた相対的なサイジングシステムを実装しています、実際のコードはマジックナンバーだらけの簡易出来なものになっています:

// 画面サイズに応じたUIコンポーネントのスケーリング
g.numberButtons = []*drawable.NumberButton{
    drawable.NewNumberButton(0, w*110/750, h*90/1334, h*80/1334, /* 色情報など */),
    drawable.NewNumberButton(1, w*110/750, h*90/1334, h*80/1334, /* 色情報など */),
    // 残りのボタン...
}

特にモバイル端末とデスクトップの両方でプレイ可能なように、タッチとマウス入力の両方に対応する設計になっています、これはEbitenginのサンプルコードでもよく見るパターンです:

// タッチとマウス入力の両方に対応
var x, y int
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
    x, y = ebiten.CursorPosition()  // マウス位置
}
g.touchIDs = inpututil.AppendJustPressedTouchIDs(g.touchIDs[:0])
for _, touchID := range g.touchIDs {
    x, y = ebiten.TouchPosition(touchID)  // タッチ位置
}

部分的な再描画による最適化

パフォーマンス向上のため、画面全体を毎フレーム再描画するのではなく、変更があった部分のみを再描画する最適化を実装しています、厳密には違うかもですがReactの仮想DOM的なイメージです:

func (g *Game) Draw(screen *ebiten.Image) {
    // 全体再描画が必要な場合のみtmpを作り直す
    if g.tmp == nil || g.changeMode {
        g.tmp = ebiten.NewImage(screenWidth, screenHeight)
        // ...
    }
    
    // 必要な部分だけ再描画
    if g.changePlayerBoard {
        g.playerBoard.Draw(g.tmp, 0, 0)
        g.changePlayerBoard = false
    }
    if g.changeHistoryBoard {
        g.historyBoard.Draw(g.tmp, 0, screenHeight*2/10)
        g.changeHistoryBoard = false
    }
    if g.changeTimer {
        g.timer.Draw(g.tmp, 0, screenHeight*25/40)
        g.changeTimer = false
    }
    
    // 最終的な画面に全体描画
    screen.DrawImage(g.tmp, &ebiten.DrawImageOptions{})
}

モジュラーなUIコンポーネント設計

ゲームUIはdrawableパッケージで独立したコンポーネントとして実装されており、再利用性と保守性を高めています、これもReact的なイメージで作りました:

UIコンポーネント構成図
※ Clineに書いてもらいました、正確ではない可能性がありますのでざっくりでみてください

  1. PlayerBoard: プレイヤー情報とステータス表示
  2. HistoryBoard: 対戦履歴の表示
  3. InputBoard: 数字入力領域
  4. Tenkey: テンキー全体の管理
  5. Message: システムメッセージ表示
  6. Timer: 制限時間表示

UIコンポーネント体制

各コンポーネントは独自の描画メソッドと状態・振る舞いを持ち、以下のように独立して動作します:

// drawable/number_button.go
func (nb *NumberButton) Draw(screen *ebiten.Image) {
    // ボタンの背景描画
    nb.rect.Draw(screen)
    // テキスト描画
    nb.text.Draw(screen, nb.rect.X()-nb.text.Width()/2, nb.rect.Y()-nb.text.Height()/2)
}

// 押下状態の管理
func (nb *NumberButton) Push() {
    if nb.disabled {
        return
    }
    nb.input.Add(nb.number)
    nb.disabled = true
}

アニメーションとビジュアルエフェクト

ゲーム体験を向上させるため、複数のアニメーションとビジュアルエフェクトを実装しています、正直ここはかなりお粗末です...:

// ブリンクエフェクトとアニメーション
func (g *Game) counterHelper(blinkingFunc func(), animationFunc func()) {
    g.bliCounter++
    bliMaxCount := 20
    aniMaxCount := 20
    lightRatio := 0.7
    changeCount := int(float64(bliMaxCount) * lightRatio)
    
    if !g.changeTurn && g.bliCounter <= changeCount {
        blinkingFunc()  // 点滅エフェクト
    }
    if g.changeTurn && g.mode == ModePlaying {
        animationFunc()  // ターン変更アニメーション
    }
    
    // カウンターリセット
    if g.bliCounter > bliMaxCount {
        g.bliCounter = 0
    }
    if g.aniCounter > aniMaxCount {
        g.aniCounter = 0
        g.changeTurn = false
    }
}

ターン変更時には、波動エフェクトでユーザーに明確に伝えます:

// ターン変更アニメーション
g.aniCounter++
text := drawable.NewText("相手のターンです", 30, drawable.HistoryFrameColor)
if g.isMyTurn {
    text = drawable.NewText("あなたのターンです", 30, drawable.HistoryFrameColor)
}
// サイン波による揺れるアニメーション
aniOp.GeoM.Translate(25*math.Cos(float64(g.aniCounter))+float64(screenWidth/2-w/2),
                    float64(screenHeight/2))

このようなモジュラー設計と最適化により、WebAssemblyで動作しながらも、スムーズな描画と快適なゲーム体験を実現しています。

マッチングとレーティングシステム

本ゲームでは、次の2つの自作Goサーバを使ってオンライン対戦機能を実現しています:

  1. WebSocketベースのマッチングサーバ
  2. Redis(by Upstash)を利用したEloレーティングサーバ

これらのサーバは、ゲームの本体とは別のサービスとして動作し、マイクロサービスのような感じにしています。

WebSocketによるマッチングシステム

マッチングサーバは、WebSocketを使って接続してきたプレイヤー同士を効率的にペアリングします。
https://github.com/ponyo877/easy-matchmaking

マッチングの流れは以下の通りです:

  1. クライアントがWebSocketでマッチングサーバに接続
  2. キューに2人以上のプレイヤーが溜まったらマッチング実行
  3. マッチングが成立すると、両プレイヤーに相手の情報とルームIDを送信
  4. これを受けて、クライアント側ではWebRTCのsignalingを開始
// マッチングサーバのコア機能(easy-matchmaking/main.go)
func matchmaking() {
    for {
        if session.CanMatch() {  // 2人以上のプレイヤーがいるか確認
            now := time.Now()
            roomID := entity.NewHash(now).String()  // ルームIDをタイムスタンプからハッシュ生成
            p1, _ := session.Dequeue()  // キューから2人取り出し
            p2, _ := session.Dequeue()
            match[p1.ID()], match[p2.ID()] = p2, p1  // マッチング情報を保存
            
            // 両プレイヤーにマッチング情報を送信
            broadcast <- NewResMsg(p1.Conn(), roomID, p2.ID(), now)
            broadcast <- NewResMsg(p2.Conn(), roomID, p1.ID(), now)
            log.Printf("Matched!: %s vs %s", p1.ID(), p2.ID())
            continue
        }
    }
}

マッチングサーバは、Goの並行処理機能を活用しています:

  1. セッション管理にはsync.Mutexを使用して競合を防止
  2. 各WebSocket接続ごとにゴルーチンを起動して並列処理
  3. チャネルを使ったブロードキャスト処理による効率的なメッセージング

クライアント側では次のようにマッチングサーバに接続します:

// conn/conn.go(クライアント側)
ws, _, err := websocket.Dial(context.Background(), mmURL.String(), nil)
if err != nil {
    log.Fatal(err)
}
defer ws.Close(websocket.StatusNormalClosure, "close connection")

// マッチング要求を送信
if err := ws.Write(context.Background(), websocket.MessageText, reqMsg); err != nil {
    log.Fatal(err)
}

// マッチング完了を待機
for {
    if err := wsjson.Read(context.Background(), ws, &resMsg); err != nil {
        log.Fatal(err)
        break
    }
    if resMsg.Type == "MATCH" {
        break
    }
}

マッチングが成立すると、signalingサーバを使ってWebRTC接続を確立し、P2P通信へと移行します。マッチングサーバはその後の通信には関与しないため、サーバ負荷を最小限に抑えることができます。

マッチングのサービスは個人開発のチャットでも使っています。以下の記事でも取り上げています
https://zenn.dev/ponyo877/articles/3e347e2b65eba1

Eloレーティングシステム

プレイヤースキルの評価と公平なマッチングのために、チェスなどの1vs1のゲームでよく使われている「Eloレーティングシステム」を実装しています。
https://github.com/ponyo877/easy-rating

このシステムの特徴は次の通りです:

新レーティング計算式
レーティング更新式
引用: Unity ML-Agents Toolkit | ELO Rating System

  1. 初期レーティングは1500から開始
  2. 対戦結果に基づいて数学的にレーティングを調整
  3. 強い相手に勝つと大きくレーティングが上がり、弱い相手に負けると大きく下がる

また、レーティングサーバには改ざん防止のためのセキュリティ機能も実装されています:

  1. クライアントから送信されるデータにはハッシュ値を付加
  2. サーバ側でハッシュ値を検証して不正リクエストを防止
  3. 同一マッチの報告は1回のみ受け付ける仕組み

このように、マッチングとレーティングを別々のマイクロサービス的に実装することで、システム全体の柔軟性と拡張性を高めています。

開発で苦労した点と学び

WebRTCのP2P接続確立

今までP2P通信をすんなり確立できたふうに書いていましたが、実際は大変でした。
Client間の通信ではあるのですが厳密にはNAT超えの問題があるため、接続の確立にはSTUNサーバやTURNサーバが必要になります。特にモバイルネットワークなど一部の環境では接続確立が難しく、対応に苦労しました

STUNはGoogleが公開しているサーバ(stun.l.google.com:19302)、TURNは無料TURNのExpressTURNを使っています
https://www.expressturn.com/

// go-ayame/ayame.go
func DefaultOptions() *ConnectionOptions {
    return &ConnectionOptions{
        ICEServers: []webrtc.ICEServer{
            // TURNサーバの設定
            {
                URLs:           []string{"turn:" + turnHost},
                Username:       turnUser,
                Credential:     turnPass,
                CredentialType: webrtc.ICECredentialTypePassword,
            },
            // STUNサーバの設定
            {
                URLs: []string{"stun:stun.l.google.com:19302"},
            },
        },
        ClientID:     getULID(),
        UseTrickeICE: true,
    }
}

非同期処理の設計

Goのchannelを活用した非同期処理の設計は当初複雑に感じましたが、結果としてコードの可読性と保守性を大幅に向上させることができました。特に通信イベントとUI更新の連携部分はchannelを使うことで比較的わかりやすくなりました(色んな用途のchannelを乱立させすぎてスパゲッティではありますが...)

// ターン管理の例
go func(ch chan bool, rch chan *entity.Rating) {
    for {
        g.isMyTurn = <-ch  // ターン変更を待機
        g.timer.Set(60)    // タイマーリセット
        
        // UIメッセージ更新
        if g.isMyTurn {
            g.message.SetMessage("推理した4桁の数字を入れてください")
        } else {
            g.message.SetMessage("相手は考えています...")
        }
        
        g.changeTurn = true
        g.changeTimer = true
    }
}(g.tch, g.rch)

WebAssemblyのサイズ最適化

WebAssemblyのバイナリサイズは当初大きな課題でした。
そもそも静的コンテンツのホスティングに使っているCloudflare Pageの1ファイルの容量制限に引っかかっていましたし、ロード時間も長大になっていました
そこで以下の対策を施しました:

  1. wasm-optによるバイナリ最適化
  2. gzipによる圧縮
  3. 画像などの静的リソースの最小化
<!-- go/main.html -->
async function loadWasm() {
    const go = new Go();
    const response = await fetch("/go/demo.wasm.gz");
    const compressedData = await response.arrayBuffer();
    const decompressedData = pako.ungzip(new Uint8Array(compressedData)).buffer;
    const result = await WebAssembly.instantiate(decompressedData, go.importObject);
    document.getElementById('loading').remove();
    go.run(result.instance);
}

これにより、Cloudflare Pageに載せることができましたし、まだまだ時間がかかりますが初期ロード時間を短縮することができました。

まとめと今後の展望

今回のHit&Blow開発を通じて、WebAssemblyとWebRTCという比較的新しい技術を組み合わせた実装に挑戦しました。特にGoの強力な並行処理機能とWebAssemblyの相性の良さを実感しています。

得られた技術的知見

このプロジェクトから得られた主な技術的知見は以下の3つです:

  • WebRTCのP2P通信の実用性:サーバ負荷を最小限に抑えながら、リアルタイム対戦ゲームが実現できることを確認。特にデータチャネルを使った軽量通信は、ゲーム用途に適していると思います

  • EbitengineでのゲームUI実装:うまくUIの部品を作っていけばボードゲームのようなボタンぽちぽちしながらターン製で進むゲームを実装できました。もちろんReactとかと比べるとスクラッチだとつらみも多いのでいいライブラリを探しています

  • Goの並行プログラミングの威力:チャネルとゴルーチンを活用した非同期処理設計は、UI描画と通信処理の分離に理想的。特に複数の非同期イベント(対戦相手からのメッセージ、タイマー更新など)を扱う際に真価を発揮しました

最後に

WebRTCとWebAssemblyの組み合わせは、おそらくまだ一般的ではないもののサーバレスで対話型のアプリケーションを実現する強力な選択肢となり得ます。特にGoの型安全性と並行処理モデルは、複雑なゲームロジックを管理する上で大きなアドバンテージでした。

「技術的に面白そうなことをする」というモチベーションから始まったプロジェクトですが、実際に動くゲームとして形にできたことは大きな達成感があります。今後もWebRTCとWebAssemblyの可能性を探り、より高度なゲームやアプリケーションの開発にチャレンジしていきたいと思います。

ぜひ友人同士でゲームをプレイしてみていただき、フィードバックとかいただければ嬉しいです!!
https://hit-and-blow.pages.dev/go/

参考リンク

本プロジェクト関連

Go & WebAssembly

WebRTC(まだまだよく分かってない...)

ゲーム開発(サンプルゲームについハマってしまう)

その他参考技術(大好きな映画ソーシャルネットワークの冒頭にも登場、窓に書く数式)

Discussion