[VRChat] One Button, Three Reactions: Lights, Sound, and Signage in Sync

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

Switching a light, BGM, and a sign together from a single bool. Covers separating state from reactions, why you align the initial state in Start, and how to avoid deleting the button itself.

You want "press a switch and the mood changes" in your world. The lights go down, the BGM stops, a closed sign appears.

Write the three separately and they drift apart immediately. The light goes out while the sound plays on. Press again and only the sign is left behind. Something is stranded every time.

This article covers switching three reactions together from a single state.

Pressing the wall switch changes the light, the speaker, and the wall panel at once

What You'll Learn

  • Writing it as "one state, several reactions"
  • Why you align the appearance at startup
  • How to avoid deleting the button itself
  • When to use Light.enabled versus SetActive

Start from having tried variables and references.

Sponsored


One state, three reactions

The cause of drift is always the same. Each reaction ends up holding its own state.

The light remembers whether it's on, the sound remembers whether it's playing, the sign remembers whether it's showing. Three separate memories fall out of agreement somewhere.

The solution is simple. Remember only one thing.

One value, isOn, branching into three: light, sound, and signage

Hold one boolean called isOn and build all three appearances from it. On press, flip isOn and build the three again.

Then the result is the same however many times it's called, from wherever. It's logic that only "renders the current state into appearance."

Separating the press action, the state you remember, and the reactions that reflect it

This shape comes up repeatedly from here. When you add synchronization, and when you tell a late joiner the current state, the foundation is the same.

Sponsored

Hands-On: Switch light, sound, and signage together

One press turns on the light, starts the BGM, and shows the sign on the wall. Press again and all three go off together.

1. Place the button and three targets

Place four things in a scene with a floor.

NameHow to make it, and settings
RoomButtonCube. Position (0, 1, 1), Scale (0.4, 0.4, 0.4)
RoomLightLight → Point Light. Position (0, 2.5, 0), Range 6, Intensity 2, Mode "Realtime"
RoomSignCube. Position (0, 1.8, 2.8), Scale (1.2, 0.5, 0.05)
RoomAudioCreate Empty with an Audio Source added. Position (0, 1, 0)
Arrangement of light, sign, audio source, and button

Put a short looping sound in RoomAudio's Audio Source. Make sure Play On Awake is off. Left on, it starts playing the moment someone enters the world, which defeats the button. Turn Loop on.

Set the light's Mode to Realtime. Baked would leave the burnt-in light behind when you switch it off at runtime.

2. Write the code

In Assets/Scripts, choose "Create → U# Script" and name it LocalRoomToggle.

using UdonSharp;
using UnityEngine;

[UdonBehaviourSyncMode(BehaviourSyncMode.None)]
public class LocalRoomToggle : UdonSharpBehaviour
{
    [SerializeField] private Light targetLight;      // The lighting
    [SerializeField] private GameObject targetVisual; // The sign to show
    [SerializeField] private AudioSource targetAudio; // The BGM
    [SerializeField] private bool startOn;            // Whether to start on

    private bool isOn;   // The only thing we remember
    private bool ready;

    private void Start()
    {
        ready = targetLight != null && targetVisual != null && targetAudio != null;
        if (!ready)
        {
            Debug.LogWarning("[LocalRoomToggle] Set all three targets.");
            return;
        }

        // Hiding the button itself or its parent makes it unpressable
        if (targetVisual == gameObject || transform.IsChildOf(targetVisual.transform))
        {
            ready = false;
            Debug.LogWarning("[LocalRoomToggle] Don't set the button itself or its parent as the visual.");
            return;
        }

        isOn = startOn;
        ApplyState();   // Align the appearance with the state at startup
    }

    public override void Interact()
    {
        if (!ready) return;
        isOn = !isOn;
        ApplyState();
        Debug.Log("[LocalRoomToggle] isOn=" + isOn);
    }

    // Look at the state and build the three appearances. Same result however often it's called
    private void ApplyState()
    {
        targetLight.enabled = isOn;
        targetVisual.SetActive(isOn);

        if (isOn)
        {
            if (!targetAudio.isPlaying) targetAudio.Play();
        }
        else
        {
            targetAudio.Stop();
        }
    }
}

Three things to note.

