はじめに
まず、出来上がったものがこちら(繋げて打ち上げる何か
誰かが部屋にはいった、出た、攻撃されたがマーキー通知されます。
サーバーからクライアントまで全部Goで構成されています。
やったこと
- 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の追加、削除をリアルタイムで行えるようにしている。

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シェーダーに役割を委譲する等の作り込みが甘いので絶賛改造中。
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の各仕様を深く読み込んでみたり、ゲーム用のフレームワークを作り込んでいく予定です。

![Monosnap [WARN_COPY MODE] Aburi Particle Editor (GPU) (Ubuntu) 2026-02-09 11.01.27.png](https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F24762%2F42558510-a10f-4cb5-905f-4ab8fed0061a.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&s=66ccd8f1ebb4cbfb78cb0dbb563bf1f4)