ボードゲームのワールドを作っています。サイコロを置いて、押すと目が出るようにしました。
自分の画面では動いています。友達と入ってみると、自分の画面では3、相手の画面では5 が出ています。同じサイコロを見ているはずなのに、目が違います。
原因は単純です。それぞれのパソコンが、それぞれサイコロを振っている からです。この記事では、全員に同じ目が出る サイコロ を作ります。
この記事でわかること
- 各自で乱数を引くと食い違う理由
- 所有者が決めて配るという考え方
- 途中から来た人にも目が伝わること
- 転がる演出との組み合わせ方
所有権を理解する を読んだ状態から始めます。
振るのは1人でいい
まず、うまくいかない書き方を見ます。
// これでは食い違う
public override void Interact()
{
int value = Random.Range(1, 7);
diceText.text = value.ToString();
}
このコードは、押した人の画面でしか 動きません。 しかも、仮に全員で実行したとしても、それぞれ違う数が出ます。

乱数は、呼ぶたびに違う数を返します。 だから、複数のパソコンで別々に呼べば、当然ばらばらになります。
正しい形は、現実のボードゲームと同じです。
サイコロを振るのは1人。出た目を、みんなで見る。

コードにすると、こうなります。
- 押した人が 所有権を取る
- 所有者が
Random.Range()を 1回だけ 呼ぶ - その結果を 同期変数 に入れて配る
- 受け取った各自が、その数字を表示する
これは新しい仕組みではありません。 ネットワーク同期入門 の照明スイッチと、まったく同じ形です。持っている値が真偽から整数に変わっただけです。
「決めるのは1人、配るのは仕組み」 という形は、乱数以外でも使えます。この記事のいちばんの持ち帰りはここです。
実践:全員に同じ目が出るサイコロ
押すと1から6の目が出て、全員の画面でそろうサイコロを作ります。
1. サイコロと表示を置く
床のあるシーンに、次を置きます。
| 名前 | 作り方と設定 |
|---|---|
Dice | Cube。(0, 1, 2)、Scale (0.3, 0.3, 0.3) |
DiceCanvas | UI Canvas(World Space)。(0, 1.6, 2)、Scale (0.005, 0.005, 0.005) |
DiceText | DiceCanvas の子に TextMeshPro。大きめの文字、中央そろえ |

DiceText には、あらかじめ - と入れておきます。まだ振っていない状態です。
2. コードを書く
Assets/Scripts で「Create → U# Script」を選び、SyncedDice を作ります。
using UdonSharp;
using UnityEngine;
using TMPro;
using VRC.SDKBase;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class SyncedDice : UdonSharpBehaviour
{
[SerializeField] private TextMeshProUGUI diceText;
[SerializeField] private float cooldownSeconds = 1f;
[UdonSynced] private int diceValue; // 全員で共有する目
private float nextAllowedTime;
private void Start()
{
ApplyState();
}
public override void Interact()
{
// 連打を弾く
if (Time.time < nextAllowedTime) return;
nextAllowedTime = Time.time + cooldownSeconds;
if (!Utilities.IsValid(Networking.LocalPlayer)) return;
// 1. 振る人になる
if (!Networking.IsOwner(gameObject))
{
Networking.SetOwner(Networking.LocalPlayer, gameObject);
}
if (!Networking.IsOwner(gameObject)) return;
// 2. 1回だけ振る。ここが唯一の乱数
diceValue = Random.Range(1, 7); // 上限は含まれないので 7
// 3. 自分の画面に反映して、送信を頼む
ApplyState();
RequestSerialization();
Debug.Log("[SyncedDice] " + diceValue);
}
// 値が届いたときに呼ばれる
public override void OnDeserialization()
{
ApplyState();
}
// 値を見て、表示を作る
private void ApplyState()
{
if (diceText == null) return;
diceText.text = diceValue == 0 ? "-" : diceValue.ToString();
}
}
見どころは3つです。
Random.Range() を呼んでいるのは1か所だけ です。しかも所有権を確かめたあとにあります。他の人のパソコンでは、この行は動きません。
Random.Range(1, 7) の 7 に注目してください。整数版は上限が含まれないので、6 と書くと5までしか出ません。全員が一度は踏むところです。
構造は同期入門とまったく同じです。 所有権を取って、値を変えて、ApplyState() して、RequestSerialization()。受け取る側は OnDeserialization() から同じ ApplyState() を呼びます。
3. 割り当てて確かめる
Dice に Udon Behaviour を追加して、SyncedDice を指定します。Dice Textに DiceText をドラッグし、Interaction Text を サイコロを振る にします。
Build & Test で Number of Clients を 2 にして起動します。
| 順序 | 操作 | 期待する結果 |
|---|---|---|
| 1 | AとBが入る | どちらの画面も - |
| 2 | Aが振る | 両方の画面に、同じ数字が出る |
| 3 | Bが振る | 両方の画面が、同じ新しい数字に変わる |
| 4 | Aが連打する | 1秒に1回しか変わらない |
| 5 | Bが退出して入り直す | 最後に出た目が、そのまま見えてい る |
| 6 | 何度も振る | 1から6が出る。7や0は出ない |
2番目と5番目が、この記事の答え合わせです。
2番目では、Bは何もしていないのに同じ目を見ています。振ったのはAだけ だからです。
5番目では、途中から来たBに最後の目が届いています。同期変数を使っているので、あとから入った人にも最新の値が配られます。
6番目で、7 が出ないことも確かめてください。Random.Range(1, 7) が正しく書けている証拠です。
転がる演出を足す
数字がぱっと切り替わるだけでは味気ありません。転がるアニメーションを足したくなります。
ここで、順番を取り違えないでください。