ApplyState() gathers the reactions. It contains no "toggle" logic. It only looks at the current isOn and builds the matching appearance. That's why the same function can be called at startup and on press.

Sound is played after checking isPlaying. Calling Play() while it's already playing restarts it from the top. For looping BGM, that sounds like a cut.

It checks that the button itself isn't the target. Putting the button into targetVisual makes it disappear the moment you press it, never to be pressed again. With this check, a configuration mistake gets a reason in the Console.

3. Assign the references

Add a Udon Behaviour to RoomButton and set Program Source to LocalRoomToggle.

FieldWhat to set
Target LightRoomLight from the Hierarchy
Target VisualRoomSign from the Hierarchy
Target AudioRoomAudio from the Hierarchy
Start OnOff (start from the off state)
Interaction TextToggle room settings
Dragging from the Hierarchy into the three fields

Start On decides the state at startup. Turn it on and people enter a room that's already lit with BGM playing.

4. Run it and confirm

Press Play and press RoomButton.

TimingExpected result
Right after PlayThe light is off, the sign is hidden, and no sound plays
Press onceAll three go on together. Console shows isOn=True
Press againAll three go off together. isOn=False
Press repeatedlyAll three switch in lockstep every time
The room's lighting switching each time the button is pressed

This shows an earlier version of the example. In this article, sound and signage switch alongside the lighting.

Turn Start On on and Play again. All three are on from the moment you enter, because Start() calls ApplyState(). Without that one line, you get mismatches — the light turned off in the Inspector while the sign is showing.

Choosing what to switch

Light.enabled and GameObject.SetActive() look similar and differ in scope.

MethodWhat stopsWhere it fits
Light.enabled = falseThe light's emission onlyTurn off the lighting, keep the lamp's model visible
GameObject.SetActive(false)The whole object and its childrenHide the sign or model entirely

An object set to SetActive(false) has its Udon stopped too. Update doesn't run and nothing outside can call into it. So a hidden object can't bring itself back.

That's the true form of "I pressed the button and now I can't press it." Keep what you hide separate from the button doing the operating.

The same pattern works in plenty of situations.

  • A cafe or izakaya world: Open and closed. Switch lighting, in-store BGM, and the sign together
  • A gallery: An artwork's spotlight, ambient sound, and the caption panel
  • A horror world: A cabin's lamp. Switch on to see the note on the desk, off to return to darkness
  • A stage: Drop the house lights, bring up the stage lighting, sound effects, and the curtain

The example is one room, one switch, three reactions, but swapping the targets gives you any of these.

Sponsored

Common Pitfalls

  • Nothing happens on press → Check whether an isOn= log appears in the Console. If not, it isn't reaching Interact
  • Only one of them doesn't change → That reference is None, or points at the wrong target. Check the startup warning too
  • The sound restarts from the top on each press → You skipped the isPlaying check
  • It's out of sync from the moment you enter → Check that Start() calls ApplyState()
  • You pressed it and can't press it any more → The button itself or its parent is set as the visual target

Bonus: Good to Know Up Front

  • This is local logic: Only the presser's screen changes. Lining everyone up requires the mechanism in networking basics
  • One-time logic takes a different shape: "A lever that never goes back once pulled" can't be built by flipping isOn. Remember whether it was pressed separately
  • Delayed effects take a different shape too: "Sound two seconds after it lights" can't be expressed by one state. Use delayed events
  • Don't apply it every frame in Update: Writing targetLight.enabled = isOn; inside Update() works, but it repeats the same work every frame for nothing. Apply only when it changes
  • 3D positional sound needs VRC Spatial Audio Source: An Audio Source alone is fine if the volume should be the same anywhere in the room, but add it when you want the sound to grow as you approach. Covered in BGM and spatial audio

Summary

Lining up several reactions takes one pattern.

  • Remember one state. Build the reactions from that state
  • Group the applying logic into a function and call it both at startup and on interaction
  • Light.enabled is just the light; SetActive is the whole object
  • A hidden object's Udon stops. Don't hide the button itself

The question to ask while building is: "What state decides this appearance?" Once the answer is a single value, adding reactions never causes drift.

Next, operate the same target from multiple buttons. Go to methods and custom events, or survey the kinds of interaction in receiving player input. To make this switch reach everyone, go to networking basics.

VRChat Notes in this section63