Implement AB test attributes in game code. Use when the user asks to add an AB test, make a value configurable via AB tests, add remote configurability, or wire up ABTests.GetAttribute / GetJobAttribute calls.
3f:T2fc2,
This skill guides you through adding AB test attributes to the codebase. See the API Reference at the end for method signatures and events.
| Scope | API | Assigned by | Use when |
|---|---|---|---|
| Player | GetAttribute / GetAttributeAsync | Per-player (UserId) | Different players should see different values: tutorial thresholds, UI variants, per-player multipliers |
| Job | GetJobAttribute / GetJobAttributeAsync | Per-server (JobId) | All players on a server share the same value: event parameters, feature toggles, wave configs, spawn rates |
Job-level tests use a JOBID- prefix internally -- you don't need to add this yourself; backend handles it based on test config.
Use dot-separated hierarchical names: {System}.{Feature}.{Parameter}
.Enabled (e.g., Eco.DiversityBonus.Enabled)Event.Money.StormDuration)FTU.TutorialGroup)-- Job-level (server-wide)
Event.Money.StormDuration -- number (seconds)
Event.FireAndIce.MutationChance -- number (0-1)
Wave.MinGapStudsMin -- number
GenRate.ShowOverhead -- boolean
AB.HideCarryStand -- boolean
LuckyBlock.NaturalSpawn.Enabled -- boolean
-- Player-level (per-player)
FTU.SessionCountThreshold -- number
Camera.ZoomMultiplier -- number
Eco.DiversityBonus.Enabled -- boolean
FriendRequest.Enabled -- boolean
Steal.Common -- number (weight)
| Variant | Yields? | Use in |
|---|---|---|
GetAttribute / GetJobAttribute | No | Gameplay loops, event handlers, UI updates, anywhere yielding is unsafe |
GetAttributeAsync / GetJobAttributeAsync | Yes | playerAdded, join flows, init() where you need the value before proceeding |
Rule: Default to sync. Only use async when you explicitly need to block until the value is available (e.g., deciding whether to teleport a player on join).
local ABTests = require(ReplicatedStorage.UserGenerated.ABTests)
Place it in the -- Imports section of the service/controller.
Find the hardcoded value and replace it with an ABTests call. Always use the current hardcoded value as the default so behavior is unchanged without backend config.
Before:
local STORM_DURATION = 30
After:
local stormDuration = ABTests.GetJobAttribute("Event.Money.StormDuration", 30)
If the value can change at runtime via live config updates, listen for changes. Which event depends on scope:
ABTests.PlayerUpdatedABTests.JobUpdatedIf the test needs custom targeting conditions (beyond what backend can do with built-in conditions), add a callback to ABTestConditions.server.luau. See section 6.
For values read during gameplay. No yielding.
-- In a service function
local function processReward(player: Player)
local multiplier = ABTests.GetJobAttribute("Reward.Multiplier", 1)
giveReward(player, BASE_REWARD * multiplier)
end
For values needed before a player-specific decision on join.
function MyService.playerAdded(player: Player)
local threshold = ABTests.GetAttributeAsync(player, "FTU.SpeedThreshold", 200)
if player.Data.CurrentSpeed >= threshold then
return
end
-- player qualifies for FTU flow
end
local ABTests = require(ReplicatedStorage.UserGenerated.ABTests)
local Players = game:GetService("Players")
local LocalPlayer = Players.LocalPlayer
local MyController = {}
local function applySettings()
local enabled = ABTests.GetAttribute(LocalPlayer, "Feature.Enabled", false)
if type(enabled) ~= "boolean" then
enabled = false
end
-- apply the value
end
function MyController.init()
-- Apply once loaded
if ABTests.IsLoaded() then
applySettings()
else
task.spawn(function()
ABTests.Loaded:Wait()
applySettings()
end)
end
-- React to live updates
ABTests.PlayerUpdated:Connect(function(updatedPlayer)
if updatedPlayer == LocalPlayer then
applySettings()
end
end)
end
return MyController
local ABTests = require(ReplicatedStorage.UserGenerated.ABTests)
local MyController = {}
local function applySettings()
local showFeature = ABTests.GetJobAttribute("AB.ShowFeature", true)
if type(showFeature) ~= "boolean" then
showFeature = true
end
-- apply the value
end
function MyController.init()
if ABTests.IsLoaded() then
applySettings()
else
task.spawn(function()
ABTests.Loaded:Wait()
applySettings()
end)
end
ABTests.JobUpdated:Connect(applySettings)
end
return MyController
When the controller cannot proceed without the value. Use sparingly -- this yields init.
function MyController.init()
local enabled = ABTests.GetJobAttributeAsync("FTU.HideTower", true)
if not enabled then
return
end
-- setup tower UI
end
Events typically read many job-level attributes for all their parameters:
local function run(duration: number, _context: any?)
local messageDelay = ABTests.GetJobAttribute("Event.MyEvent.MessageDelay", 3)
local messageText = ABTests.GetJobAttribute(
"Event.MyEvent.Message",
"My Event has started!"
)
local spawnRate = ABTests.GetJobAttribute("Event.MyEvent.SpawnRate", 0.5)
local maxItems = ABTests.GetJobAttribute("Event.MyEvent.MaxItemCap", 100)
task.delay(messageDelay, function()
Popup.GlobalMessage(messageText)
end)
-- use spawnRate, maxItems in event logic...
return function()
-- cleanup
end
end
Condition callbacks let the backend target tests to specific server/player states. Add them to src/ServerScriptService/UGApp/ABTestConditions.server.luau.
The file exports a module table of callbacks. All callbacks are auto-registered at the bottom via a loop:
for name, func in pairs(module) do
if type(name) == "string" and type(func) == "function" then
ServerABTests.RegisterConditionCallback(name, func)
end
end
--[[
Description of what this condition checks.
@param player Required/Optional
@param arg1 Description
@return true if condition is met
]]
function module.MyConditionName(
player: Player?,
arg1: number,
arg2: number?
): boolean
assert(player)
Asserts.Player(player)
Asserts.IntegerNonNegative(arg1)
if arg2 ~= nil then
Asserts.IntegerNonNegative(arg2)
end
-- logic here
return result
end
| Callback | Purpose |
|---|---|
TestRecentPurchases | Purchase count in last N days within [min, max] |
TestRobuxSpend | Total Robux spent in last N days within [min, max] |
TestSessionCount | Lifetime session count within [min, max] |
TestPlaceId | game.PlaceId in allowed list |
IsPrivateServer | game.PrivateServerId ~= "" |
IsPrivateServerOwner | Private server + player is owner |
TestServerSize | Player count within [min, max] |
TestPlaceVersion | game.PlaceVersion meets minimum per-place |
IsFTUServer | TPService.isFTU() |
(player: Player?, ...any) -> booleanassert(player) if the condition requires a playerAsserts at the topmin inclusive, max inclusive and optionalProfiles.GetAsync(player, true)player: Player? parameterDefault = current behavior. The default value in GetAttribute/GetJobAttribute must match the current hardcoded value. This ensures no behavior change without backend config.
Validate returned types. Backend can send unexpected types. Guard booleans and numbers:
local val = ABTests.GetJobAttribute("Key", true)
if type(val) ~= "boolean" then val = true end
Don't yield in hot paths. Use sync GetAttribute/GetJobAttribute in gameplay loops, event handlers, and render callbacks. These return the default if not loaded yet.
Comment the ABTest attributes. At the top of the file or above the function, document what attributes are read and their types/defaults:
-- ABTest Attributes:
-- - Camera.ZoomMultiplier: number (default 1.3)
Backend handles test configuration. You implement the attribute reads. Backend configures groups, percentages, conditions, and scheduling. Once your code is merged, backend sets up the test.
One attribute per tunable. Don't pack multiple values into a single table attribute when they could be independent. Event.Money.SpawnRate and Event.Money.MaxCap are better than Event.Money.Config = {spawnRate=0.5, maxCap=100}.
Works on both client and server:
local ABTests = require(ReplicatedStorage.UserGenerated.ABTests)
| Method | Yields? | Description |
|---|---|---|
ABTests.GetAttribute(player, key, default) | No | Returns current value or default if not loaded yet |
ABTests.GetAttributeAsync(player, key, default) | Yes | Yields until attribute is loaded, then returns value or default |
Client can only read LocalPlayer's attributes. Other players always return default.
Job attributes use the first player in the server (or LocalPlayer on client). All players on a server share the same value.
| Method | Yields? | Description |
|---|---|---|
ABTests.GetJobAttribute(key, default) | No | Returns current value or default if not loaded yet |
ABTests.GetJobAttributeAsync(key, default) | Yes | Yields until attribute is loaded, then returns value or default |
| API | Description |
|---|---|
ABTests.IsLoaded() | Returns true if the system has loaded initial values |
| Event | Fires when | Payload |
|---|---|---|
ABTests.Loaded | System loads initial values | (none) |
ABTests.PlayerUpdated | A player's attributes change at runtime | (player: Player) |
ABTests.JobUpdated | Job-level attributes change at runtime | (none) |
Default values can be any Luau type:
| Type | Example |
|---|---|
| String | ABTests.GetAttribute(player, "Variant", "Control") |
| Number | ABTests.GetAttribute(player, "DamageMultiplier", 1.5) |
| Boolean | ABTests.GetAttribute(player, "NewFeature", false) |
| Table | ABTests.GetAttribute(player, "ShopPrices", { sword = 100, shield = 50 }) |
LocalPlayer's attributes. Other players always return default.