Messages

Typed events anyone can send, who acts on them, object channels, and what one costs on the wire.

Updated 2026-09-10

Declaring a message

A message is a class with fields and a pair of methods that say how they travel:

public class OpenDoor : NetworkEvent
{
    public int doorId;

    public override void Serialize(ByteWriter writer)
    {
        writer.WriteInt32(doorId);
    }

    public override void Deserialize(ByteReader reader)
    {
        doorId = reader.ReadInt32();
    }
}

Packing is yours because guessing it would cost more than writing it: Udon has no reflection, so anything that serialized for you would have to be handed a description of every field anyway. A signal with no fields overrides neither method.

Separate the wish from the fact. OpenDoorRequest and DoorOpened as two types is almost always better than one type sent twice. A handler then knows which it has from the type, and cannot act on somebody's request as though it were settled.

Two places a message can go

A message goes either to the whole room or to one object's own channel. That is the entire choice, and what you write says which:

Room.Send(new OpenDoor { doorId = 3 });   // the room: every client
this.Send(new Touched());                 // this object's own channel
channel.Send(new Touched());              // another object's channel, by reference

this.Send is not Room.Send. It sends on the channel belonging to the object the behaviour is on, so nothing has to carry an id saying which object was meant. Room.Send is the room-wide one, and being static it also works from a plain or abstract class, which has no this to hang a call off.

Subscribing mirrors it exactly:

Room.Subscribe<OpenDoor>(OnOpenDoor);     // hear that type from the room
this.Subscribe<Touched>(OnTouched);       // hear that type on this object's channel
channel.Subscribe<Touched>(OnTouched);    // hear that type on another object's channel
what you write where it goes who hears it
Room.Send(evt) the room everything subscribed to that type, on every client
this.Send(evt) this object everything subscribed to that type on this object
channel.Send(evt) that one object everything subscribed to that type on that channel

The room is the one to reach for when anybody may send. An object's own channel has a single owner, so this.Send requires owning the object — see below.

Who acts on it

Addressing decides who acts, never who may send:

Room.Send(message, NetworkTarget.All);         // everybody, the default
Room.Send(message, NetworkTarget.Master);      // only the client arbitrating
Room.Send(message, NetworkTarget.Others);      // everybody but the sender
Room.Send(message, player);                    // one player

Any client can put any message on the wire addressed however they like, and every client receives the bytes either way. NetworkTarget.Master is a filter, not a permission, and a world that needs real authority checks who sent it:

private void OnOpenDoorRequest(VRCPlayerApi sender, OpenDoorRequest message)
{
    if (!Room.IsMaster || !Allowed(from))
        return;

    Room.Send(new DoorOpened { doorId = message.doorId });
}

That is the master-as-server shape: a client asks, the arbitrating client decides, and its own message to everybody is what actually changes anything.

What you get

In order, once each, sender included. A client acts on its own message immediately, because a client never receives its own writes and would otherwise be the only one that never heard.

A batch that arrives twice acts once. Every message carries a sequence number and each reader remembers where it got to, so a resend after VRChat refused the first attempt changes nothing.

A late joiner hears nothing they missed. Each batch carries when it was made, and a client that joined later drops one stamped before it arrived rather than replaying a fight it missed. That is the opposite of what a synced value should do, and exactly what an event should: use a synced value when a late joiner should learn the current state.

Nothing is lost to a refused send. VRChat caps how much one behaviour may send at once, and a batch over that cap is refused rather than truncated. A refused batch is kept and retried. A world that keeps Room.Bus.Pending above zero is sending faster than the wire will take it.

One object's own channel

A message about a particular object can travel on that object instead of on the room, so nothing has to carry an id saying which one it meant. Write the same two verbs on the behaviour itself, with no component to add and nothing to reference:

public override void Start()
{
    this.Subscribe<Touched>(OnTouched);
}

public override void Interact()
{
    this.Send(new Touched());
}

A handler can be told who sent it by taking the player as well:

this.Subscribe<Touched>((sender, message) => Log.Info(sender.displayName + " touched it"));

Sending requires owning the object. Only the owner's write replicates, so a non-owner is told now rather than finding out its message never left. Claim it first (Ownership.Claim(gameObject)) if any client should be able to send; a pickup or an interact usually already has.

To read another object's channel from somewhere else, that object needs a NetworkBus on it, and the same verbs work against the reference:

public NetworkBus channel;

channel.Subscribe<Touched>(OnTouched);
channel.Send(new Touched());

Either way an object channel has a single owner, so two players sending on the same one contend for it. Use an object channel when the object's owner is the one sending, and the room when anybody may send.

What it costs

Three bytes of overhead per message: one saying who should act on it, one length, one saying which type it is. A message addressed to a particular player carries that player's id as well.

A batch pays four bytes once, for the stamp saying when it was made. Once per batch and never per message, so batching several messages spreads it.

The budget that actually bites is VRChat's cap on one behaviour's synced state, which applies to a whole batch rather than to one message. this.Pending() is how a world sees it coming: zero almost always, and staying above zero means the batches are larger than one sync will take.

Custom events, without this package

VRChat's own event call is there whether or not you use any of the above, because the compiler passes it through:

this.SendCustomNetworkEvent(NetworkEventTarget.All, nameof(Ring));

public void Ring()
{
    bell.Play();
}

It calls a public method by name on every client, or on the owner with NetworkEventTarget.Owner. It carries no data, which is the whole reason typed messages exist. SendCustomEventDelayedSeconds and SendCustomEventDelayedFrames schedule a local call later and send nothing.

OnOwnershipRequest can be overridden to return false and refuse a transfer, and OnOwnershipTransferred runs on every client when an owner changes. ClientSim does not raise ownership requests, so that pair has to be tested in an uploaded world.

When nothing arrives

The package says so once per behaviour, rather than leaving you to guess:

  • Sending with no bus in the scene, so the message went nowhere.
  • Subscribing, then finding no bus anywhere to read for ten seconds.

Both mean the same thing in practice: the object that carries room messages is missing or disabled. It is added for you when a world first sends, so the usual cause is that it was switched off along with something else.

Something went wrong Reload