[VRChat] Saving Volume with PlayerData: Your Settings on Your Next Visit

Created: 2026-09-08Last updated: 2026-09-09

Saving a BGM volume slider to PlayerData and restoring it next visit. Hands-on with why reading before restoration wipes your save, and how to avoid it.

The BGM was a bit loud, so you turned the world's slider down and played. You enjoyed it, so you visit again the next day and get greeted at full volume.

Volume, subtitles on or off, whether you've seen the tutorial. Settings that are yours alone can't be kept in synced variables. Synchronization exists to tell people in this room right now, and it's gone once you leave.

This article makes the volume you set on a slider persist to next time.

The same person's second visit starting with the slider where they left it

What You'll Learn

  • When to use synced variables versus PlayerData
  • Why you wait for restoration before reading
  • That you write only your own, and pass whose data when reading
  • How to avoid hammering the save

Start from having tried networking basics and building world-space UI.

Sponsored


The blackboard is wiped; the locker stays

In the networking article we used the analogy of synced variables as a blackboard — a place readable by whoever is in the room. Leave the room and you part with that board.

PlayerData is your own locker, placed in that world. Its contents are yours alone and they're still there next time you come.

The blackboard is wiped when you leave the room; the locker stays until next time

Lining them up on volume makes the difference clear.

What you wantWhat to use
Stop the room's BGM for everyone present at onceSynced variable
Lower BGM for yourself alone, still lowered next timePlayerData

What can be saved is values. Booleans, numbers, and strings are the basics, and Vector3 and Color work too.

What can't be saved is equally clear. AudioSources and GameObjects themselves, materials, references to things in the scene. None of those get saved.

Only values get saved; things in the scene themselves don't

What you save is the number "the volume was 0.2," not the slider or the AudioSource. Next time, you write that number back into whatever is in the scene then.

Saves are per world, per player. The same account can't see it from a different world. Conversely, the same world in a different instance reads the same data.

Sponsored

Reading before restoration wipes your save

Here's the most common accident with PlayerData.

Right after entering, the saved data hasn't arrived yet. Reading in that state returns the default value (0 for a float), not what you saved.

Writing back the 0 read before restoration wipes the real save

The problem is putting that 0 into the slider. Moving the slider counts as "the volume changed" and triggers a save. That's how a real 0.2 gets overwritten with 0 and lost.

Reading early isn't harmful by itself. The danger is writing back a default value you mistook for the real one.

The signal for avoiding this is OnPlayerRestored.

public override void OnPlayerRestored(VRCPlayerApi player)

It means "this player's saved data has been retrieved, so you can read it now." Read after it fires and you get the correct value.

Two cautions about this event.

It fires for other people too. It fires on every join, for that person's restoration. So your BGM volume doesn't get rewritten by someone else's arrival, filter to your own with player.isLocal.

Writing and reading have different shapes. That's confusing at first.

PlayerData.SetFloat("bgm_volume", 0.2f);                          // Write: only your own
PlayerData.TryGetFloat(player, "bgm_volume", out float saved);    // Read: pass whose data

You can only write your own locker, so the writing side takes no player. The reading side can peek into other people's lockers, so you pass whose. Leaderboard displays are built on that reading form.

Hands-On: A slider that remembers the volume

Build a mechanism where a slider sets the BGM volume and the next visit starts at that volume.

1. Place the BGM and slider

Place the following in a scene with a floor.

NameHow to make it, and settings
BgmCreate Empty with an Audio Source added. Play On Awake and Loop on, Spatial Blend 0
SettingCanvasUI Canvas (World Space). (0, 1.5, 2), Scale (0.003, 0.003, 0.003)
VolumeSliderUI Slider as a child of SettingCanvas
PersistenceRootCreate Empty. (0, 0, 0)
Arrangement of the slider panel and the BGM

Put a loopable track in Bgm's Audio Source.

As in building world-space UI, add VRC Ui Shape to the Canvas and confirm the Graphic Raycaster is present. Without it, you can't touch the slider inside VRChat.

Set VolumeSlider to Min Value 0, Max Value 1, Value 0.5.

2. Write the code

Create BgmVolumeSetting in Assets/Scripts.

using UdonSharp;
using UnityEngine;
using UnityEngine.UI;
using VRC.SDK3.Persistence;
using VRC.SDKBase;

[UdonBehaviourSyncMode(BehaviourSyncMode.None)]
public class BgmVolumeSetting : UdonSharpBehaviour
{
    [SerializeField] private AudioSource bgm;
    [SerializeField] private Slider volumeSlider;
    [SerializeField] private float defaultVolume = 0.5f;

    private const string VolumeKey = "bgm_volume";

    private bool restored;      // Don't save until restoration finishes
    private bool savePending;   // Whether a save is already booked

    private void Start()
    {
        // Until restoration arrives, play at the volume the author chose
        Apply(defaultVolume);
    }

