UdonSharp SendCustomEvent: Control One Light from Two Buttons

Created: 2025-12-19Last updated: 2026-09-09

Control one light from two buttons in UdonSharp by keeping the logic on the light, and choose between typed references and SendCustomEvent.

You want light switches at both the entrance and the back of the room, either one toggling the same light.

The straightforward approach copies the same code into both buttons. It works, but the day you want to change the brightness, you're fixing two places. Forget one and the room behaves differently depending on where you press.

This article covers keeping one copy of the logic on the light's side, so buttons only call it.

Switches on the left and right walls, combining into one line to the ceiling light

What You'll Learn

  • Designing so logic lives in one place
  • How to call a method on another Udon directly
  • When to use SendCustomEvent instead
  • Why to use nameof

Start from having tried variables and references.

Sponsored


Separate the pressing side from the changing side

The way to avoid writing the same logic twice is to split the roles.

Two buttons calling the same single routine, which changes one light
  • The button side is responsible only for "it was pressed." It knows nothing about the light
  • The light side holds only the "toggle" logic. It doesn't know who pressed

Then growing to three buttons leaves the light's logic at one copy. Want a different brightness? Fix one place on the light side and it reaches all of them.

A structure separating the caller from the thing that actually changes

This shape pays off more as the world grows. Opening the same door from an entrance, the back, and a remote control just means keeping the logic on the door.

More callers, no more changers. That's the most important idea in this article.

Sponsored

Hands-On: One light from two buttons

Put buttons at the entrance and the back, and make either one toggle the same light.

1. Place the light and buttons

Place three things in a scene with a floor.

NameHow to make it, and settings
RoomLightLight → Point Light. (0, 2.5, 0), Range 6, Intensity 2, Mode "Realtime"
ButtonFrontCube. (-1.5, 1, -1), Scale (0.4, 0.4, 0.4)
ButtonBackCube. (1.5, 1, 2), Scale (0.4, 0.4, 0.4)
Two separated buttons pointing at the same light

Prepare an object for the side that operates the light too. Create LightController with "Create Empty" at (0, 0, 0).

2. Write the light-side code

Build the changing side first. In Assets/Scripts, choose "Create → U# Script" and make RoomLightController.

using UdonSharp;
using UnityEngine;

[UdonBehaviourSyncMode(BehaviourSyncMode.None)]
public class RoomLightController : UdonSharpBehaviour
{
    [SerializeField] private Light targetLight;
    [SerializeField] private float dimIntensity = 0.2f;
    [SerializeField] private float brightIntensity = 2f;

    private bool isBright;

    private void Start()
    {
        Apply();
    }

    // The entry point called from outside. Keep it public
    public void ToggleLight()
    {
        isBright = !isBright;
        Apply();
        Debug.Log("[RoomLightController] bright=" + isBright);
    }

    public void TurnOn()
    {
        isBright = true;
        Apply();
    }

    public void TurnOff()
    {
        isBright = false;
        Apply();
    }

    // Push state into appearance. This is the only thing that touches the light
    private void Apply()
    {
        if (targetLight == null) return;
        targetLight.intensity = isBright ? brightIntensity : dimIntensity;
    }
}

ToggleLight(), TurnOn(), and TurnOff() are public, because they're the entry points called from outside.

Apply() stays private. It's internal logic that nothing outside needs to call directly. Separating the entry points you expose from the internal logic makes it easier to read later.

Add a Udon Behaviour to LightController, set this script, and assign RoomLight to Target Light.

3. Write the button-side code

Now the pressing side. Make one named RoomLightButton.

using UdonSharp;
using UnityEngine;

[UdonBehaviourSyncMode(BehaviourSyncMode.None)]
public class RoomLightButton : UdonSharpBehaviour
{
    // Receive it by the other side's class name (a typed reference)
    [SerializeField] private RoomLightController controller;

    public override void Interact()
    {
        if (controller == null)
        {
            Debug.LogWarning("[RoomLightButton] Controller is not set.");
            return;
        }

        controller.ToggleLight();  // Call the other side's method directly
    }
}

