UI Designer
On this page
- The two hook scripts
- On the design page: Make it work in your game
- If the scripts are missing
- What's inside
- Events: ui:on
- Currencies
- On the server
- On the client
- Opening and closing screens
- Buttons and actions
- Remote actions and server checks
- Developer products and game passes
- 1. Create the real ids
- 2. Put the ids in the design
- 3. Delivering what was bought
- Saving data
- Settings
- Game systems
- Daily rewards
- Quests
- Battle pass
- Codes
- Spin wheel, gifts, Index, achievements and offers
- Teleports
- Trading
- The checklist
- Quick reference
Scripting your UI
Your exported UI already opens screens, plays sounds, saves settings and sells currency packs. This page shows how to hook it to your game: what its buttons fire, how currencies stay in sync, how to sell developer products and game passes safely, and how to save data.
The two hook scripts#
Every export comes with two scripts that connect the UI to your game: the .rbxmx, the starter place and Send to Studio all include them. They are written from your design, so they already list every button, event, product and setting it has:
| Script | Where | What it does |
|---|---|---|
<Name>_UIHooks (LocalScript) | StarterPlayer > StarterPlayerScripts | An empty ui:on handler for every event the UI fires, grouped by screen, plus an Examples table for currencies, the HUD and screens. |
<Name>_ServerHooks (Script) | ServerScriptService | Checked handlers for Remote actions, delivery hooks for products and rewards the UI can't pay by itself, and, on its last line, UI.Server.start({...}) with options picked for this UI. |
- From the
.rbxmx: the server script ships turned off, so it can't run from Workspace. When the game runs,BloxUI_Setupmoves both scripts into place and turns the server one on. If this UI's hook scripts are already installed, it keeps those, so a game never runs two copies. - Send to Studio installs both in the same undo step as the UI. Sending again updates them, except a script you've edited: that one is kept exactly as you left it.
- Both carry the attributes
BloxUIHooks(your UI's name),BloxUIHooksSide(ClientorServer) andBloxUIHooksHash. That's how an install tells your edited scripts apart. Leave them on and keep the scripts' names.
On the design page: Make it work in your game#
Under your design on the UI Designer, the Make it work in your game box shows the same checklist in plain words: how to put the UI in your game, then what's left to do. Copy AI prompt copies a prompt for a coding AI with this UI's events, server actions, currencies and products, and both scripts once the design is exported (how to use it). How to hook it up opens this page.
If the scripts are missing#
A UI installed before exports included the scripts, or scripts you deleted: make them again from the design in the place. Open Studio's Command Bar (in the View tab in the classic layout; search Studio's menus for "Command Bar" if you don't see it), paste the whole block, change NAME to your UI's name and press Enter.
-- Only if <Name>_UIHooks / <Name>_ServerHooks are missing (exports include them). Makes both, tagged like an export's,
-- so Send to Studio updates them later. Paste it all into the Command Bar and press Enter.
local NAME = "NeonStrike" -- your UI's name: the StringValue in ReplicatedStorage > BloxUI_Blueprints
local RS = game:GetService("ReplicatedStorage")
local pack = workspace:FindFirstChild("BloxUI_" .. NAME) -- a dropped .rbxmx that hasn't run yet
local runtime = RS:FindFirstChild("BloxUI") or (pack and pack:FindFirstChild("BloxUI"))
local folder = RS:FindFirstChild("BloxUI_Blueprints") or (pack and pack:FindFirstChild("BloxUI_Blueprints"))
local value = folder and folder:FindFirstChild(NAME)
assert(runtime and value, "Can't find BloxUI and the UI " .. NAME .. " in this place")
local UI = require(runtime)
local Integration = UI.Blueprint.Integration
local out = Integration.generate((UI.Blueprint.decode(value.Value)), { name = NAME })
local places = {
{ "LocalScript", "_UIHooks", "Client", game:GetService("StarterPlayer"):FindFirstChildOfClass("StarterPlayerScripts"), out.client },
{ "Script", "_ServerHooks", "Server", game:GetService("ServerScriptService"), out.server },
}
for _, p in places do
for _, child in p[4]:GetChildren() do
local mine = child:GetAttribute(Integration.TAG) == NAME and child:GetAttribute(Integration.SIDE) == p[3]
assert(not mine and child.Name ~= NAME .. p[2], NAME .. p[2] .. " is already in " .. p[4].Name)
end
end
for _, p in places do
local s = Instance.new(p[1])
s.Name = NAME .. p[2]
s.Source = p[5]
s:SetAttribute(Integration.TAG, NAME)
s:SetAttribute(Integration.SIDE, p[3])
s:SetAttribute(Integration.HASH, Integration.hash(p[5]))
s.Parent = p[4]
end
print("Added " .. NAME .. "_UIHooks and " .. NAME .. "_ServerHooks. Checklist items to do: " .. out.checklist.todo)It uses the generator inside the BloxUI runtime (UI.Blueprint.Integration), the same one that writes the scripts for exports, and tags them the same way. It never replaces a script: if one is already there, it stops and says so.
What's inside#
- A header with the checklist: product ids to add, rewards only your code can give, event-only settings.
- Handlers that are empty but safe. A delivery hook returns
falseuntil your code gives something, so an unfinished stub never marks a purchase as paid. - Server handlers that check every value a client sends before using it.
- An
Examplestable nothing calls: copy the lines you need.
Fill in the handlers your game needs and delete the rest. The smallest possible client script looks like this:
-- A LocalScript in StarterPlayer > StarterPlayerScripts
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local UI = require(ReplicatedStorage:WaitForChild("BloxUI"))
-- BloxUI_BlueprintLoader builds the UI and registers it under its name
local ui = UI.Blueprint.waitFor("NeonStrike", 30)
if ui == nil then
warn("The UI NeonStrike wasn't built: is BloxUI_BlueprintLoader in StarterPlayerScripts?")
return
end
ui:on("Rebirth", function(context)
print("Rebirth pressed on", context.screen, "by button", context.id)
end)
ui:open("shop")Events: ui:on#
ui:on(name, fn) runs fn(context) when the UI fires name. context.id is the element (a button id), context.screen its screen (nil on the HUD), context.payload what it sent. Handlers run on the client, so use them for effects and for asking the server.
-- An Event action: { "kind": "Event", "name": "Rebirth" } on a button
ui:on("Rebirth", function(context)
-- context.id = the button's id ("doRebirth")
-- context.screen = the screen it is on ("rebirth")
-- context.payload = what the element sent (nil for a plain button)
end)
-- Every click also fires the element's own id, whatever its action
ui:on("doRebirth", function(context)
print("the Rebirth button was pressed")
end)
-- An Event with a payload (a teleport row button: { "kind": "Event", "name": "Teleport", "payload": "shop" })
ui:on("Teleport", function(context)
print("go to", context.payload) -- "shop"
end)
-- Screens opening and closing
ui:on("ScreenOpened", function(context)
print("opened", context.screen)
end)
ui:on("ScreenClosed", function(context)
print("closed", context.screen)
end)
-- ui:on returns a function that removes the handler again
local stop = ui:on("Equip", function(context)
print("equip", context.payload) -- the item id
end)
stop()Sections fire their own events. The hook scripts stub the ones your UI has:
| Event | Fired when | context.payload |
|---|---|---|
Purchased | something was bought with an in-game currency (the server already took the price) | { section, item } |
Upgrade | an upgrade level was bought | { id, level } |
Equip, Unequip, Favorite | inventory buttons | the item id |
DailyClaim | a daily reward day was pressed (display only) | the day number |
QuestClaim | a finished quest was claimed (display only) | the quest id |
PassClaim | a pass tier was claimed (display only) | { tier, track } |
CaseOpened | a case or egg reveal played (the server rolled it) | the drop { name, icon, rarity } |
SpinWon, RewardClaimed | a wheel stopped, a reward was claimed (the server already gave it) | { index, wedge }, { kind, item, data } |
Teleport | a teleport row button or a Worlds card | the place id, e.g. "shop" |
TradeAccept, TradeDecline | the Trade window's buttons | none |
Loaded | the loading screen finished | none |
ScreenOpened, ScreenClosed | any screen opens or closes | the screen id |
Currencies#
Each currency has a source that decides who owns the number:
| Source | Where the number lives | Use it for |
|---|---|---|
leaderstat | player.leaderstats.<Name> (UI.Server creates it) | the main currency, shown on the player list |
attribute | the player attribute <Name> | second currencies (gems, tokens) |
none | only on the player's screen | decoration; anything bought with it only happens on that screen |
The UI Designer makes currencies that products, passes or cases pay into server-owned (leaderstat or attribute), so the server can deliver them. The counters follow the leaderstat or attribute by themselves: change the number on the server and the UI updates. You never set a counter from the client.
On the server#
-- In <Name>_ServerHooks (server), above UI.Server.start
local Players = game:GetService("Players")
-- A part players touch to collect 25 coins (once every 10 seconds per player)
local coinPad = workspace:WaitForChild("CoinPad")
local lastTouch = {}
coinPad.Touched:Connect(function(hit)
local player = Players:GetPlayerFromCharacter(hit.Parent)
if player == nil then
return
end
local now = os.clock()
if lastTouch[player] and now - lastTouch[player] < 10 then
return
end
lastTouch[player] = now
UI.Server.grant(player, "coins", 25) -- saved, and waits while the player's data loads
end)
Players.PlayerRemoving:Connect(function(player)
lastTouch[player] = nil
end)
-- Reading a balance on the server
local function canAffordSword(player)
return (UI.Server.getCurrency(player, "coins") or 0) >= 500
endUI.Server.grant(player, id, amount) adds (or, with a negative amount, takes) and is saved. While a player's data is still loading, grants wait and are added once it's in. UI.Server.getCurrency and UI.Server.setCurrency read and set the number directly.
On the client#
-- In <Name>_UIHooks (client)
local coins = UI.peek(ui.currencies.coins) -- the number the counter shows right now
-- Run code whenever the counter changes (ui.currencies.<id> is a Fusion value)
local scope = UI.scope()
scope:Observer(ui.currencies.coins):onChange(function()
print("Coins is now", UI.peek(ui.currencies.coins))
end)
-- The "+100" fly-in. For a leaderstat / attribute currency this only plays the effect:
-- the server changes the real balance and the counter follows it.
ui:give("coins", 100) -- from the middle of the screen
ui:give("coins", 100, workspace.CoinChest) -- from a part in the worldTo play the fly-in when the server pays (a chest, a kill reward), let the server tell that player's screen:
-- Server (ServerHooks): pay, then tell that player's screen to play the fly-in.
-- Make a RemoteEvent named "CoinsEarned" in ReplicatedStorage first.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local CoinsEarned = ReplicatedStorage:WaitForChild("CoinsEarned")
local function reward(player, amount, fromPart)
if UI.Server.grant(player, "coins", amount) then
CoinsEarned:FireClient(player, amount, fromPart)
end
end-- Client (UIHooks): only the effect. The counter already follows the leaderstat.
local CoinsEarned = game:GetService("ReplicatedStorage"):WaitForChild("CoinsEarned")
CoinsEarned.OnClientEvent:Connect(function(amount, fromPart)
ui:give("coins", amount, fromPart)
end)Opening and closing screens#
Screen ids are listed in the Examples table of <Name>_UIHooks (for example shop, inventory, settings). HUD buttons already open their screens, and a screen with an openKey toggles on that key.
-- In <Name>_UIHooks (client). Screen ids are in the Examples table at the bottom of the script.
ui:open("shop")
ui:close("shop")
ui:toggle("inventory")
if ui:isOpen("settings") then
ui:close("settings")
end
ui:closeAll()
-- Open the shop when the player uses a ProximityPrompt on a stand in the world
local prompt = workspace:WaitForChild("ShopStand"):WaitForChild("ProximityPrompt")
prompt.Triggered:Connect(function()
ui:open("shop")
end)
-- Open a screen when the server says so (a RemoteEvent "OpenScreen" in ReplicatedStorage)
local OpenScreen = game:GetService("ReplicatedStorage"):WaitForChild("OpenScreen")
OpenScreen.OnClientEvent:Connect(function(screenId)
if typeof(screenId) == "string" then
ui:open(screenId)
end
end)Opening a window closes the other open windows. The start screen (a main menu or loading screen) opens by itself when the UI is built.
Buttons and actions#
Every button in the design has an action. You can read them in the blueprint JSON (ReplicatedStorage.BloxUI_Blueprints.<Name>):
| Action | What happens | Your code |
|---|---|---|
{"kind": "Open", "target": "shop"}Close, Toggle | opens, closes or toggles a screen (Close without a target closes its own screen) | none |
{"kind": "Purchase", "productId": 123} | Roblox's developer product prompt | see products |
{"kind": "GamePass", "gamePassId": 123} | Roblox's game pass prompt | see game passes |
{"kind": "Remote", "name": "Rebirth"} | asks the server: UI.Server.on("Rebirth", fn) runs there | a server handler (ServerHooks stubs it) |
{"kind": "Event", "name": "Rebirth"} | fires ui:on("Rebirth") on the client; an optional payload (up to 64 characters) arrives as context.payload | a client handler (UIHooks stubs it) |
{"kind": "Notify", "text": "Coming soon!"} | a small message on screen | none |
{"kind": "None"} | only fires the button's own id | ui:on("<button id>") |
Every click also fires the button's id, whatever its action, so ui:on("playButton", fn) always works.
Remote actions and server checks#
A Remote action sends (elementId, payload) to the server through the BloxUI_Action RemoteEvent that UI.Server creates. It is rate-limited (20 per second per player) and shape-checked (a string id of up to 100 characters, plain values, tables at most 2 levels deep). Deciding whether the player may do it is your job:
-- The button's action in the design: { "kind": "Remote", "name": "Rebirth" }
-- In <Name>_ServerHooks, above UI.Server.start. UI.Server already rate-limits the remote and checks
-- the shapes (elementId: a short string; payload: plain values). Everything else is yours to check.
local REBIRTH_COST = 10000
UI.Server.on("Rebirth", function(player, elementId, payload)
-- a button sends no payload: ignore anything else a client made up
if payload ~= nil then
return
end
local coins = UI.Server.getCurrency(player, "coins") or 0
if coins < REBIRTH_COST then
return -- the server decides, never the client
end
UI.Server.setCurrency(player, "coins", 0)
player:SetAttribute("Rebirths", (player:GetAttribute("Rebirths") or 0) + 1)
-- TODO: save Rebirths with your own player data (UI.Server only saves the UI's currencies)
end)Prefer your own RemoteEvent? Name it exactly like the action and put it in ReplicatedStorage. BloxUI fires it directly (you then lose UI.Server's rate limit and shape checks, so check everything yourself):
-- Your own RemoteEvent works too: name it exactly like the action ("Rebirth") and put it in
-- ReplicatedStorage. BloxUI then fires it directly with (elementId, payload) instead of BloxUI_Action.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Rebirth = Instance.new("RemoteEvent")
Rebirth.Name = "Rebirth"
Rebirth.Parent = ReplicatedStorage
Rebirth.OnServerEvent:Connect(function(player, elementId, payload)
if typeof(elementId) ~= "string" or payload ~= nil then
return
end
-- check and do the rebirth here, exactly like the UI.Server.on version
end)Developer products and game passes#
1. Create the real ids#
A design comes with Robux prices but no ids. Until an item has one, pressing it says Not for sale yet! Publish your game, then open create.roblox.com/dashboard/creations > your experience > Monetization:
- Developer Products for things bought again and again (currency packs, revives, spins).
- Passes for things bought once (VIP, x2 coins, extra slots).
2. Put the ids in the design#
The ids belong in the blueprint. Each Robux item takes a productId or a gamePassId, and a currency pack also takes a grant so UI.Server can pay it by itself:
{ "id": "gems_small", "title": "Starter Gems", "icon": "gem", "price": 49, "currency": "robux",
"productId": 3312847105, "grant": { "currency": "gems", "amount": 100 } }
{ "id": "vip", "title": "VIP", "icon": "crown", "price": 699, "currency": "robux",
"gamePassId": 1203948871 }The website has no id editor for the shop's buttons yet, so set them in the place with this Command Bar block. Items are matched by the title the shop shows. Run it again after you re-install the UI, because an install replaces the blueprint.
-- Changes a UI's design (its blueprint) inside the place. Paste it into the Command Bar, change the
-- CHANGES part, press Enter, then press Play. Send to Studio / a new export replaces these edits.
local NAME = "NeonStrike" -- your UI's name: the StringValue in ReplicatedStorage > BloxUI_Blueprints
local HttpService = game:GetService("HttpService")
local folder = game:GetService("ReplicatedStorage"):FindFirstChild("BloxUI_Blueprints")
local value = folder and folder:FindFirstChild(NAME)
assert(value, "No UI named " .. NAME .. " in ReplicatedStorage.BloxUI_Blueprints")
local bp = HttpService:JSONDecode(value.Value)
local function each(t, fn) -- calls fn on every table inside the blueprint
fn(t)
for _, v in t do
if type(v) == "table" then
each(v, fn)
end
end
end
-- CHANGES -----------------------------------------------------------------------------------------
-- Robux items by the title the shop shows. Developer products: bought again and again.
local PRODUCTS = {
["Starter Gems"] = 3312847105,
["Small Gem Pack"] = 3312847188,
}
-- Game passes: bought once.
local PASSES = {
["VIP"] = 1203948871,
}
each(bp, function(t)
if t.price ~= nil and PRODUCTS[t.title] then
t.productId, t.gamePassId = PRODUCTS[t.title], nil
elseif t.price ~= nil and PASSES[t.title] then
t.gamePassId, t.productId = PASSES[t.title], nil
end
end)
bp.logo.text = "NEON STRIKE" -- the logo on the menu and loading screens
bp.style.palette.primary = "#FF4F7B" -- the main colour
-- -------------------------------------------------------------------------------------------------
value.Value = HttpService:JSONEncode(bp)
print("Saved " .. NAME .. ": press Play to see it")Other ids live on their sections: premiumGamePassId or premiumProductId on a Pass (battle pass), luckGamePassId on a Spin, productId or gamePassId on an Offer screen, doubleProductId on offline earnings, groupId on Social rewards.
Using the Robux Products or Game Passes Extras? Their forms on the website take the ids and what each one gives, and the Extras deliver those purchases. The buttons still prompt the ids on their items in the blueprint, so use the same ids in both places (Robux Products warns in Output when they differ).
3. Delivering what was bought#
- Items with a
grant(currency packs, starter packs, premium passes): UI.Server answers Roblox'sProcessReceipt, pays the grant and saves it together with the purchase id before it tells Roblox the purchase is done. Nothing to write. - Products without a grant (a revive, a pet): ServerHooks has a
UI.Server.onReceipt(productId, fn)stub for each. You give the thing. - Game passes: ServerHooks checks ownership with Roblox on join and after a purchase, then sets the player attribute
GamePass_<item id>. Your code reads that attribute.
-- "Revive" (25 Robux) in Shop > Extras: a developer product without a grant.
-- This is the stub Export as code writes; you fill in the TODO.
UI.Server.onReceipt(3312847230, function(player: Player, receiptInfo: any): boolean
local claimId = "product:" .. tostring(receiptInfo.PurchaseId)
if claimId and alreadyGiven(player, claimId) then
return true -- given before (a retry): never give twice
end
local given = false
-- TODO: give what it contains, then set given = true
-- e.g. given = revivePlayer(player) (your function, true when it worked)
if given and claimId then
remember(player, claimId) -- keep it with what you gave
end
return given -- false: not granted yet, Roblox tries again later
end)-- ServerHooks (written by Export as code): owners get the attribute GamePass_<item id>
local GAME_PASSES: { [number]: string } = {
[1203948871] = "vip", -- "VIP" (299 Robux) in Shop > Passes
}
local function givePass(player: Player, gamePassId: number)
player:SetAttribute("GamePass_" .. GAME_PASSES[gamePassId], true)
-- TODO: turn the perk on here (or read the GamePass_<id> attribute where it applies)
end
-- Anywhere on the server: is this player VIP?
local function isVip(player: Player): boolean
return player:GetAttribute("GamePass_vip") == true
end
-- A VIP-only door: the server lets owners through
workspace:WaitForChild("VipDoor").Touched:Connect(function(hit)
local player = game:GetService("Players"):GetPlayerFromCharacter(hit.Parent)
if player and isVip(player) and player.Character then
player.Character:PivotTo(workspace.VipRoom.Entrance.CFrame + Vector3.new(0, 3, 0))
end
end)Only one script in a game may set MarketplaceService.ProcessReceipt. If your game already has one, start with ProcessReceipt = false and call UI.Server.handleReceipt(receiptInfo) from yours (see Saving data). BloxUI never trusts the purchase prompt's "purchased" flag for game passes: it asks Roblox (UserOwnsGamePassAsync). If a test purchase doesn't turn a pass on in Studio, check it in the live game.
Saving data#
With SaveData = true (what ServerHooks picks when the UI keeps anything per player), UI.Server saves the UI's currencies, upgrade levels, premium passes, reward claims and cooldowns, and the last 100 purchase ids, in the DataStore BloxUI_Data (key u_<userId>). It loads on join, saves on change (at most once a minute), when the player leaves and when the server shuts down, and locks each player's data to one server at a time so teleports and rejoins never lose coins. Settings are saved separately in BloxUI_Settings.
-- The last lines of <Name>_ServerHooks. Pick one.
-- 1. BloxUI saves the UI's data (the usual choice): currencies, upgrade levels, premium passes,
-- reward claims and purchase receipts, in the DataStore "BloxUI_Data", one server at a time.
UI.Server.start({ SaveData = true })
-- 2. Your own data code saves the currencies. Purchases then wait until your save worked.
UI.Server.start({
SaveData = false,
saveHook = function(player)
return MyData.save(player) -- your function: true once the player's data is saved
end,
})
-- 3. Your game already sets MarketplaceService.ProcessReceipt: keep yours, and call BloxUI from it.
UI.Server.start({ SaveData = true, ProcessReceipt = false })
-- ...then, inside your own ProcessReceipt:
-- local decision = UI.Server.handleReceipt(receiptInfo)
-- Fractions or very big numbers in a leaderstat currency
UI.Server.start({ SaveData = true, NumberValues = true })- Studio: DataStores need a published game and Game Settings > Security > Enable Studio Access to API Services. Without it, data lives in memory for the session only.
- Your own data code: a leaderstat your scripts create before the player joins is left alone, unless you start with
SaveData = true. WithSaveData = false, nothing is saved by BloxUI and Robux purchases are only granted once yoursaveHooksays your save worked. - UI.Server saves only what the UI owns. Your own progress (levels, inventory, quest progress) still needs your own DataStore code, or the Extras: their data travels in the same save as the UI's, one server at a time.
Settings#
Settings screens are real: each change applies at once, fires ui:on(id), and is saved per player (sent to the server 2 seconds after the last change). There are two kinds:
| Built-in (BloxUI applies them) | What they change |
|---|---|
sfx, music | UI sound and music volume or on / off |
fov, sensitivity, shadows | camera field of view, mouse sensitivity, Lighting.GlobalShadows (this player only) |
lowDetail, reducedMotion, colorblind, uiScale | lite mode, calmer effects, colour-blind palettes, UI size |
damageNumbers, hitMarkers, killFeed, notifications, skipHatch, showHud | the matching HUD and effect switches (F1 always brings a hidden HUD back) |
Event-only settings are anything else (showOtherPets, aimAssist, speedUnits...). BloxUI shows and saves them; only your code can apply them, in their ui:on handler:
-- UIHooks (client). An event-only setting: BloxUI shows and saves it, your code applies it.
ui:on("showOtherPets", function(context)
local show = context.payload -- true / false
local me = game:GetService("Players").LocalPlayer.UserId
for _, pet in workspace:WaitForChild("Pets"):GetChildren() do
if pet:GetAttribute("OwnerId") ~= me then -- your game's own way to tell whose pet it is
for _, part in pet:GetDescendants() do
if part:IsA("BasePart") then
part.LocalTransparencyModifier = if show then 0 else 1 -- only on this screen
end
end
end
end
end)
-- Saved values load before your script runs: Export as code adds this so every handler starts right
for _, id in { "showOtherPets" } do
ui:emit(id, { id = id, payload = ui:getSetting(id), source = "start" })
end
-- Read or change any setting yourself (built-in ones apply at once and are saved too)
print(ui:getSetting("fov")) --> 70
ui:setSetting("music", 30) -- false when the value isn't allowedOn the server, UI.Server.getSettings(player) returns every setting with the defaults filled in.
Game systems#
Some sections run entirely on the server; others only show what your game tells them. The hook scripts' comments say which is which.
| System | Who runs it | What you add |
|---|---|---|
| Shop items priced in coins or gems, upgrades, cases / eggs | UI.Server: checks the price on the server, takes it, pays the grant, rolls cases | Bought:<id> handlers for items without a grant, onUpgrade, onCaseDrop |
| Spin wheel, playtime gifts, Index milestones, achievements, social rewards | UI.Server: the server's clock, dice and checks | onSpinPrize / onReward for prizes without a grant; progress for achievements and the Index |
| Limited-time offers, offline earnings | UI.Server (shown after joining, bought through the usual prompts) | ids; onReward for offer items without a grant |
| Daily rewards, quests, battle pass tiers | Display only | your server keeps the progress and gives the reward |
| Codes, teleports, trading, rebirth | Your server | a Remote or Event handler |
Daily rewards#
The Daily window has no clock: it lets the player press the next day and fires DailyClaim. Your server decides, and the window shows the count your server sends back.
-- ServerHooks, above UI.Server.start. The server keeps the streak; the window only shows it.
-- Make a RemoteEvent named "ClaimDaily" in ReplicatedStorage first.
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local store = DataStoreService:GetDataStore("DailyRewards")
local ClaimDaily = ReplicatedStorage:WaitForChild("ClaimDaily")
local REWARDS = { 50, 75, 100, 150, 200, 300, 500 } -- coins for days 1-7: match your Daily section
local WAIT = 20 * 3600 -- the next day opens 20 hours after the last claim
local RESET = 48 * 3600 -- 48 hours without a claim starts the streak again
local data = {} -- [player] = { streak, last }
local function status(d)
local since = os.time() - d.last
local streak = if since >= RESET then 0 else d.streak
return streak % #REWARDS, since >= WAIT -- days claimed this week, is the next one ready
end
local function onJoin(player: Player)
local ok, saved = pcall(function()
return store:GetAsync("u_" .. player.UserId)
end)
if not ok or player.Parent == nil then
return -- DataStore trouble: no claims this visit, and nothing good is overwritten
end
data[player] = if type(saved) == "table" then saved else { streak = 0, last = 0 }
ClaimDaily:FireClient(player, (status(data[player])))
end
Players.PlayerAdded:Connect(onJoin)
for _, player in Players:GetPlayers() do
task.spawn(onJoin, player)
end
Players.PlayerRemoving:Connect(function(player)
data[player] = nil
end)
ClaimDaily.OnServerEvent:Connect(function(player)
local d = data[player]
if d == nil or d.busy then
return
end
local claimed, ready = status(d)
if not ready then
ClaimDaily:FireClient(player, claimed, "Come back tomorrow for the next reward!")
return
end
d.busy = true
local streak = (if os.time() - d.last >= RESET then 0 else d.streak) + 1
local new = { streak = streak, last = os.time() }
local saved = pcall(function()
store:SetAsync("u_" .. player.UserId, new)
end)
d.busy = nil
if not saved then
ClaimDaily:FireClient(player, claimed, "Your reward couldn't be saved. Try again in a minute.")
return
end
data[player] = new
local day = (streak - 1) % #REWARDS + 1
UI.Server.grant(player, "coins", REWARDS[day])
ClaimDaily:FireClient(player, day)
end)-- UIHooks (client). The Daily window lets the player press the next day at any time: the server
-- answers with the real count, and the window follows it.
local ClaimDaily = game:GetService("ReplicatedStorage"):WaitForChild("ClaimDaily")
local DAILY = "daily" -- the Daily section's id (see the Examples table at the bottom of UIHooks)
ui:on("DailyClaim", function(context)
ClaimDaily:FireServer() -- context.payload is the day pressed; the server decides anyway
end)
ClaimDaily.OnClientEvent:Connect(function(claimed, message)
ui:state(DAILY, "claimed", 0):set(claimed)
if message then
ui:notify(message, "info")
end
end)Quests#
A quest shows the progress in ui:state(<section id>, "<quest id>.progress") and its Claim button lights up at the goal. Keep the progress on the server, mirror it with attributes, and check claims there:
-- ServerHooks, above UI.Server.start. Make a RemoteEvent named "ClaimQuest" in ReplicatedStorage.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ClaimQuest = ReplicatedStorage:WaitForChild("ClaimQuest")
-- match the ids, goals and rewards of your Quests section
local QUESTS = {
q1 = { goal = 10, coins = 100 }, -- Hatch 10 eggs
q2 = { goal = 50, coins = 250 }, -- Collect 50 gems
}
-- call this from your game code, e.g. addQuestProgress(player, "q1", 1) when an egg hatches
local function addQuestProgress(player: Player, questId: string, amount: number)
local q = QUESTS[questId]
if q then
local now = player:GetAttribute("Quest_" .. questId) or 0
player:SetAttribute("Quest_" .. questId, math.min(q.goal, now + amount))
end
end
ClaimQuest.OnServerEvent:Connect(function(player, questId)
local q = typeof(questId) == "string" and QUESTS[questId]
if not q or player:GetAttribute("QuestDone_" .. questId) then
return
end
if (player:GetAttribute("Quest_" .. questId) or 0) < q.goal then
return -- not finished: the client can't claim early
end
player:SetAttribute("QuestDone_" .. questId, true)
UI.Server.grant(player, "coins", q.coins)
-- TODO: save quest progress and QuestDone_ with your player data if quests should last past a rejoin
end)-- UIHooks (client): the Quests window follows the attributes the server sets
local Players = game:GetService("Players")
local ClaimQuest = game:GetService("ReplicatedStorage"):WaitForChild("ClaimQuest")
local QUESTS = "quests" -- the Quests section's id
local player = Players.LocalPlayer
for _, questId in { "q1", "q2" } do
local function sync()
ui:state(QUESTS, questId .. ".progress", 0):set(player:GetAttribute("Quest_" .. questId) or 0)
ui:state(QUESTS, questId .. ".claimed", false):set(player:GetAttribute("QuestDone_" .. questId) == true)
end
player:GetAttributeChangedSignal("Quest_" .. questId):Connect(sync)
player:GetAttributeChangedSignal("QuestDone_" .. questId):Connect(sync)
sync()
end
ui:on("QuestClaim", function(context)
ClaimQuest:FireServer(context.payload) -- the quest id
end)Battle pass#
The tier reached is ui:state(<section id>, "progress"). Premium is automatic: UI.Server sets BloxUI_Premium_<section id> for owners of the Pass's premiumGamePassId or buyers of its premiumProductId, and the window follows it. Tier rewards are yours to give:
-- Battle pass. Server (ServerHooks): your game raises the tier; claims are checked here.
-- Make a RemoteEvent named "ClaimPassTier" in ReplicatedStorage.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ClaimPassTier = ReplicatedStorage:WaitForChild("ClaimPassTier")
local PASS = "pass" -- the Pass section's id
local FREE = { [1] = 50, [2] = 100, [3] = 150 } -- coins per tier: match your Pass section
local PREMIUM = { [1] = 20, [2] = 40, [3] = 80 } -- gems per tier
ClaimPassTier.OnServerEvent:Connect(function(player, tier, track)
if typeof(tier) ~= "number" or (track ~= "free" and track ~= "premium") then
return
end
if tier > (player:GetAttribute("PassTier") or 0) then
return -- not reached yet
end
-- UI.Server sets BloxUI_Premium_<section id> for owners of the premium product or game pass
if track == "premium" and player:GetAttribute("BloxUI_Premium_" .. PASS) ~= true then
return
end
local key = "PassClaimed_" .. track .. "_" .. tier
if player:GetAttribute(key) then
return
end
player:SetAttribute(key, true) -- TODO: save claims and PassTier with your player data
if track == "free" then
UI.Server.grant(player, "coins", FREE[tier] or 0)
else
UI.Server.grant(player, "gems", PREMIUM[tier] or 0)
end
end)-- Battle pass. Client (UIHooks): show the tier reached, send claims to the server
local ClaimPassTier = game:GetService("ReplicatedStorage"):WaitForChild("ClaimPassTier")
local PASS = "pass" -- the Pass section's id
local player = game:GetService("Players").LocalPlayer
local function syncTier()
ui:state(PASS, "progress", 0):set(player:GetAttribute("PassTier") or 0)
end
player:GetAttributeChangedSignal("PassTier"):Connect(syncTier)
syncTier()
ui:on("PassClaim", function(context)
ClaimPassTier:FireServer(context.payload.tier, context.payload.track)
end)Codes#
The Codes box's action is a Remote (usually RedeemCode) and the typed code is the payload. ServerHooks writes the handler with a code table that only the server sees:
-- The Codes box's action: { "kind": "Remote", "name": "RedeemCode" }. Export as code writes this.
do
-- The codes and what each one gives, here on the server only (a client never sees them).
local CODES: { [string]: number } = {
RELEASE = 50,
THANKS100K = 250,
}
-- Codes each player used. TODO: save this with the player's data, or a code works
-- once per server instead of once per player.
local used: { [number]: { [string]: boolean } } = {}
UI.Server.on("RedeemCode", function(player: Player, elementId: any, payload: any)
-- Accept a short string only, then look it up in CODES.
if typeof(payload) ~= "string" or #payload > 40 then
return
end
local code = string.upper((string.gsub(payload, "%s+", "")))
local amount = CODES[code]
local mine = used[player.UserId] or {}
if amount == nil or mine[code] then
return -- unknown, or used already
end
mine[code] = true
used[player.UserId] = mine
UI.Server.grant(player, "gems", amount) -- Gems (saved)
end)
endSpin wheel, gifts, Index, achievements and offers#
UI.Server decides every one of these: the wheel is rolled with the server's random numbers, free spins and gifts use the server's clock, group rewards ask Roblox, invites are checked. Prizes with a grant are paid automatically. The rest go to these hooks, idempotent by claim id like onReceipt:
-- ServerHooks. Prizes WITH a grant ({ currency, amount }) are paid by UI.Server itself.
-- Prizes without one (a pet, a boost...) come to these hooks. Return true only once it's given.
-- Spin wheel wedges: wedge = { id, label, icon, amount, rarity }; info = { section, index, spinId, mode, purchaseId? }
UI.Server.onSpinPrize(function(player: Player, wedge: any, info: any): boolean
local claimId = "spin:" .. tostring(info.spinId)
if claimId and alreadyGiven(player, claimId) then
return true -- given before (a retry): never give twice
end
local given = false
-- TODO: give the wedge's prize, then set given = true
if given and claimId then
remember(player, claimId)
end
return given -- false: the spin is undone (cooldown or price given back)
end)
-- Gifts, Index milestones, achievements, social rewards and offer items without a grant
-- info = { kind = "Gift" | "Index" | "Achievement" | "Social" | "Offer", section, id, claimId, purchaseId? }
UI.Server.onReward(function(player: Player, reward: any, info: any): boolean
local claimId = info.claimId
if claimId and alreadyGiven(player, claimId) then
return true
end
local given = false
-- TODO: give the reward (reward.label, reward.icon, reward.amount), then set given = true
if given and claimId then
remember(player, claimId)
end
return given -- false: the claim fails and can be tried again (an offer purchase waits)
end)
-- Case / egg drops: drop = { name, icon, rarity, chance }; info = { case, section, purchaseId? }
UI.Server.onCaseDrop(function(player: Player, drop: any, info: any): boolean
local claimId = if info.purchaseId then "case:" .. tostring(info.purchaseId) else nil
if claimId and alreadyGiven(player, claimId) then
return true
end
local given = false
-- TODO: give drop.name to the player (your inventory), then set given = true
if given and claimId then
remember(player, claimId)
end
return given -- false: an in-game buy is refunded, a Robux one retried later
end)Achievements, the Index and upgrades read what your game sets on the server:
-- ServerHooks. Achievements, the Index and upgrades read what your game code sets on the server.
-- Achievements: progress is the player attribute BloxUI_Ach_<achievement id>.
-- The player can claim the reward once it reaches the goal (UI.Server checks it).
local function addAchievementProgress(player: Player, achievementId: string, amount: number)
local attribute = "BloxUI_Ach_" .. achievementId
local now = player:GetAttribute(attribute)
player:SetAttribute(attribute, (if typeof(now) == "number" then now else 0) + amount)
end
-- e.g. addAchievementProgress(player, "ach_first_hatch", 1) when a player hatches an egg
-- Index (collection book): tell UI.Server what the player has found, on join and whenever
-- they find something new. Milestones pay from it.
local function updateIndex(player: Player, foundIds: { string })
UI.Server.setIndexOwned(player, foundIds) -- e.g. { "dex_goldfish", "dex_parrot" }
end
-- Upgrades: BloxUI sells the levels (player attribute BloxUI_Upgrade_<id>, saved). The generated
-- onUpgrade runs on join and after every level bought: apply the level there.
local function onUpgrade(player: Player, id: string, level: number)
if id == "walk" then -- "Walk Speed: +10% speed"
local humanoid = player.Character and player.Character:FindFirstChildOfClass("Humanoid")
if humanoid then
humanoid.WalkSpeed = 16 * (1 + 0.1 * level)
end
end
endTeleports#
Teleport rows and Worlds cards fire the Event Teleport with the place in context.payload. The game moves the player, on the server, after checking:
-- UIHooks (client). Teleport rows and Worlds cards fire the Event "Teleport" with the place in context.payload.
local TeleportTo = game:GetService("ReplicatedStorage"):WaitForChild("TeleportTo") -- your RemoteEvent
ui:on("Teleport", function(context)
-- context.payload = "spawn" | "shop" | "eggs" | "vip" (the button id is context.id, e.g. "tp_spawn")
local place = context.payload or string.match(context.id or "", "^tp_(.+)$")
TeleportTo:FireServer(place)
end)-- ServerHooks (server). A client can ask for any place, so the server checks before it moves anyone.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TeleportTo = Instance.new("RemoteEvent")
TeleportTo.Name = "TeleportTo"
TeleportTo.Parent = ReplicatedStorage
local PLACES = {
spawn = workspace.Spawns.Main,
shop = workspace.Zones.Shop,
eggs = workspace.Zones.Eggs,
vip = workspace.Zones.VIP,
}
TeleportTo.OnServerEvent:Connect(function(player, place)
local target = typeof(place) == "string" and PLACES[place]
if target and player.Character and (place ~= "vip" or player:GetAttribute("GamePass_vip")) then
player.Character:PivotTo(target.CFrame + Vector3.new(0, 3, 0))
end
end)Trading#
The Trade window is only the screen. Offers, accepting and swapping items are your game's code, checked on the server:
-- UIHooks (client). The Trade window is the screen only: offers, accepting and swapping are yours.
local Trade = game:GetService("ReplicatedStorage"):WaitForChild("Trade") -- your RemoteEvent
ui:on("TradeAccept", function(context)
Trade:FireServer("accept") -- the server checks BOTH offers again, then swaps the items itself
end)
ui:on("TradeDecline", function(context)
Trade:FireServer("decline")
end)The checklist#
The top of both hook scripts lists what's left to do, in groups. Red groups break something until fixed; the rest are advice.
| Group | Means | Fix |
|---|---|---|
| Product and game-pass ids | Robux items, cases, spins, premium passes, x2 Luck, offers and x2 offline earnings without a real id, or with an id that looks made up (under 1000, repeated digits, a counting sequence, a round number, or within 9 of another id) | create the ids and add them, then make the hook scripts again |
| Group id for Social rewards | a "Join the Group" reward without your group's id: nobody can claim it | set groupId (the number in your group's address) |
| Items without a grant | things BloxUI can't give by itself, each with the handler that must: onReceipt, givePass, Bought:<id>, onCaseDrop, onSpinPrize, onReward, onUpgrade, display-only Daily / Quests / Pass rewards | fill in those handlers |
| Event-only settings | settings BloxUI saves but can't apply | fill in their ui:on handlers |
| Empty sound slots | sounds that use the theme's defaults (a note, not a problem) | optional: your own Creator Store audio ids in style.sounds and music |
| Validator warnings | what BloxUI repaired when it read the design, and Remote actions named like server events | fix the design so it is exactly what you meant |
Quick reference#
| Client (UIHooks) | |
|---|---|
UI.Blueprint.waitFor(name, seconds) | the built UI, or nil after the timeout |
ui:on(name, fn) / ui:emit(name, context) | listen to / fire an event; ui:on returns a function that disconnects |
ui:open(id) ui:close(id) ui:toggle(id) ui:isOpen(id) ui:closeAll() | screens |
ui.currencies[id], UI.peek(value) | a currency's Fusion value, and its number |
ui:give(id, amount, from?) | the fly-in (only the effect for server-owned currencies) |
ui:notify(text, kind?, essential?) | a toast; essential shows even with notifications off |
ui:getSetting(id) / ui:setSetting(id, value) | settings |
ui:state(sectionId, key, default) | a section's shown state (daily claimed, quest <id>.progress, pass progress) |
ui.hud[elementId] | HUD element handles (see the Examples table) |
ui:setOwned(ids) | what an Index shows as found, display only (with UI.Server use setIndexOwned) |
| Server (ServerHooks) | |
UI.Server.start(options) | last line; SaveData, saveHook, ProcessReceipt, NumberValues, SaveSettings |
UI.Server.on(name, fn(player, elementId, payload)) | Remote actions and server events |
UI.Server.grant(player, id, amount), getCurrency, setCurrency | currencies |
UI.Server.onReceipt(productId, fn) | products without a grant: return true once given |
UI.Server.onCaseDrop, onSpinPrize, onReward | prizes without a grant: return true once given |
UI.Server.canBuy = fn(player, sectionId, itemId) | veto an in-game-currency buy (return false) |
UI.Server.setIndexOwned(player, ids) | what the player has found (Index) |
UI.Server.getSettings(player), UI.Server.handleReceipt(info), UI.Server.flush() | settings, your own ProcessReceipt, save now |
Next: let an AI write the handlers for you in Vibe-code it with Claude or ChatGPT.