あるイベント向けに「AIを使ってVRChatワールドを作ってみよう」という話があり、そこから巨大あみだくじワールド 「巨大あみだくじ / Ghost-Leg Express」 を制作しました。
プレイヤーはカートに乗り、ランダム生成された巨大なあみだくじの上を自動で進みます。参加しないプレイヤーも盤面の上を自由に歩き、走っているカートを追いかけながら観戦できます。
2026年5月15日にプロジェクトを開始し、同年5月30日にv1.0を公開。その後、PC、Quest、iOS対応のワールドになりました。
この記事では、VRChatワールド制作初心者寄りの視点から、次の内容を紹介します。
- 複数のAIを使った制作の進め方
- あみだくじを全員に同じ状態で見せるseed同期
- カートの座標を毎フレーム同期しない仕組み
- Quest対応をきっかけに変更した観戦体験
- Public公開直後に発覚したUdonSharpの実行時エラー
作ったもの
「巨大あみだくじ / Ghost-Leg Express」は、最大4人で遊べるVRChatワールドです。
基本的な流れは次のとおりです。
- プレイヤーが4台のカートから好きなものに乗る
- インスタンスのMasterがSTARTを押す
- ランダムなあみだくじが生成される
- カートが自動的に経路をたどる
- ゴールしたプレイヤーが対応する賞品エリアへ移動する
- 紙吹雪、爆発、または演出なしの結果が表示される
主な仕様は次のとおりです。
- 最大4人参加、観戦者は自由に盤面を歩行可能
- PC、Quest、iOS対応
- 日本語、英語UI
- プレイヤーごとのカート色選択
- 全員ゴール後の一斉演出と、個別到着時の即時演出を切り替え可能
- 途中参加者にも現在のあみだくじとカート位置を復元
技術スタックはUnity 2022.3 LTS、VRChat World SDK、UdonSharp、ClientSimです。
AIをどう使ったか
今回の制作では、設計と実装の中心にClaude Opusを使いました。コードレビューや文書化にはGemini、GitHub Copilot、Codexなども使っています。
おおまかな役割は次のようなものでした。
| 用途 | 主に使用したAI |
|---|---|
| 要件整理、仕様策定 | Claude Opus |
| アーキテクチャ設計 | Claude Opus |
| UdonSharp実装 | Claude Opus |
| コードレビュー | Gemini、GitHub Copilot、Codex |
| ドキュメント整理 | Gemini、GitHub Copilot、Codex |
単に「このワールドを作って」と依頼したわけではありません。
仕様書、タスクリスト、シーン構成、既知の落とし穴などをMarkdownで残し、AIが次の作業でも同じ前提を参照できるようにしました。また、大きな設計判断はADR(Architecture Decision Record)として記録しています。
例えば、このプロジェクトでは次のような判断をADRに残しました。
- あみだくじをカートで体験する
- 横線の状態ではなくseedを同期する
- カートの経路を事前計算する
- 同期変数を最小限にする
- 観戦画面を廃止し、カートを追いかける方式にする
- v1.0からQuest対応を含める
- 巨大な縦型構造ではなく、平面の盤面にする
AIに長期間の作業を任せる場合、会話履歴だけに頼ると判断が少しずつ変わってしまいます。「何を決めたか」だけでなく、「なぜそう決めたか」をリポジトリに残すことが重要でした。
全員に同じあみだくじを見せる
マルチプレイヤーのVRChatワールドでは、各プレイヤーが別々のクライアントでワールドを動かしています。
そのため、各クライアントで個別に乱数を生成すると、プレイヤーごとに異なるあみだくじが表示される可能性があります。
単純な解決方法は、すべての横線について有効・無効を同期することです。ただし、このワールドには最大33本の横線候補があります。今後レーン数を増やす場合まで考えると、状態を一つずつ同期する方式は扱いにくくなります。
そこで、横線の結果ではなく、乱数生成に使う seedだけを同期する 方式にしました。
Master
|
| seedを決定して同期
v
各クライアント
|
| 同じseedをSystem.Randomへ渡す
v
同じ横線配置をローカルで再生成
実装の中心はシンプルです。
public void Rebuild(int seed)
{
var rng = new System.Random(seed);
for (int seg = 0; seg < SEGMENT_COUNT; seg++)
{
int r = rng.Next(0, 10);
// rに応じて、その段に置く横線パターンを決める
// 各クライアントで同じ順番・同じ回数だけ乱数を呼ぶ
}
}
同じseedに対して、同じ順番で同じ回数だけNext()を呼べば、各クライアントで同じ結果を得られます。
この方式には次の利点があります。
- 同期するデータが小さい
- 横線候補が増えても同期変数を増やさずに済む
- 途中参加者もseedから盤面を復元できる
- 不具合が起きたseedを固定して再現できる
一方で、生成アルゴリズムの途中に新しい乱数呼び出しを加えると、それ以降の結果がすべて変わります。乱数を呼ぶ順序と回数も仕様の一部として扱う必要があります。
横線を完全な独立抽選にしなかった理由
あみだくじでは、同じ高さで隣り合う横線を同時に置くと、経路が曖昧になります。
例えば4レーンの場合、同じ段に次のような横線を置くことはできません。
|---|---| |
そこで各レーン間を個別に抽選するのではなく、「その段に置いてよい横線の組み合わせ」を先に定義し、重み付きで1パターンを選ぶ方式にしました。
(0, 0, 0) 横線なし
(1, 0, 0) 左だけ
(0, 1, 0) 中央だけ
(0, 0, 1) 右だけ
(1, 0, 1) 左右
これなら、隣接する横線が同時に有効になるパターンを最初から除外できます。後から競合を修正する処理も不要です。
カートの座標も同期しない
次の課題はカートの移動です。
カートのTransformを頻繁に同期すれば、各クライアントで位置を揃えられます。しかし、今回はカートの位置そのものも同期しない設計にしました。
まず、生成されたあみだくじから各カートの経路をWaypoint列として事前計算します。
スタート
|
v
縦方向のWaypoint
|
+-- 横線があれば隣のレーンへ移動
|
v
次の縦方向のWaypoint
|
v
ゴール
各クライアントは同じあみだくじを再生成しているため、同じスタートレーンなら同じWaypoint列を計算できます。
走行中は、同期された開始時刻と現在のサーバー時刻から経過時間を求めます。
double now = Networking.GetServerTimeInSeconds();
double elapsed =
Networking.CalculateServerDeltaTime(now, gameManager.raceStartTime);
その経過時間から移動距離と現在の区間を求め、Waypoint間を補間します。
float traveled = (float)elapsed * speed;
transform.position = Vector3.Lerp(from, to, t);
同期するのは開始時刻だけで、走行中の座標は各クライアントがローカルで計算します。
これにより、次の情報を共有するだけでレースを再現できます。
| 同期する値 | 用途 |
|---|---|
seed |
あみだくじとゴール演出の再生成 |
gameState |
待機中、走行中、結果表示などの状態 |
raceStartTime |
カート位置の時間基準 |
participantPlayerIds |
各カートの参加者 |
途中参加者も、seedから盤面と経路を再生成し、raceStartTimeから現在位置を計算できます。レースの最初から移動を再生する必要はありません。
サーバー時刻をそのまま引き算しない
当初の設計を詰める過程で、サーバー時刻同士を単純に引き算しない方針にしました。
// 採用しなかった形
double elapsed = now - raceStartTime;
VRChatではサーバー時刻の内部表現に起因して、値の符号などを意識する必要があります。そのため、差分計算にはVRChatが用意しているNetworking.CalculateServerDeltaTime()を使っています。
double elapsed =
Networking.CalculateServerDeltaTime(now, raceStartTime);
AIが一般的なC#として自然なコードを出しても、VRChat固有APIの前提まで含めて確認する必要があります。
観戦方式を「見る」から「追いかける」に変えた
初期案では、巨大な縦型あみだくじを作り、観戦デッキとRenderTextureの画面から全体を見る構想がありました。
しかし、Quest対応をv1.0へ含める方針にしたことで、次の問題が大きくなりました。
- RenderTextureでは追加の描画パスが必要
- 透過する観戦床はモバイルGPUへの負荷が高い
- PC版とQuest版で異なる観戦体験を作ると実装とテストが増える
- 観戦者が画面の前に立っているだけでは、VRChatらしい体験が弱い
そこで、観戦デッキとRenderTextureを廃止しました。
最終的には、あみだくじ全体を約60mの平面へ配置し、観戦者も盤面の上を歩けるようにしました。カートの速度を通常のプレイヤー移動より少し遅くし、観戦者が横を走って追いかけられるようにしています。
これはパフォーマンス上の都合から始まった変更ですが、結果として「画面で見る観戦」から「同じ盤面を一緒に走る観戦」へ変わりました。
Quest対応は、単にポリゴン数やテクスチャを減らす作業ではなく、体験そのものを見直すきっかけになりました。
QuestとiOSへの対応
PC専用であれば、表現や描画負荷に余裕があります。しかしVRChatではQuestユーザーも多いため、初期段階からモバイル対応を意識しました。
主に次の方針を採用しています。
- リアルタイムライトを使わず、Baked Lightingを使用
- モバイル対応シェーダーを使用
- 透過マテリアルを避ける
- テクスチャサイズを抑える
- 全マテリアルでGPU Instancingを有効化
- RenderTexture、Mirror、Video Playerなどを使用しない
- パーティクルの同時表示数を抑える
- PCとモバイルで基本的に同じ体験を提供する
リポジトリのCIでは、GPU Instancingが無効なマテリアルや、大きすぎるテクスチャ設定も確認しています。
当初、iOSは後のバージョンで対応する予定でしたが、最終的にはiOS Build Supportも追加し、PC、Quest、iOSの3プラットフォーム対応となりました。
Public公開直後にSTARTが動かなくなった
一番印象に残っている不具合は、公開直後に発覚した START無反応バグ です。
症状は非常に単純でした。
STARTを押しても、カートが発進しない。
ClientSimのConsoleには、次のようなエラーが出ていました。
An exception occurred during EXTERN to
'SystemConvert.__ToInt32__SystemInt64__SystemInt32'.
[UdonBehaviour] An exception occurred during Udon execution,
this UdonBehaviour will be halted.
原因は、AIが実装したseed生成処理でした。
seed = useDebugSeed
? debugSeed
: (int)System.DateTime.Now.Ticks;
DateTime.Now.Ticksはlongです。それをintへ明示的にキャストしていました。
通常のC#の感覚では、オーバーフローする値をintへキャストすると、下位ビットへ切り詰められる動作を想像するかもしれません。
しかしUdonSharpでは、このキャストがSystem.Convert.ToInt32(long)へコンパイルされました。これは範囲外の値に対してOverflowExceptionを発生させます。
DateTime.Now.Ticksはint.MaxValueを大きく超えるため、STARTを押すたびに必ず例外になっていました。
さらに、UdonBehaviourは実行時例外が発生するとhaltします。その場の処理が失敗するだけでなく、そのGameManagerは以後のイベントにも反応しなくなります。
なぜ公開前に見つからなかったのか
開発中は、同じあみだくじを再現するために固定seedを使っていました。
public bool useDebugSeed = true;
public int debugSeed = 12345;
useDebugSeedが有効な間はDateTime.Now.Ticksを使う処理へ入りません。そのため、開発中のテストでは問題なく動いていました。
公開直前に本番用としてuseDebugSeedをOFFにしたことで、初めて問題のコードパスへ入りました。そして、そのまま公開されました。
「テスト用設定では動くが、本番設定でだけ壊れる」という典型的な事故でした。
最終的な修正
最初は値をマスクしてintの範囲へ収める修正を行いました。
seed = (int)(System.DateTime.Now.Ticks & 0x7FFFFFFFL);
ただし、Ticksの下位31ビットを使う方式ではseedの周期が約3.5分と短く、同じseedが再登場しやすくなります。
そこで最終的には、最初からintを返すVRChat APIへ置き換えました。
seed = useDebugSeed
? debugSeed
: Networking.GetServerTimeInMilliseconds();
GetServerTimeInMilliseconds()なら縮小キャストが不要です。周期も約24.8日あり、今回のランダムseedとしては十分でした。
このバグから得た教訓
この件から、次のことを学びました。
- UdonSharpはC#に見えても、通常のC#と同じ実行結果になるとは限らない
-
(int)longなどの縮小キャストは、Udonでは実行時例外の原因になり得る - UdonBehaviourは例外でhaltするため、関連機能までまとめて無反応になる
- デバッグ設定だけでなく、本番と同じ設定でもテストする
- AIが書いた自然に見えるコードほど、対象環境での挙動を確認する
公開直後にSTARTが動かないという、なかなか強いデビューになりました。
AIレビューで見つかったこと
公開後は、GeminiやGitHub Copilotなどによるコードレビューも通しました。
レビューでは、今回のキャスト問題以外にも、次のような潜在的な問題が見つかりました。
- Waypoint配列の上限超過
- 端数によって終点付近の区間判定に失敗する可能性
- 参照不足でカートがゴールできず、ゲーム全体が進まなくなる可能性
- 同じ状態を再受信したときに演出が再発火する問題
- 座席退出後の情報が残り、参加者がいるように見える問題
- Static Batchingと動的オブジェクト設定の競合
AIが作ったコードを、別のAIにレビューさせるのは有効でした。ただし、指摘が正しいか、修正によって別の問題が起きないかは人間が判断する必要があります。
AI同士で多数決を取るというより、異なる観点を追加するために使うイメージです。
コード以外にもAIを使った
このプロジェクトでは、実装以外の作業にもAIを使いました。
仕様書とADR
ワールドの仕様、シーン構造、同期方式、パフォーマンス目標などをMarkdownで管理しました。
特にADRには、採用した案だけでなく、採用しなかった案とトレードオフも記録しています。
これにより、後から別のAIへ作業を引き継ぐ場合でも、「現在の実装がなぜこの形なのか」を共有しやすくなりました。
タスクリスト
制作工程をPhaseに分け、それぞれに完了条件を設定しました。
Phase 1: 盤面とシーン構築
Phase 2: カートとVRC_Station
Phase 3: seed同期と経路計算
Phase 4: 複数カートとゴール処理
Phase 5: UIとゲーム進行
Phase 6: 多言語、色、演出
Phase 7以降: Quest対応、実機確認、公開準備
「コードを書いたら完了」ではなく、ClientSim、Build & Test、実機確認などを完了条件へ含めました。
CI
GitHub Actionsでは、Unityを完全にビルドするのではなく、リポジトリ上で機械的に確認できる項目を検査しています。
- Unityの生成物やIDEファイルが混入していないか
- VRChatの
blueprintIdがシーンへ残っていないか - Markdownの相対リンクが壊れていないか
- GPU Instancingが有効になっているか
- Quest向けテクスチャサイズを超えていないか
AIに注意してもらうだけでなく、守れるルールはCIへ移すことで、同じミスを繰り返しにくくしました。
AIでVRChatワールドは作れたのか
結論として、AIを使ってVRChatワールドをPublic公開まで持っていくことはできました。
特に効果が大きかったのは、次の部分です。
- 要件を仕様へ落とし込む
- 複数案を比較する
- UdonSharpコードの初期実装を作る
- ドキュメントを実装に合わせて更新する
- コードレビューの観点を増やす
- 細かい修正を継続的に進める
一方で、AIへ任せきりにはできませんでした。
- VRChat上で本当に意図した体験になるか
- VR HMDで酔わないか
- Questでパフォーマンスが足りるか
- UdonSharp固有の挙動に問題がないか
- 公開前の設定が正しいか
これらは、最終的にUnity、ClientSim、VRChatクライアント、実機で確認する必要があります。
AIは非常に速く設計やコードを提案してくれます。しかし今回の公開直後バグのように、自信を持って間違ったコードを書くこともあります。
そのため、AIを「正解を出す存在」としてではなく、設計、実装、レビューを高速化する共同作業者として扱うのがよいと感じました。
まとめ
今回、AIを活用してVRChatの巨大あみだくじワールドを制作し、Public公開まで進めました。
技術面で特に有効だったのは、すべての結果や座標を同期するのではなく、seedと開始時刻を共有し、各クライアントで同じ状態を再計算する設計です。
また、Quest対応を早い段階で決めたことで、負荷の高い観戦画面を廃止し、プレイヤーがカートを追いかける体験へ変更できました。
そして、Public公開直後には、longからintへのキャストが原因でGameManagerがhaltする不具合も踏みました。
今回の主な学びは次のとおりです。
- 同期するデータを減らし、再現可能な情報を同期する
- 途中参加を最初から設計に含める
- Quest対応は体験設計と一緒に考える
- AIの判断を引き継げるよう、仕様と設計理由を残す
- AIが書いたコードでも、UdonSharp固有の挙動は実機で確認する
- デバッグ設定だけでなく、本番設定でも公開前テストを行う
ワールドはVRChatで 「巨大あみだくじ / Ghost-Leg Express」 と検索すると遊べます。
まだ公開したばかりなので、実際の遊ばれ方や改善点はこれから見えてくると思います。
関連リンク
- GitHub: Bonkoturyu/VRChat-Amidakuji-World
- VRChat: ワールド名「巨大あみだくじ / Ghost-Leg Express」で検索 or 以下のリンク


