0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ゲーム [ebitengine] からサーバーサイド [WebTransport] まで全部Goでリアルタイム通信パズルゲームのモックを作る

0
Last updated at Posted at 2026-02-09

はじめに

まず、出来上がったものがこちら(繋げて打ち上げる何か

誰かが部屋にはいった、出た、攻撃されたがマーキー通知されます。
サーバーからクライアントまで全部Goで構成されています。

MULTIを選ぶと最大10人で遊べるマッチに乱入します。
Monosnap メディア プレーヤー 2026-02-07 22.27.05.png

やったこと

  • ebitengineでパズルゲームを作成、それっぽい、リアルタイム通信と対戦が可能
    • windows android web上で動作を確認できるバイナリを作成
    • 専用パーティクルエディタ作成
    • wasm単体ではWebTransportがむずかしいため、js_bridgeを作成
  • AWS ECS上にWebTransport用サーバーをデプロイ
    • Terraformで環境構築
    • ECS更新時にドメイン紐づけ(節約のため、NLBを一旦用意してない
    • Let'sEncryptで証明書を自動更新

モチベーション

 AIの性能が向上して、何らかの新しい要素を勉強しようと思ったとき、まず作らせてみる。が有力になってきたなと感じています。ドキュメントを読んで1から理解する能力も重要ですが、眼の前に動いているものがあって、それを壊して、分解して動作を確かめる。というのに勝る勉強道具というのはなかなかないと思います。なので今回はそれの実践です。
 もともとebitengineは触ってましたし、 ◯テオスの新作は出ないし、SSEや、WebScoketは触ったことがあったのでここはより先端で触ったことがないWebTransportをやってみようという感じです。

今回の利用ライブラリや参考等

Goでゲームが作れるゲームエンジン

quic-goを基盤とした WebTransport プロトコルの実装

ebitengine + donburi(ECS)で動作するパーティクルシステムとそのエディター

↓を試せる場所

QUICのために証明書取得

AWS上に証明書の自動更新の仕組みを実装する。

今回はHTTP/3 + QUICベースで動作するWebTransportを利用するためプロトコルや、セキュリティ上の理由で

  • Let's Encrypt + AWS Lambda で証明書の自動更新
  • Secrets Manager で証明書管理

という構成にして、ECS側から証明書を参照する。専用のドメインも取得(今回はサブドメインで)

  • Dockerマルチステージビルド(Alpine Linux)
  • Terraformで完全にIaC化
  • AWS ECS Fargate でコンテナデプロイ

サーバー実装

まず手元でテストを行う場合はmkcertを利用するのが良かった

 これによって、localhostに証明書を当てて、https通信を可能にし、quic WecTransportを動作可能に指せる。
Protobuf v3 でメッセージをシリアライズ
2つのチャネルの使い分け:
Control Stream(TCP的): JoinRequest, AttackEvent, Heartbeat など
Datagram(UDP的): BoardSnapshot(盤面状態)
といったことをおこなっている、盤面状態はまだ、作り込み中。

 また、今回試した点としては、サーバー監視ページをAIに作り込んでもらった。
ログやBotの追加、削除をリアルタイムで行えるようにしている。
Monosnap Interstellar Admin - Brave 2026-02-09 11.18.19.png

ebitengine側実装

wasmではWetTransportをそのまま受け取ることができないのでjs側のbridgeを実装

js bridgeの参考
// WebTransport bridge for WASM (syscall/js).
// Exposes window.wtBridge with callback hooks and simple send/close methods.
(function () {
  const state = {
    transport: null,
    stream: null,
    writer: null,
    reader: null,
    readLoopAbort: false,
  };

  function resetState() {
    state.transport = null;
    state.stream = null;
    state.writer = null;
    state.reader = null;
    state.readLoopAbort = false;
  }

  async function startReadLoop(reader) {
    while (!state.readLoopAbort) {
      try {
        const { value, done } = await reader.read();
        if (done) {
          if (typeof window.wtBridge.onclose === "function") {
            window.wtBridge.onclose();
          }
          break;
        }
        if (value && value.byteLength && typeof window.wtBridge.onmessage === "function") {
          // Forward Uint8Array to WASM.
          window.wtBridge.onmessage(value);
        }
      } catch (err) {
        if (typeof window.wtBridge.onerror === "function") {
          window.wtBridge.onerror(err);
        } else {
          console.warn("wtBridge read error:", err);
        }
        break;
      }
    }
  }

  window.wtBridge = {
    onopen: null,
    onmessage: null,
    onclose: null,
    onerror: null,

    async connect(url) {
      try {
        if (!("WebTransport" in window)) {
          throw new Error("WebTransport is not supported in this browser.");
        }
        if (state.transport) {
          await this.close();
        }

        const transport = new WebTransport(url);
        state.transport = transport;

        await transport.ready;

        const stream = await transport.createBidirectionalStream();
        state.stream = stream;
        state.writer = stream.writable.getWriter();
        state.reader = stream.readable.getReader();

        if (typeof this.onopen === "function") {
          this.onopen();
        }

        startReadLoop(state.reader);
        return true;
      } catch (err) {
        if (typeof this.onerror === "function") {
          this.onerror(err);
        } else {
          console.warn("wtBridge connect error:", err);
        }
        return false;
      }
    },

    async send(data) {
      try {
        if (!state.writer) {
          throw new Error("WebTransport writer not ready.");
        }
        // data should be Uint8Array from WASM.
        await state.writer.write(data);
        return true;
      } catch (err) {
        if (typeof this.onerror === "function") {
          this.onerror(err);
        } else {
          console.warn("wtBridge send error:", err);
        }
        return false;
      }
    },

    async close() {
      try {
        state.readLoopAbort = true;
        if (state.reader) {
          await state.reader.cancel();
        }
        if (state.writer) {
          await state.writer.close();
        }
        if (state.transport) {
          await state.transport.close();
        }
        resetState();
        if (typeof this.onclose === "function") {
          this.onclose();
        }
      } catch (err) {
        if (typeof this.onerror === "function") {
          this.onerror(err);
        } else {
          console.warn("wtBridge close error:", err);
        }
      }
    },
  };
})();
Go側の参考
//go:build js
// +build js

package network

import (
	"comet/pkg/framing"
	"comet/pkg/pb"
	"errors"
	"io"
	"log"
	"syscall/js"
	"time"

	"github.com/yohamta/donburi"
	"google.golang.org/protobuf/proto"
)

const (
	heartbeatInterval = 1 * time.Second
)

type promiseResult struct {
	value js.Value
	err   error
}

// Connect initiates the WebTransport connection asynchronously
func Connect(w donburi.World) {
	data, ok := Get(w)
	if !ok {
		return
	}

	if data.GetState() != StateDisconnected {
		return
	}

	data.SetState(StateConnecting)

	go func() {
		err := connect(data)
		if err != nil {
			log.Printf("network: connection failed: %v", err)
			data.SetError(err)
			return
		}

		// Start read/write loops
		go readLoop(data)
		go writeLoop(data)
		go heartbeatLoop(data)
	}()
}

// connect performs the synchronous connection handshake
func connect(data *NetworkData) error {
	if data.jsBridge.IsUndefined() || !data.jsBridge.Truthy() {
		data.jsBridge = js.Global().Get("wtBridge")
	}
	if data.jsBridge.IsUndefined() || !data.jsBridge.Truthy() {
		return errors.New("wtBridge not available")
	}

	if !data.callbacksSet {
		setupCallbacks(data)
	}

	if data.Reader == nil || data.pipeWriter == nil {
		pipeR, pipeW := io.Pipe()
		data.Reader = framing.NewReader(pipeR)
		data.pipeWriter = pipeW
	}

	ok, err := awaitPromiseBool(data.jsBridge.Call("connect", data.ServerURL))
	if err != nil {
		return err
	}
	if !ok {
		return errors.New("webtransport connect failed")
	}

	// Send JoinRequest
	joinReq := &pb.ControlMessage{
		Event: &pb.ControlMessage_JoinReq{
			JoinReq: &pb.JoinRequest{
				ProtocolVersion: ProtocolVersion,
				ClientVersion:   ClientVersion,
			},
		},
	}

	if err := sendMessage(data, joinReq); err != nil {
		return err
	}

	// Wait for JoinResponse
	msg, err := receiveMessage(data)
	if err != nil {
		return err
	}

	switch event := msg.Event.(type) {
	case *pb.ControlMessage_JoinRes:
		data.mu.Lock()
		data.PlayerID = event.JoinRes.YourId
		data.CurrentPlayers = event.JoinRes.CurrentPlayerIds
		data.State = StateConnected
		data.mu.Unlock()
		log.Printf("network: connected as player %s with %d others", event.JoinRes.YourId, len(event.JoinRes.CurrentPlayerIds))

	case *pb.ControlMessage_ErrorRes:
		return &ProtocolError{Code: event.ErrorRes.Code, Message: event.ErrorRes.Message}

	default:
		return &ProtocolError{Message: "unexpected response to JoinRequest"}
	}

	return nil
}

func setupCallbacks(data *NetworkData) {
	data.jsOnOpen = js.FuncOf(func(this js.Value, args []js.Value) any {
		return nil
	})
	data.jsOnOpenSet = true

	data.jsOnMsg = js.FuncOf(func(this js.Value, args []js.Value) any {
		if len(args) == 0 || data.pipeWriter == nil {
			return nil
		}
		raw := args[0]
		length := raw.Get("byteLength").Int()
		if length <= 0 {
			return nil
		}
		buf := make([]byte, length)
		js.CopyBytesToGo(buf, raw)
		go func(b []byte) {
			if data.pipeWriter == nil {
				return
			}
			_, _ = data.pipeWriter.Write(b)
		}(buf)
		return nil
	})
	data.jsOnMsgSet = true

	data.jsOnClose = js.FuncOf(func(this js.Value, args []js.Value) any {
		data.SetState(StateDisconnected)
		closePipe(data)
		if data.cancel != nil {
			data.cancel()
		}
		return nil
	})
	data.jsOnCloseSet = true

	data.jsOnError = js.FuncOf(func(this js.Value, args []js.Value) any {
		err := errors.New("webtransport error")
		if len(args) > 0 {
			err = errors.New(args[0].String())
		}
		data.SetError(err)
		select {
		case data.Errors <- err:
		default:
		}
		return nil
	})
	data.jsOnErrorSet = true

	data.jsBridge.Set("onopen", data.jsOnOpen)
	data.jsBridge.Set("onmessage", data.jsOnMsg)
	data.jsBridge.Set("onclose", data.jsOnClose)
	data.jsBridge.Set("onerror", data.jsOnError)
	data.callbacksSet = true
}

func closeBridge(data *NetworkData) {
	if data.jsBridge.IsUndefined() || !data.jsBridge.Truthy() {
		return
	}
	_ = data.jsBridge.Call("close")
	closePipe(data)
}

func releaseCallbacks(data *NetworkData) {
	if data.jsOnOpenSet {
		data.jsOnOpen.Release()
	}
	if data.jsOnMsgSet {
		data.jsOnMsg.Release()
	}
	if data.jsOnCloseSet {
		data.jsOnClose.Release()
	}
	if data.jsOnErrorSet {
		data.jsOnError.Release()
	}
	data.callbacksSet = false
	data.jsOnOpenSet = false
	data.jsOnMsgSet = false
	data.jsOnCloseSet = false
	data.jsOnErrorSet = false
}

func closePipe(data *NetworkData) {
	if data.pipeWriter != nil {
		_ = data.pipeWriter.Close()
		data.pipeWriter = nil
	}
}

func awaitPromiseBool(promise js.Value) (bool, error) {
	ch := make(chan promiseResult, 1)

	var thenFunc js.Func
	var catchFunc js.Func

	thenFunc = js.FuncOf(func(this js.Value, args []js.Value) any {
		result := js.Value{}
		if len(args) > 0 {
			result = args[0]
		}
		ch <- promiseResult{value: result}
		thenFunc.Release()
		catchFunc.Release()
		return nil
	})

	catchFunc = js.FuncOf(func(this js.Value, args []js.Value) any {
		err := errors.New("promise rejected")
		if len(args) > 0 {
			err = errors.New(args[0].String())
		}
		ch <- promiseResult{err: err}
		thenFunc.Release()
		catchFunc.Release()
		return nil
	})

	promise.Call("then", thenFunc).Call("catch", catchFunc)
	res := <-ch
	if res.err != nil {
		return false, res.err
	}
	if res.value.IsUndefined() || res.value.IsNull() {
		return false, nil
	}
	return res.value.Bool(), nil
}

// readLoop handles incoming messages from the server
func readLoop(data *NetworkData) {
	defer func() {
		data.SetState(StateDisconnected)
	}()

	for {
		select {
		case <-data.ctx.Done():
			return
		default:
		}

		msg, err := receiveMessage(data)
		if err != nil {
			select {
			case <-data.ctx.Done():
				return
			default:
				log.Printf("network: read error: %v", err)
				data.Errors <- err
				return
			}
		}

		switch event := msg.Event.(type) {
		case *pb.ControlMessage_Attack:
			select {
			case data.IncomingAttacks <- event.Attack:
			default:
				log.Println("network: incoming attack channel full, dropping")
			}

		case *pb.ControlMessage_Update:
			select {
			case data.PlayerUpdates <- event.Update:
			default:
				log.Println("network: player update channel full, dropping")
			}

		case *pb.ControlMessage_Heartbeat:
			// Calculate RTT
			now := time.Now().UnixMilli()
			rtt := time.Duration(now-event.Heartbeat.ClientSendTs) * time.Millisecond
			data.mu.Lock()
			data.RTT = rtt
			data.LastHeartbeat = time.Now()
			data.mu.Unlock()

		case *pb.ControlMessage_ErrorRes:
			log.Printf("network: server error: %s", event.ErrorRes.Message)
			data.Errors <- &ProtocolError{Code: event.ErrorRes.Code, Message: event.ErrorRes.Message}
		}
	}
}

// writeLoop handles outgoing messages to the server
func writeLoop(data *NetworkData) {
	for {
		select {
		case <-data.ctx.Done():
			return

		case attack := <-data.OutgoingAttacks:
			msg := &pb.ControlMessage{
				Event: &pb.ControlMessage_Attack{
					Attack: attack,
				},
			}
			if err := sendMessage(data, msg); err != nil {
				log.Printf("network: failed to send attack: %v", err)
			}
		}
	}
}

// heartbeatLoop sends periodic heartbeats
func heartbeatLoop(data *NetworkData) {
	ticker := time.NewTicker(heartbeatInterval)
	defer ticker.Stop()

	for {
		select {
		case <-data.ctx.Done():
			return

		case <-ticker.C:
			data.mu.Lock()
			data.PingID++
			pingID := data.PingID
			data.mu.Unlock()

			msg := &pb.ControlMessage{
				Event: &pb.ControlMessage_Heartbeat{
					Heartbeat: &pb.Heartbeat{
						PingId:       pingID,
						ClientSendTs: time.Now().UnixMilli(),
					},
				},
			}
			if err := sendMessage(data, msg); err != nil {
				log.Printf("network: failed to send heartbeat: %v", err)
			}
		}
	}
}

// sendMessage marshals and sends a ControlMessage
func sendMessage(data *NetworkData, msg *pb.ControlMessage) error {
	if data.jsBridge.IsUndefined() || !data.jsBridge.Truthy() {
		return &ProtocolError{Message: "no wtBridge available"}
	}

	buf, err := proto.Marshal(msg)
	if err != nil {
		return err
	}

	framed, err := framing.EncodeMessage(buf)
	if err != nil {
		return err
	}

	u8 := js.Global().Get("Uint8Array").New(len(framed))
	js.CopyBytesToJS(u8, framed)
	promise := data.jsBridge.Call("send", u8)
	attachPromiseLogging(promise)
	return nil
}

func attachPromiseLogging(promise js.Value) {
	var thenFunc js.Func
	var catchFunc js.Func

	thenFunc = js.FuncOf(func(this js.Value, args []js.Value) any {
		thenFunc.Release()
		catchFunc.Release()
		return nil
	})

	catchFunc = js.FuncOf(func(this js.Value, args []js.Value) any {
		if len(args) > 0 {
			log.Printf("network: send error: %s", args[0].String())
		} else {
			log.Printf("network: send error: promise rejected")
		}
		thenFunc.Release()
		catchFunc.Release()
		return nil
	})

	promise.Call("then", thenFunc).Call("catch", catchFunc)
}

// receiveMessage reads and unmarshals a ControlMessage
func receiveMessage(data *NetworkData) (*pb.ControlMessage, error) {
	if data.Reader == nil {
		return nil, &ProtocolError{Message: "no reader available"}
	}

	buf, err := data.Reader.ReadMessage()
	if err != nil {
		return nil, err
	}

	msg := &pb.ControlMessage{}
	if err := proto.Unmarshal(buf, msg); err != nil {
		return nil, err
	}

	return msg, nil
}

// Update processes network events in the ECS update loop
// This should be called from the game's Update function
func Update(w donburi.World) (attacks []*pb.AttackEvent, updates []*pb.PlayerUpdate, errors []error) {
	data, ok := Get(w)
	if !ok {
		return
	}

	// Drain incoming channels (non-blocking)
	for {
		select {
		case attack := <-data.IncomingAttacks:
			attacks = append(attacks, attack)
		default:
			goto drainUpdates
		}
	}

drainUpdates:
	for {
		select {
		case update := <-data.PlayerUpdates:
			updates = append(updates, update)

			// Update internal player list
			data.mu.Lock()
			if update.Action == pb.PlayerUpdate_JOIN {
				exists := false
				for _, id := range data.CurrentPlayers {
					if id == update.PlayerId {
						exists = true
						break
					}
				}
				if !exists {
					data.CurrentPlayers = append(data.CurrentPlayers, update.PlayerId)
				}
			} else if update.Action == pb.PlayerUpdate_LEAVE {
				for i, id := range data.CurrentPlayers {
					if id == update.PlayerId {
						data.CurrentPlayers = append(data.CurrentPlayers[:i], data.CurrentPlayers[i+1:]...)
						break
					}
				}
			}
			data.mu.Unlock()
		default:
			goto drainErrors
		}
	}

drainErrors:
	for {
		select {
		case err := <-data.Errors:
			errors = append(errors, err)
		default:
			return
		}
	}
}

// SendAttack queues an attack to be sent to the server
func SendAttack(w donburi.World, amount int32) {
	data, ok := Get(w)
	if !ok || data.GetState() != StateConnected {
		log.Printf("network: SendAttack skipped (not connected)")
		return
	}

	attack := &pb.AttackEvent{
		Amount: amount,
		// from_id and to_id are set by the server
	}

	select {
	case data.OutgoingAttacks <- attack:
		log.Printf("network: attack queued (amount=%d)", amount)
	default:
		log.Println("network: outgoing attack channel full, dropping")
	}
}

// IsConnected returns true if the network is connected
func IsConnected(w donburi.World) bool {
	data, ok := Get(w)
	if !ok {
		return false
	}
	return data.GetState() == StateConnected
}

// ProtocolError represents a protocol-level error
type ProtocolError struct {
	Code    pb.ErrorResponse_Code
	Message string
}

func (e *ProtocolError) Error() string {
	return e.Message
}

パーティクルシステムを自作

 chirashiというパーティクルエディタを自作しました。作成したパーティクルはyamlで管理されます。
全部yamlで管理されるのでAIに割と雑に指示をだしてもいい感じのパーティクルを生成してくれたりする。
まだ、kageシェーダーに役割を委譲する等の作り込みが甘いので絶賛改造中。

Monosnap [WARN_COPY MODE] Aburi Particle Editor (GPU) (Ubuntu) 2026-02-09 11.01.27.png

name: "burner_flame"
description: "Burner flame - focused, tapered flame jet"

image:
  image_from: "ef1"
  image_id: 16

emitter:
  x: 0
  y: 0

animation:
  duration:
    value: 0.25
    range:
      min: 0.2
      max: 0.3

  position:
    type: "polar"
    angle:
      min: 1.47  # ~84 degrees - almost straight down
      max: 1.67  # ~96 degrees - tight spread
    distance:
      min: 100
      max: 180
    easing: "Linear"

  alpha:
    start: 1.0
    end: 0.0
    easing: "InQuad"

  # Key: scale down to create tapered effect
  scale:
    start: 1.2
    end: 0.1
    easing: "Linear"

  rotation:
    start: 0
    end: 0
    easing: "Linear"

  # Hot burner: blue-white core -> blue -> orange tip
  color:
    start_r: 0.7
    start_g: 0.85
    start_b: 1.0
    end_r: 1.0
    end_g: 0.5
    end_b: 0.1
    easing: "InQuad"

spawn:
  interval: 1
  particles_per_spawn: 15
  max_particles: 1500
  is_loop: true
  life_time: 300

ebitenでWeb向けビルドを行う際はmageが便利

makefileをGoでかける的なツールで、ビルドして、サーブして、localhostでチェックみたいなことが可能になる。

// Serve builds web version and starts local development server on port 8080
func Serve() error {
	mg.Deps(BuildWeb)
	return startDevServer("build/web", 8080)
}

func BuildWeb() error {
	fmt.Println("Building Ebiten game for web...")
	if err := os.MkdirAll("build/web", 0755); err != nil {
		return err
	}

	// Copy public folder contents to build/web
	if err := copyDir("public", "build/web"); err != nil {
		return fmt.Errorf("failed to copy public folder: %w", err)
	}

	env := map[string]string{
		"GOOS":   "js",
		"GOARCH": "wasm",
	}
	return sh.RunWith(env, "go", "build", "-o", "build/web/game.wasm", "./cmd/wasm/")
}

// startDevServer starts a local HTTP server for web development
func startDevServer(dir string, port int) error {
	addr := "localhost:" + strconv.Itoa(port)

	// Custom file server with proper MIME types for WASM
	fs := http.FileServer(http.Dir(dir))
	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Set appropriate MIME types for WASM files
		if filepath.Ext(r.URL.Path) == ".wasm" {
			w.Header().Set("Content-Type", "application/wasm")
		}

		// Set Cross-Origin-Embedder-Policy and Cross-Origin-Opener-Policy headers
		// These are required for SharedArrayBuffer which may be used by WASM
		w.Header().Set("Cross-Origin-Embedder-Policy", "require-corp")
		w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")

		fs.ServeHTTP(w, r)
	})

	fmt.Printf("Starting development server at http://%s\n", addr)
	fmt.Printf("Serving files from: %s\n", dir)
	fmt.Println("Press Ctrl+C to stop the server")

	return http.ListenAndServe(addr, handler)
}

感想、まとめ

 今回は勉強7割の構成だったため、ここから、ゲームとしての独自性、ネットワーク周りの整備、ゲームアセットの作成とやることはたくさんあります。ここから、WebTransportの各仕様を深く読み込んでみたり、ゲーム用のフレームワークを作り込んでいく予定です。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?