ローカルの 2048 を AI が自動プレイし、その様子を VRM アバター「コテコ」(アルヨ調)が
実況する 完全オフライン のデモ AI2048 を作りました。トレードショーでの実演を想定し、
「AI が画面を見て考えて喋っている」絵で足を止めてもらうことを狙っています。
ポイントは「役割分担」です。
- 手の決定は expectimax(純 Python・CPU) — 安定して 2048 に到達。LLM には手を選ばせない。
- 盤面は localStorage の
gameStateから読む(画像認識でなく誤差ゼロ)。 - 制御ループの中心は OpenClaw(Docker)エージェント。毎ターン Qwen3 がスキルを呼ぶ。
- 実況は VOICEVOX(ずんだもん声)→ three-vrm(VRM アバター)に同期。
- 2048 の盤面を背景にライブ配信し、その前でアバターが喋る 1 画面の絵にする。
LLM(Qwen3.6)・音声合成(VOICEVOX)・VRM 表示(three-vrm)は
EarthTourGuide / AIassistant のスタックを流用し、
AMD GPU(Ryzen AI Max+ 395 / ROCm)上でフルローカルに動かしています。
リポジトリ: https://github.com/kotetsuy/AI2048
この記事の前半はセットアップ/使い方(リポジトリの READMEJ)、後半は仕組みと
設計判断(同 TECHNICALJ)をベースにまとめています。
第1部:セットアップと使い方
1. 動作環境
| 項目 | 値 |
|---|---|
| マシン | NucBox EVO X2 / Ryzen AI MAX+ 395 / gfx1151 / 48GB unified memory |
| OS | Ubuntu 24.04 |
| GPU | ROCm 7.2.x(HSA_OVERRIDE_GFX_VERSION=11.5.1) |
| 表示/音声 | xrdp + GNOME Remote Desktop、PipeWire → xrdp-sink |
LLM/VLM は gfx1151(ROCm)、OpenClaw 本体・expectimax・ブラウザ制御は CPU で動きます。
2. 前提(リポジトリ外で用意するもの)
このリポジトリはオーケストレーション一式(スクリプト・OpenClaw 設定・three-vrm 同梱版)を
含みますが、以下の 大きいアセットは別途用意します(.gitignore 対象 / ライセンス上同梱しない)。
| 必要物 | 既定パス | 入手方法 |
|---|---|---|
| 2048 ゲーム本体 | ~/2048 |
git clone https://github.com/gabrielecirulli/2048 |
| llama.cpp(ROCm ビルド) | ~/llama.cpp/build/bin/llama-server |
gfx1151 向けに ROCm 対応でビルド |
| Qwen3 モデル | ~/AIassistant/qwen3.6/...UD-Q4_K_XL.gguf |
GGUF を配置 |
| VRM アバター | ~/AIassistant/vroid/koteko.vrm |
VRM ファイルを配置 |
| VOICEVOX | Docker イメージ |
start_all.sh が自動 pull/起動 |
必須コマンド: docker(+ compose v2), tmux, curl, google-chrome, python3, xrandr。
3. セットアップ(git clone → 実行)
# 1) クローン
cd ~
git clone https://github.com/kotetsuy/AI2048.git AI2048
cd AI2048
# 2) 2048 ゲーム本体を取得(オフライン配信元)
git clone https://github.com/gabrielecirulli/2048 ~/2048
# 3) 背景配信(bgcast)用の venv を作成(playwright + aiohttp。Chromium 本体は不要)
python3 -m venv .venv
./.venv/bin/pip install playwright aiohttp
# ※ connect_over_cdp はホストの Chrome を使うので `playwright install` は不要。
# 4) Compose の .env を用意(パスとトークンを含む。.env はリポジトリに含まれない)
cd openclaw-demo
STATE="$(pwd)/state"
{ echo "OPENCLAW_IMAGE=openclaw-2048:local";
echo "OPENCLAW_CONFIG_DIR=${STATE}";
echo "OPENCLAW_WORKSPACE_DIR=${STATE}/workspace";
echo "OPENCLAW_GATEWAY_PORT=18789";
echo "OPENCLAW_GATEWAY_BIND=lan";
echo "OPENCLAW_TZ=Asia/Tokyo";
echo "OPENCLAW_GATEWAY_TOKEN=$(openssl rand -hex 32)"; } > .env
# 5) OpenClaw のコンテナ画像をビルド(公式画像 + Python + playwright)
docker compose build # → openclaw-2048:local
cd ..
openclaw-demo/.env は OPENCLAW_GATEWAY_TOKEN(シークレット)を含むため .gitignore 済みで、
リポジトリには含まれません。テンプレートは openclaw-demo/.env.example にあるので、手動で
用意する場合は cp .env.example .env してパス(絶対パス)とトークンを書き換えます。
上記 4) のワンライナーは現在地から .env を自動生成します。
4. 起動(これ一発)
cd ~/AI2048
./start_all.sh
start_all.sh が以下を順に立ち上げます(既に上がっていればスキップ=idempotent)。
- 2048 静的サーバ
:8009(tmux) - VOICEVOX(docker)
:50021 - llama-server
:8080(-c 65536 --parallel 2、AIzunda/EarthTourGuide と共用) - three-vrm
:8000(VRM 表示 +/speak+ 背景中継) - 2048 表示用 Chrome(CDP
:9222、headed) - OpenClaw gateway(docker)
:18789 - VRM 全画面表示 + 背景配信(
start_phase2_display.sh)
ホスト側プロセスは tmux セッション ai2048 で走ります。ログは tmux attach -t ai2048、
Chrome/bgcast は /tmp/chrome-*.log /tmp/bgcast.log を見ます。
動作確認(1 ゲームだけ実況)
cd openclaw-demo
docker compose run --rm -T openclaw-cli \
agent --agent main --session-key demo$(date +%s) \
--message "play2048 スキルで新規ゲームを始め、step→narrate で実況して報告して。"
exit 0 かつホスト画面の盤面が変化し、アバターが喋れば OK です。
5. 連続稼働(デモ本番)
cd ~/AI2048
./demo_loop.sh
外側ループが毎回フレッシュな --session-key で OpenClaw エージェントを呼び、
「数手 steps → narrate(間引き)」→ 終局で勝敗演出 → newgame を繰り返します。
調整用の環境変数:
| env | 既定 | 意味 |
|---|---|---|
DEMO_MOVES |
8 | 1 セッションあたりの手数 |
DEMO_GAP |
2 | セッション間の待ち(秒) |
DEMO_FRESH |
1 | 開始時に newgame するか(0 で継続) |
停止方法:
- アバター画面の 「⏹ デモ停止」ボタン(即時停止。進行中セッションも
docker stop)。 - 端末で
Ctrl-C(現セッション完了後に停止)。 - 起動 PID は
/tmp/demo_loop.pidに記録されます。
6. 停止
./stop_all.sh # デモ一式を停止
./stop_all.sh --keep-shared # 共用の llama / VOICEVOX は残す(推奨)
llama-server と VOICEVOX は EarthTourGuide / AIzunda と共用なので、
共用環境では --keep-shared を使ってください。
7. ポート一覧
| サービス | ポート | 用途 |
|---|---|---|
| 2048 静的サーバ | 8009 | ゲーム配信 |
| three-vrm | 8000 | VRM 表示 / /speak / 背景中継 |
| llama-server | 8080 | Qwen3(実況テキスト生成・共用) |
| Chrome CDP | 9222 | ボット操作 / 背景 screencast 元 |
| OpenClaw gateway | 18789 | エージェント制御プレーン |
| VOICEVOX | 50021 | 音声合成 |
8. トラブルシュート
| 症状 | 対処 |
|---|---|
| アバターが喋らない |
curl localhost:8000/status の clients が 0 → 表示 Chrome が /ws 未接続。start_phase2_display.sh を再実行し /tmp/chrome-vrm.log を確認 |
| 背景に 2048 が出ない |
/tmp/bgcast.log に /bg_ingest connected が無い → bgcast 未起動。.venv に playwright+aiohttp があるか確認(NO_BGCAST=1 で無効化も可) |
| エージェントが overflow | llama は -c 65536 --parallel 2 で起動(per-slot 32768 が必要) |
| CDP に繋がらない | Chrome は /tmp/chrome-cdp-2048 プロファイルで headed 起動。起動前に rm -f /tmp/chrome-cdp-2048/SingletonLock
|
Cannot continue from message role: assistant |
セッション main の使い回し → 毎回ユニークな --session-key を渡す |
第2部:仕組みと設計判断
ここからは「どう動いているか」と「なぜそう作ったか」を解説します。
1. 全体構成
| レイヤ | 担当 | 配置 |
|---|---|---|
| オーケストレーション | OpenClaw Gateway + エージェント(Qwen3) | Docker(network_mode: host) |
| 手の決定 | expectimax(純 Python) | コンテナ内 CPU |
| ブラウザ制御 | Playwright connect_over_cdp
|
コンテナ → ホスト Chrome |
| 盤面表示 | gabrielecirulli/2048 + Chrome(headed) | ホスト |
| 実況テキスト整形 | llama-server / Qwen3.6-35B-A3B | ホスト gfx1151(共用) |
| 音声合成 | VOICEVOX | ホスト(docker) |
| アバター/字幕/背景 | three-vrm + zundamon.html(three.js + three-vrm) | ホスト Chrome |
設計の核は、OpenClaw を「より agentic に見せる」ために毎ターンの制御ループの中心に
置きつつ(方式A)、手の質と速度は expectimax に固定して LLM には手を選ばせない、という分業です。
LLM(弱い・遅い)に手を選ばせると 2048 に届きません。LLM は実況テキストの生成と制御ループの駆動だけを担います。
確定した設計判断
- OpenClaw を本デモのオーケストレーション本体とし、Docker(Compose)で動かす。
- ゲームは gabrielecirulli/2048(本家)を
python -m http.serverでローカル配信(オフライン)。 -
盤面は localStorage の
gameStateから JSON で読む。敗北は.game-messageのクラス、
勝利(2048)はstate["won"]で判定。 - 手の決定は expectimax(純 Python・CPU)固定。LLM には手を選ばせない。
- llama-server は AIzunda / EarthTourGuide と共用。
--parallel 2以上で起動し、
会話と実況呼び出しが直列化しないようにする。 - 重い推論(LLM)は gfx1151(ROCm)、OpenClaw 本体・expectimax・ブラウザ制御は CPU。
- 「絵」方式は VRM アバター同居 + 背景化。2048 の画面を VRM の
scene.backgroundに流し、
その前でアバターが実況する 1 画面の絵にする。
2. 盤面の取得(認識誤差ゼロ)
2048 の GameManager は毎ターン完全な状態を localStorage["gameState"] に JSON で保存します。
これを CDP 経由で読むので、画像認識も DOM パースも不要で誤差ゼロです。
window.localStorage.getItem('gameState')
// → {"grid":{"cells":[[col0...],[col1...]]}, "score":N, "won":bool, ...}
-
cells[x][y]は x=列・y=行。parse_board()でboard[y][x]に転置して 4×4 行列にする。 -
ゲームオーバー時は
gameStateが消える仕様 → 敗北判定は.game-messageのクラス
(game-overを含むか)で行う。勝利(2048)はstate["won"]。 - コンテナ越しの高レイテンシで
gameStateが一瞬欠落する(None)ことがある →
settle_status()が board 確定 or over まで読み直して過渡状態を吸収する。
3. expectimax ソルバ(play2048_bot.py)
手の決定ロジックは純粋関数なので、そのまま流用します(play2048_cdp.py が choose_move を import)。
-
move_board(board, dir): 0=上/1=右/2=下/3=左。_compress_merge_left()で 1 列を詰めてマージ。 -
評価関数
evaluate():-
蛇行(snake)重み: 最大タイルを左上に集めて単調性を保つ古典ヒューリスティック(
4**kで重み付け)。 -
空きマスボーナス
EMPTY_BONUS = 4**13(詰まり防止)。
-
蛇行(snake)重み: 最大タイルを左上に集めて単調性を保つ古典ヒューリスティック(
-
expectimax(board, depth, is_chance): プレイヤーノードは max、チャンスノードは
空きマスに 2(0.9)/4(0.1) を置いた期待値。in-place 更新で deepcopy を回避。 -
可変探索深さ
_depth_for(): 空きが少ないほど分岐が減るので深く読む(空き ≥6→3、≥3→4、それ未満→5)。
4. OpenClaw 統合
4.1 スキルの形(重要な設計含意)
OpenClaw のスキルは「独自の typed tool を JS で登録する」ものでは ありません。
スキル = SKILL.md(YAML フロントマター + markdown 本文の指示書)で、本文がエージェントに
「いつ・どの組み込みツールを呼ぶか」を教えます。同梱スクリプトは組み込み exec ツールで実行します。
- 配置:
state/workspace/skills/play2048/(SKILL.md+play2048_cdp.py+play2048_bot.py) - フロントマター:
name,description,metadata.openclaw.requires.bins: [python3],os: [linux] - エージェントは
python3 {baseDir}/play2048_cdp.py <subcommand>をexecで呼び、stdout の 1 行 JSON を読む。
4.2 CLI サブコマンド(play2048_cdp.py)
各コマンドは毎回 connect_over_cdp し、状態はブラウザの localStorage に持ちます(ステートレス)。
stdout は機械可読の 1 行 JSON、人間向けログは stderr です。
| サブコマンド | 役割 | 主な出力 |
|---|---|---|
read |
盤面と状態を返す | board/score/won/over/max_tile/empty |
solve |
expectimax で次の手 | direction/key/dir_ja |
press <dir> |
矢印キー送出 | pressed |
step |
read+solve+press を 1 回に束ねる | event: move/won/over/stuck/wait |
steps --count N |
複数手を 1 コールで一気に着手(最速・間引き用の主役) | event, moves, score, max_tile, board |
narrate |
実況(three-vrm /speak へ送る) |
event:narrate, spoken, duration_sec, waited_sec |
newgame |
新規ゲーム開始 | event:newgame |
play |
モノリシック自動プレイ(保険) | result: won/lost/stuck |
steps がデモの主役です。1 セッション内で最大 N 手を着手し、途中で won/over/stuck/wait なら
そこまでで止めて返します。これで エージェントの LLM 往復を手ごとに発生させず数手まとめて進め、
実況を間引きます(実測 8 手 ≈ 1.5s。旧 step 方式は 4 手で約 70s でした)。
4.3 制御フロー(方式A・連続稼働)
demo_loop.sh が外側ループで、毎回フレッシュな --session-key で OpenClaw エージェントを呼びます。
SKILL.md の手順は次の通りです。
- セッション開始時に newgame しない(前セッションのゲームを継続。ブラウザに状態が残る)。
- 指定手数(既定 8)まで
steps --count 4→narrate(バッチごとに 1 回)を繰り返す。 - event が
won→narrate --speaker 1(あまあま声で勝利演出)→newgame。 -
over/stuck→ 締めの実況 →newgame。 - 手数・score・max を一言で報告して終了 → 次セッションが続きを回す。
数手ごとに新セッションにすることで、コンテキスト溢れも無限ゲームも回避しつつ
OpenClaw を制御の中心に保てます。
5. ネットワークとコンテキストの確定構成
実機検証で当初の仮説をいくつも更新しました。要点だけ挙げます。
5.1 network_mode: host(bridge ではない)
-
Chrome 149 は remote-debugging を 127.0.0.1 にしか bind しない
(--remote-debugging-address=0.0.0.0を無視)。→ bridge +host.docker.internalでは CDP に届かない。 -
host ネットワークなら localhost で CDP(9222)/2048(8009)/llama(8080)/VOICEVOX(50021)/three-vrm(8000)
すべてに到達。Chrome の DNS-rebinding(Host ヘッダ)制限も localhost で回避できる。 - このため provider baseUrl も
http://localhost:8080/v1(host net ではhost.docker.internalは解決不可)。 - Chrome 起動には
--remote-allow-origins=*も付与し、CDP 接続前にホスト名を IP 解決する(DNS-rebinding 対策)。
5.2 コンテキスト・サイズの規則(最重要)
- OpenClaw は 「プロンプトトークン ≤
contextWindow÷ 2」 を要求します(残り半分を応答用に予約)。 - ガードが許す最大使用量 =
contextWindow/2 + maxTokens= 24576 + 1024 = 25600 トークン。
これが llama の per-slot n_ctx に収まれば良い(contextWindowを n_ctx に揃える必要はない)。 - プロンプト縮小策:
-
skills.allowBundled: []で bundled スキル約 57 個をシステムプロンプトから除外。 -
plugins.deny: [...]でツール供給プラグイン 7 個を無効化(gateway ログが0 pluginsに)。 - 効果: 初回プロンプト P = 16,902 トークン(旧 ~21k から約 20% 減)。
-
-
確定構成: llama
-c 65536 --parallel 2(per-slot 32768)/ openclaw.jsoncontextWindow: 49152,
maxTokens: 1024。検証で 2 手 step→narrate がピーク n_past=19,610(truncated=0)、VRAM 24.8GB/48GB。
→--parallel 2を保ったまま overflow なし。 - KV キャッシュのプレフィックス再利用が効き、2 手目以降の prompt eval は ~150-730 トークンのみ。
5.3 LLM プロバイダ設定(openclaw.json)
models.providers."llama-host": {
baseUrl: "http://localhost:8080/v1",
apiKey: "dummy-key", // llama-server は認証しない
api: "openai-completions", // vLLM/SGLang と同じ
models: [{ id: "qwen3", reasoning: false,
contextWindow: 49152, maxTokens: 1024,
compat: { thinkingFormat: "qwen-chat-template" } }] // thinking 無効
}
agents.defaults.model.primary: "llama-host/qwen3"
gateway.mode: "local" // 無いと起動ブロック
6. 実況パイプライン(narrate → three-vrm → VOICEVOX → VRM)
narrate (play2048_cdp.py) ──POST /speak──► three-vrm(:8000)
──VOICEVOX 合成──► ──ws /ws──► zundamon.html(口パク + 字幕 + 再生)
-
narrateは実況テキストを three-vrm のPOST /speak(:8000) へ送ります(依存追加なし・urllib のみ)。
three-vrm が VOICEVOX で合成し、/ws経由で VRM 表示ページへ viseme 付きで配信します。 - 音声は VRM 表示 Chrome → PipeWire → xrdp-sink → Mac 側で再生(コンテナ内では再生しない)。
-
/speak失敗時はテキストのみで継続(ループは止めない=フォールバック)。
テンポ同期
-
/speakのレスポンスにduration_sec(合成 WAV の再生秒数) を追加(WAV をパースして算出。後方互換)。 -
narrateはduration_secぶん待ってから返るので、step→narrate ループが「喋り終わってから次手」になり
実況が被りません。PLAY2048_NARRATE_WAIT(0 で無効) /..._FACTOR/..._MAX_WAITで調整可能。 -
最速・間引きモード(デモ本番): compose で
PLAY2048_NARRATE_WAIT=0+PLAY2048_MOVE_DELAY=0.2。
narrate は発話を待たず即返り、実況は数手まとめて 1 回だけにしてテンポを優先します。
キャラクター(コテコ)
実況キャラはずんだもん(〜のだ)から コテコ(アルヨ調: 〜アル/アルヨ/アルネ) に変更しました。
声は VOICEVOX ずんだもん声(id=3) のまま、VRM は koteko.vrm を使います。
7. 背景ライブ配信(play2048_bgcast.py)
2048 の画面を VRM の scene.background に流し、その前でアバターが実況する 1 画面の絵を作ります
(EarthTourGuide の earth-controller と同方式)。
2048 Chrome(:9222) ──Playwright CDP screencast──► bgcast
──ws /bg_ingest──► three-vrm ──/bg──► zundamon.html scene.background
- 生 CDP を自前 WS で扱わず Playwright 経由にするのが肝です(aiohttp はブリッジ送信のみ)。
-
Page.startScreencast(JPEG) でフレーム取得 →screencastFrameAckを最優先(怠ると止まる)。 - three-vrm は
/bg_ingest(投入)と/bg(購読)の WS を持ち、最新フレームをキャッシュして新規購読者へ即送る。 -
ズーム fit: 2048 ページを縮小してウィンドウに収め盤面の下切れを防ぐ。
do_newgameの page.reload 後も
効くよう CDPaddScriptToEvaluateOnNewDocumentで再適用(実測 1024×768 で zoom≈0.81)。
8. デモ運用機能
-
停止ボタン: アバター画面の「⏹ デモ停止」→ three-vrm
POST /stop_demo。/tmp/demo_loop.pidで
対象を厳密特定し SIGINT。さらに進行中のエージェントセッション(コンテナ)もdocker stopし即時停止。 -
字幕: 3 行で頭打ち(
-webkit-line-clamp:3)。/speakは各発話前にturn_startを送り
バッファをリセット(送らないと字幕が累積し更新が止まって見える)。 -
アバター正面化:
koteko.vrmは VRM1.0。VRM 読込後にgltf.scene.rotation.y += atan2(...)で
カメラ方向へ向け、オフセット/距離が変わっても正面を保つ。 -
連続稼働 + 自動リスタート:
demo_loop.sh(方式A)。サービス断は wait 再確認、セッション失敗は次へ
(フォールバック)。各セッションはtimeout付き。
9. 現状ステータス
| フェーズ | 状態 |
|---|---|
| Phase 0(CDP 接続フィージビリティ) | ✅ 完了 |
| Phase 1(OpenClaw コンテナ化 + スキル + エージェント駆動) | ✅ 完了 |
| Phase 2(実況の実音声化: narrate→VOICEVOX→three-vrm) | ✅ 完了 |
| Phase 3(磨き込み: 背景化 / 一括起動停止 / 連続稼働 / 勝敗演出 / 停止ボタン / 字幕 / 正面化) | ✅ ほぼ完了 |
直近の 5 分間デモテストでは 4 セッション・失敗 0、自動リスタート・アルヨ調実況・テンポ同期を確認、
VRAM 24.93→25.06GB(リークなし)。残るは長時間(1〜2 時間相当)連続稼働の耐久確認のみです。
おわりに
「LLM を agentic な制御ループの中心に置きつつ、勝率を左右する手の決定は古典的な expectimax に
固定する」という分業がこのデモの肝でした。盤面を localStorage から読んで認識誤差をゼロにしたこと、
steps で LLM 往復を間引いてテンポを稼いだこと、共用 llama-server の per-slot コンテキストに
収まるようプロンプトを削ったこと——あたりが頭を悩ませたポイントです。
コードは https://github.com/kotetsuy/AI2048 にあります。
音声・VRM パイプラインのベースは kotetsuy/EarthTourGuide /
kotetsuy/AIassistant です。