It's short. The button only does "when pressed, ask that one over there."

The line to notice is [SerializeField] private RoomLightController controller;. You can use the other side's class name directly as a type. That's called a typed reference.

Because the type is fixed, you can write controller.ToggleLight(). Mistype it as ToggleLigt() and it becomes a compile error. You never find out at runtime.

4. Assign it to both buttons

Add a Udon Behaviour to both ButtonFront and ButtonBack and set RoomLightButton.

Drag LightController into Controller on both. They point at the same target.

FieldWhat to set
ControllerLightController from the Hierarchy
Interaction TextToggle lighting

5. Run it and confirm

Press Play and try both buttons.

ActionExpected result
Press the entrance buttonThe light brightens. Console shows bright=True
Press the back buttonThe light dims. bright=False
Press the entrance button againIt brightens

Either button toggles the same state. Since only one place holds the state, changing where you press doesn't cause drift.

Now change brightIntensity to 5. Without touching a character of the button code, the result changes for both buttons. That's the effect of keeping the changing side in one place.

SendCustomEvent, another way to call

There's another way to call the other side's method.

public override void Interact()
{
    if (controller == null) return;

    // Call by specifying the method name as a string
    controller.SendCustomEvent(nameof(RoomLightController.ToggleLight));
}

SendCustomEvent calls by passing a method name as a string. It has two conditions.

  • The called method must be public
  • It must take no arguments

Private methods can't be called. Trying to call Apply() does nothing.

Calling directly by type, or calling by name

There's a reason for nameof(). You could write the string "ToggleLight" directly, but then renaming the method later goes unnoticed. You only find out at runtime that it isn't being called. nameof turns it into a compile error.

So which should you use?

SituationWhat to use
The other side's class is knownA typed reference, called directly (controller.ToggleLight())
The other side's type isn't fixedSendCustomEvent
You want other players to run it tooSendCustomNetworkEvent (network events)

For this article's situation, a typed reference is simpler and safer. SendCustomEvent is for calling several kinds of script uniformly, or for calling across the network.

To pass a value, set it into the other side's variable first, then call.

controller.targetBrightness = 3f;   // Set the value first (needs a public variable)
controller.ApplyBrightness();       // Then call

With a typed reference, you can just call a method that takes arguments in the first place. Here too, the typed reference is the more natural route.

Sponsored

Common Pitfalls

  • Nothing happens on press → Check whether Controller is None. Check the startup warning too
  • Only one button works → That button's Controller is empty, or it has no Udon Behaviour
  • SendCustomEvent won't call → Check that the called method is public and takes no arguments
  • Each press gives a separate state → State is being held on the button side. Keep exactly one, on the changing side (the Controller)

Bonus: Good to Know Up Front

  • Few entry points, free interior: Make only the methods called from outside public and keep the rest private. Fewer entry points make it easier to follow when you read it later
  • One method, one job: A ToggleLight() that also plays sound and opens a door becomes unreusable. Split them and you can call whatever combination you need
  • There's a way to call after a delay: "Run three seconds after pressing" is written with SendCustomEventDelayedSeconds. Covered in calling logic after a delay
  • This is still local: Either button changes only the presser's screen. Reaching everyone requires the mechanism in networking basics
  • A disabled target does nothing: Setting the referenced GameObject to SetActive(false) stops its Udon. Calling it does nothing

Summary

The shape that keeps you from writing the same logic twice is always the same.

  • The pressing side just asks; the changing side holds the logic
  • Exactly one place holds the state, on the changing side
  • Receive by the other side's class name and you can call its methods directly
  • SendCustomEvent handles only public methods with no arguments. Write it with nameof

The question to ask while building is: "Will I want to call this same logic from somewhere else later?" If yes, split it from the start.

Next comes logic with a delay. Build a countdown in calling logic after a delay, or to make this toggle reach everyone, go to networking basics.

Read nextNext in the learning path
Syntax

UdonSharp SendCustomEventDelayedSeconds: A Door That Opens After 3 Seconds

Read this note
VRChat Notes in this section63