Hey developers! Today we’re tackling something that can really elevate your game’s polish: a settings menu that actually remembers what your players choose. Whether it’s music volume, graphics quality, or keybind preferences, saving these choices makes your game feel professional and player-friendly.
By the end of this tutorial, you’ll understand how to create a settings UI, handle player inputs, and use DataStoreService to persist preferences across sessions. Let’s dive in!
Why Settings Menus Matter
Before we start coding, let’s talk about why this matters. Imagine playing a game where you have to mute the music every single time you join. Frustrating, right? Settings that persist across sessions show players you respect their time and preferences. It’s one of those “invisible” features that players notice when it’s missing.
We’ll be using DataStoreService to save preferences server-side, which means they’ll follow players across devices. This is better than just storing settings locally because players can access their preferences whether they’re on mobile, tablet, or PC.
Setting Up the Project Structure
First, let’s organize our project properly. Here’s what you’ll need:
- A ScreenGui in StarterGui for your settings menu UI
- A LocalScript to handle UI interactions (client-side)
- A Script in ServerScriptService to handle data storage (server-side)
- A RemoteEvent in ReplicatedStorage for client-server communication
Create a RemoteEvent in ReplicatedStorage and name it “SettingsEvent”. This will be our bridge between the client and server.
Creating the Settings UI
For this tutorial, we’ll create a simple settings menu with two common options: master volume and graphics quality. You can expand on this foundation later.
Create a ScreenGui with a Frame that contains:
- A TextLabel for the title (“Settings”)
- A Slider or TextButton for volume control
- Buttons or a Dropdown for graphics quality
- A close button
While you can design this however you like, make sure each interactive element has a clear name. For our example, I’ll assume you have a Slider named “VolumeSlider” and buttons for graphics named “LowButton”, “MediumButton”, and “HighButton”.
Building the Client-Side Logic
Let’s start with the LocalScript that handles UI interactions. This script will detect when players change settings and communicate those changes to the server.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local player = Players.LocalPlayer
local playerGui = player:WaitForChild("PlayerGui")
local settingsGui = playerGui:WaitForChild("SettingsGui")
local settingsFrame = settingsGui:WaitForChild("SettingsFrame")
local volumeSlider = settingsFrame:WaitForChild("VolumeSlider")
local lowButton = settingsFrame:WaitForChild("LowButton")
local mediumButton = settingsFrame:WaitForChild("MediumButton")
local highButton = settingsFrame:WaitForChild("HighButton")
local closeButton = settingsFrame:WaitForChild("CloseButton")
local settingsEvent = ReplicatedStorage:WaitForChild("SettingsEvent")
-- Local settings cache
local currentSettings = {
Volume = 0.5,
Graphics = "Medium"
}
-- Function to apply settings locally
local function applySettings(settings)
currentSettings = settings
-- Apply volume to all sounds in the game
local soundService = game:GetService("SoundService")
soundService.VolumeModifier = settings.Volume
-- Apply graphics settings
local renderSettings = settings:GetService("UserGameSettings")
if settings.Graphics == "Low" then
settings:GetService("UserGameSettings").SavedQualityLevel = Enum.SavedQualitySetting.QualityLevel1
elseif settings.Graphics == "Medium" then
settings:GetService("UserGameSettings").SavedQualityLevel = Enum.SavedQualitySetting.QualityLevel5
else
settings:GetService("UserGameSettings").SavedQualityLevel = Enum.SavedQualitySetting.QualityLevel10
end
end
-- Volume slider handling
volumeSlider:GetPropertyChangedSignal("Value"):Connect(function()
local newVolume = volumeSlider.Value
currentSettings.Volume = newVolume
applySettings(currentSettings)
settingsEvent:FireServer("Volume", newVolume)
end)
-- Graphics button handling
local function onGraphicsChange(quality)
currentSettings.Graphics = quality
applySettings(currentSettings)
settingsEvent:FireServer("Graphics", quality)
end
lowButton.MouseButton1Click:Connect(function()
onGraphicsChange("Low")
end)
mediumButton.MouseButton1Click:Connect(function()
onGraphicsChange("Medium")
end)
highButton.MouseButton1Click:Connect(function()
onGraphicsChange("High")
end)
-- Request saved settings when player joins
settingsEvent:FireServer("RequestSettings")
-- Listen for settings from server
settingsEvent.OnClientEvent:Connect(function(settings)
if settings then
applySettings(settings)
volumeSlider.Value = settings.Volume
end
end)
-- Close button
closeButton.MouseButton1Click:Connect(function()
settingsFrame.Visible = false
end)
This script does several important things. First, it maintains a local cache of settings so changes feel instant—we apply them immediately on the client before sending them to the server. Second, it uses a RemoteEvent to communicate with the server, sending updates whenever a setting changes. Finally, it requests saved settings when the player joins.
Building the Server-Side Logic
Now for the server script that handles saving and loading. This goes in ServerScriptService:
local DataStoreService = game:GetService("DataStoreService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local settingsDataStore = DataStoreService:GetDataStore("PlayerSettingsV1")
local settingsEvent = ReplicatedStorage:WaitForChild("SettingsEvent")
local DEFAULT_SETTINGS = {
Volume = 0.5,
Graphics = "Medium"
}
-- Cache for player settings (reduces DataStore calls)
local playerSettingsCache = {}
-- Load settings for a player
local function loadSettings(player)
local success, settings = pcall(function()
return settingsDataStore:GetAsync("Settings_" .. player.UserId)
end)
if success and settings then
playerSettingsCache[player.UserId] = settings
return settings
else
-- Return default settings if load failed or no data exists
playerSettingsCache[player.UserId] = DEFAULT_SETTINGS
return DEFAULT_SETTINGS
end
end
-- Save settings for a player
local function saveSettings(player, settings)
playerSettingsCache[player.UserId] = settings
local success, err = pcall(function()
settingsDataStore:SetAsync("Settings_" .. player.UserId, settings)
end)
if not success then
warn("Failed to save settings for " .. player.Name .. ": " .. err)
end
end
-- Handle incoming setting changes
settingsEvent.OnServerEvent:Connect(function(player, action, value)
if action == "RequestSettings" then
-- Send saved settings to player
local settings = loadSettings(player)
settingsEvent:FireClient(player, settings)
elseif action == "Volume" then
local settings = playerSettingsCache[player.UserId] or DEFAULT_SETTINGS
settings.Volume = value
saveSettings(player, settings)
elseif action == "Graphics" then
local settings = playerSettingsCache[player.UserId] or DEFAULT_SETTINGS
settings.Graphics = value
saveSettings(player, settings)
end
end)
-- Save settings when player leaves
game.Players.PlayerRemoving:Connect(function(player)
if playerSettingsCache[player.UserId] then
saveSettings(player, playerSettingsCache[player.UserId])
playerSettingsCache[player.UserId] = nil
end
end)
This server script is the heart of our persistence system. Notice how we use a cache to store settings in memory—this is important because we don’t want to call DataStore methods too frequently (they have rate limits). We only read from the DataStore when a player joins and write when they change a setting or leave.
Why We Separate Client and Server
You might wonder why we’re using RemoteEvents instead of just saving everything on the client. Here’s why this architecture matters:
- Security: DataStoreService only works on the server. This prevents exploiters from manipulating saved data.
- Consistency: Server-side storage ensures settings are the same across all devices.
- Performance: The client handles UI responsiveness while the server handles persistence in the background.
Handling Edge Cases
A robust settings system needs to handle failures gracefully. Our code already includes pcall() wrapping DataStore operations, which catches errors without crashing the game. Here are some additional considerations:
- DataStore limits: Avoid saving on every slider tick. Consider adding a small debounce so you only save after the player stops adjusting.
- Default values: Always have sensible defaults in case data fails to load.
- Version control: Notice how we named our DataStore “PlayerSettingsV1”? This makes it easier to update your settings structure in the future.
Testing Your Settings Menu
To test your settings menu:
- Enable Studio Access to API Services in Game Settings
- Play the game and adjust some settings
- Stop the game and play again—your settings should persist
- Test with a friend in a published game to verify it works for multiple players
Expanding Your Settings
Now that you have the foundation, you can easily add more settings. Want to add mouse sensitivity? Just add it to your DEFAULT_SETTINGS table, create UI for it, and add a handler in your client script. The server code barely needs to change because it already handles any key-value pairs you send.
Recap and Next Steps
Great work! You’ve built a complete settings system that saves player preferences using DataStoreService. You learned how to structure client-server communication with RemoteEvents, handle UI interactions smoothly, and persist data safely on the server.
The key takeaways are: always separate concerns between client (UI/responsiveness) and server (data persistence), use caching to reduce DataStore calls, and handle errors gracefully with pcall().
Ready to level up? Next, consider learning about ProfileService, a community-made module that provides more robust data management with session locking and better error handling. It’s perfect for games that need more complex data systems beyond simple settings.
Happy developing, and remember—small touches like persistent settings make a huge difference in player experience!