| 順番 | 正しいか |
|---|---|
| 転がす → 止まった目を読む → 配る | 間違い(各自の物理がずれるので食い違う) |
| 振った瞬間に決める → 配る → 各自が演出を見せる | 正しい |
答えは先に決まっていて、演出はそれを見せているだけです。 テレビの生中継のように見えて、実は録画を流している、という形になります。
作り方は簡単です。ApplyState() の中で、すぐ数字を出す代わりに演出を始めます。
private void ApplyState()
{
if (diceValue == 0) return;
// 転がるアニメーションを再生する
if (diceAnimator != null) diceAnimator.SetTrigger("Roll");
// 1秒後に、決まっている目を出す
SendCustomEventDelayedSeconds(nameof(ShowResult), 1f);
}
public void ShowResult()
{
if (diceText != null) diceText.text = diceValue.ToString();
}
各自の画面でそれぞれ演出が走りますが、最後に出る数字は同じです。 転がり方が少し違っても、結果はそろいます。
待たせ方は 時間差で処理を呼ぶ、アニメーションの作り方は AnimatorとUdonで扉を動かす と同じです。
うまくいかないときは
- 人によって目が違う → 各自が
Random.Range()を呼んでいます。所有者だけが呼ぶ形にします - 6が出ない →
Random.Range(1, 6)と書いています。上限は含まれないので7にします - 自分の画面だけ変わる → 所有権を取っていないか、
RequestSerialization()を呼んでいません - 途中参加の人に伝わらない →
Start()でApplyState()を呼んでいないか、ネットワークイベントだけで伝えていま す - 連打で連続して変わる → 間隔を空ける処理を入れます
- 演出の途中で数字が見えてしまう → 表示を出すタイミングを、演出が終わったあとにします
おまけ:先に知っておくと良いこと
- 同じ形が使い回せる: 抽選、カードのシャッフル、ランダムなBGMの選曲、当たりくじ。「1人が決めて、みんなで見る」ものはすべて同じ書き方です
- 2個目のサイコロは別の変数で: 2個振るなら、同期変数を2つ持ちます。1つの変数に無理やり詰め込む必要はありません
- 誰が振ったかも配れる: 同期変数をもう1つ足せば、「振った人の名前」も一緒に配れます。ボードゲームでは手番の管理に使えます
- 完全な公平さを求めない: 所有者のパソコンが結果を決めているので、理屈のうえでは細工の余地があります。友達と遊ぶワールドなら十分ですが、賭けの成立するものには向きません
- 使いどころはいろいろ: すごろく、ガチャ、ルーレット、ランダムなお題出し。どれもこの形1つで作れます
まとめ
同期する乱数は、決める人を1人に絞る話です。
- 各自で引くと食い違う。振るのは所有者だけ
- 結果は同期変数で配る。構造は同期入門と同じ
Random.Range(1, 7)の上限は含まれない- 演出は、決まった答えをあとから見せるだけ
作るときの問いかけは、「これは誰が決めて、誰が見るのか」 です。決める人が1人なら、あとは配るだけです。
決まった数の物を貸し出すなら VRCObjectPool入門、一瞬の合図を送るなら Network Events へ進んでください。