概要
- 15年のソフトウェアエンジニア経験 を活かし、初めてカードゲームを公開
- GoとReact、WASM を使ったサーバーレス構成で実装
- LLM(大規模言語モデル)前後の開発速度の違い を体験
- Tic-Tac-Toeリポジトリ を使ったゲーム開発の最小手順を解説
- WASMとの連携やトラブルシューティング まで具体的に紹介
15年目の初ゲーム公開体験
-
15年のソフトウェアエンジニア歴 を持つ筆者が、初めてゲーム開発と公開に挑戦
-
アルゼンチンの伝統的カードゲーム「Truco」 を題材に選定
-
Goでバックエンド を構築、 ReactでUI を最小限実装
-
サーバー不要 を目指し、 TinyGoでWASM化 しGitHub Pagesでホスティング
-
LLM普及前 の時代、全て手探りで3ヶ月かけて完成
-
広告や収益化は一切せず、純粋に完成を目指した作品
-
公開から1年経過後も プレイヤーが継続的に遊んでいる実績
- Truco(ゲーム本体)
- バックエンド(Go)
- フロントエンド(React、1時間の学習で実装)
LLM時代の開発「Escoba」:3日で実装
- 翌年、 家族にEscobaを教えるため に新ゲーム開発を決意
- LLM(Claude)を活用 し、TrucoのバックエンドをクローンしてEscoba用にリファクタリング
- 初回プロンプトでほぼ完璧に動作 し、バグ修正も最小限
- フロントエンドは自力で数日かかったが、 Reactスキルの課題 と WASMによる状態管理の複雑さ が主な要因
- デバッグもJavaScriptで苦労
- Escoba(ゲーム本体)、バックエンド(Go)、フロントエンド(React)を公開
最小構成で自作ゲームを作る手順
- Tic-Tac-Toeのリポジトリ をテンプレートとして提供
バックエンド(Go)の基本構成
- GameState構造体 で状態管理(初期盤面やアクション履歴など)
- CalculatePossibleActions で有効な手を算出
- RunAction でGameStateを更新
- Bot用関数 で自動プレイにも対応
- ヒューマン対ヒューマンは有料サーバー必須、基本はBot対戦推奨
フロントエンド(React)の基本構成
- 新規GameState生成 のAPIコール
- 盤面レンダリング
- 有効な手から選択し、アクション適用
- Botのターンを自動で処理
- シンプルな構成で実装可能
バックエンドとWASM連携手順
WASMへのコンパイル
- GOARCH=wasm GOOS=js go build でWASMバイナリ生成(ただし巨大化するためTinyGo推奨)
- TinyGo用のmain.go を別途用意し、エクスポート関数を明示
- main()の最後でselect {} を呼び、プログラムの即時終了を防止
//go:build tinygo
package main
func main() {
js.Global().Set("trucoNew", js.FuncOf(trucoNew))
js.Global().Set("trucoRunAction", js.FuncOf(trucoRunAction))
js.Global().Set("trucoBotRunAction", js.FuncOf(trucoBotRunAction))
select {}
}
データの受け渡し(JSON経由)
- GameState等の構造体はJSONでやり取り
- TinyGoのドキュメントを参考に、 js.CopyBytesToGo/ToJS でバイト列をやり取り
func trucoRunAction(this js.Value, p []js.Value) interface{} {
jsonBytes := make([]byte, p[0].Length())
js.CopyBytesToGo(jsonBytes, p[0])
newBytes := _runAction(jsonBytes)
buffer := js.Global().Get("Uint8Array").New(len(newBytes))
js.CopyBytesToJS(buffer, newBytes)
return buffer
}
フロントエンドからの呼び出し例
- WASM関数呼び出し・GameState管理のグローバル変数化
function jsRunAction(data) {
const encoder = new TextEncoder();
const encodedData = encoder.encode(JSON.stringify(data));
const result = trucoRunAction(encodedData);
const json = new TextDecoder().decode(result);
return JSON.parse(json);
}
let gameState = jsNewGame();
gameState = jsRunAction(action);
- 状態管理はWASM側が唯一のソース となり、フロントエンド側で直接変更不可
バックエンド更新時のWASM再ビルド
- Makefileで自動化推奨
- wasm_exec.jsのコピーも必須
- HTMLのHEADにscriptタグでwasm_exec.jsと初期化コードを追加
<script src="wasm/wasm_exec.js"></script>
<script>
const go = new Go();
const WASM_URL = 'wasm/wasm.wasm';
var wasm;
let wasmReady = false;
if ('instantiateStreaming' in WebAssembly) {
WebAssembly.instantiateStreaming(fetch(WASM_URL), go.importObject).then(function (obj) {
wasm = obj.instance;
go.run(wasm);
wasmReady = true;
})
} else {
fetch(WASM_URL).then(resp => resp.arrayBuffer() )
.then(bytes => WebAssembly.instantiate(bytes, go.importObject).then(function (obj) {
wasm = obj.instance;
go.run(wasm);
wasmReady = true;
}))
}
</script>
トラブルシューティング
- WASMファイルが読み込めない場合
- GitHub Pagesでは自動対応
- ローカルでは http-server 等でHTTPサーバー起動
- 例:
npx http-server ./public -p 8080 - ブラウザで
http://localhost:8080にアクセス
- 例:
まとめ
- ゲーム開発・公開の楽しさ と、 Go+React+WASMの実践的なノウハウ を共有
- LLM活用による開発効率化 の実感
- 自作ゲームに挑戦したい人向けの最小構成テンプレート と具体的手順
- 質問や相談も歓迎、気軽に連絡可能