    // Fires once your saved data is readable
    public override void OnPlayerRestored(VRCPlayerApi player)
    {
        if (!player.isLocal) return;   // Do nothing on other people's restoration

        float volume = defaultVolume;
        if (PlayerData.TryGetFloat(player, VolumeKey, out float saved))
        {
            volume = saved;            // There was a previous value
        }
        Apply(volume);

        restored = true;               // Saving is allowed from here
    }

    // Wire this to the slider's On Value Changed
    public void OnVolumeChanged()
    {
        if (volumeSlider == null) return;

        // Change the sound immediately, so it can be judged by ear
        if (bgm != null) bgm.volume = volumeSlider.value;

        if (!restored) return;

        // While dragging, keep one booking and save it all at once 0.5 seconds later
        if (!savePending)
        {
            savePending = true;
            SendCustomEventDelayedSeconds(nameof(SaveVolume), 0.5f);
        }
    }

    public void SaveVolume()
    {
        savePending = false;
        if (volumeSlider == null) return;

        PlayerData.SetFloat(VolumeKey, volumeSlider.value);
        Debug.Log("[BgmVolume] saved: " + volumeSlider.value);
    }

    private void Apply(float volume)
    {
        if (bgm != null) bgm.volume = volume;
        if (volumeSlider != null) volumeSlider.value = volume;
    }
}

Three points when reading it.

restored is set after Apply(). Apply() moves the slider, which calls OnVolumeChanged(), and at that moment restored is still false so nothing saves. That avoids pointlessly re-saving the value you just restored.

Saving is capped at once per 0.5 seconds. Dragging the slider calls OnVolumeChanged() every frame. Saving each time would mean hundreds of cloud writes. Keeping one booking writes once, 0.5 seconds after you finish moving. That waiting uses the same mechanism as calling logic after a delay.

You choose the key string yourself. Use a recognizable name like "bgm_volume" and keep it in one place with const to cut typos. Match the type you write with the type you read.

3. Wire it up

Add a Udon Behaviour to PersistenceRoot and set BgmVolumeSetting. Drag in Bgm and Volume Slider.

In VolumeSlider's Inspector, press + on On Value Changed (Single) at the bottom.

FieldWhat to set
ObjectPersistenceRoot
FunctionUdonBehaviour → SendCustomEvent
ArgumentOnVolumeChanged
Specifying the method name as text from the slider's On Value Changed

What you pick from the function list is SendCustomEvent, and the method name you want called is typed as a string. A misspelling here silently does nothing.

4. Confirm it actually persists

First check the behavior with Build & Test.

OrderActionExpected result
1EnterThe slider is at 0.5 and the BGM plays at that volume
2Lower it to 0.2The sound gets quieter immediately
3Let go and wait a secondThe Console shows "saved: 0.2" exactly once
4Keep draggingLogs appear at intervals, not every frame

Rows 3 and 4 confirm saving isn't being hammered.

Whether it survives to next time gets confirmed in the uploaded world, because test save data lives only inside the client and disappears when it closes.

Even before submitting to Community Labs, you can confirm by uploading in a state only you can enter, leaving, and re-entering. The slider starting at 0.2 means it worked.

Sponsored

Common Pitfalls

  • It always starts at the default → You're reading in Start(). Wait for OnPlayerRestored
  • Settings don't save → Check that the key's spelling matches between writing and reading
  • Your volume changes when someone else joins → The player.isLocal check is missing
  • Moving the slider does nothing → Check the On Value Changed setup, or the Canvas's VRC Ui Shape
  • It's gone in Build & Test → Test data disappears when the client closes. Confirm by uploading
  • Save logs flood the Console → The booking mechanism isn't working. Check the savePending check

Bonus: Good to Know Up Front

  • Tell a first visit from zero: TryGetFloat returning false is a first visit. A returned 0 means "they saved it muted." Don't conflate the two
  • Don't add too many keys: Ten settings can use ten keys, and without a naming convention you'll lose track. Line them up as bgm_volume, subtitle_on, and so on
  • It's independent of synchronization: This script's sync mode is None and PlayerData works fine. It's a separate mechanism from any individual Udon Behaviour's sync setting
  • You can read other people's too: Since reading takes a player, you can gather everyone's records onto a board. Worlds with leaderboards use this
  • It's useful all over: BGM volume, a mirror's initial state, subtitle language, whether they've seen the tutorial, which room they were in last. Anything that doesn't need to match other people

Summary

PlayerData is your own locker, placed in that world.

  • Synced variables are wiped when you leave. PlayerData persists to next time
  • Wait for OnPlayerRestored before reading. Writing back a default read too early wipes the save
  • You write only your own. Reading takes whose data
  • Don't hammer the save. Write once after the movement finishes

The question to ask before saving is: "Is this a value that has to match other people, or is it mine alone?" Yours alone means PlayerData.

For a mechanism where everyone holds their own state, go to per-player state with PlayerObject. To lend out a fixed number of objects, go to VRCObjectPool.

VRChat Notes in this section63