Roblox Luau Scripting skill for Codex
Exploit-proof remotes and saves that never vanish. Codex splits Roblox code into typed ModuleScripts on the correct side of the client-server boundary, treats every RemoteEvent argument as hostile and validates type, ownership, distance and rate, and saves player data with UpdateAsync, retries, BindToClose and session locking. It uses the task library instead of deprecated globals and works through Rojo files so the project lives in version control.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- roblox-luau Installs to ~/.agents/skills/roblox-luau/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install roblox-luau The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project roblox-luau Installs to .agents/skills/roblox-luau/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/roblox-luau.zipunzip -o roblox-luau.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) roblox-luau PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $roblox-luau, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes Roblox experiences in Luau - server and client split across ServerScriptService, ReplicatedStorage and StarterPlayerScripts, ModuleScripts, strict type checking, RemoteEvents and RemoteFunctions with server-side validation and rate limits, DataStoreService with UpdateAsync, retries and session locking, the task library, CollectionService tags, attributes, and a Rojo, Wally, Selene and StyLua toolchain. Use when scripting a Roblox game, securing remotes against exploiters, saving player data without loss, moving a place to Rojo files, or fixing yields, memory leaks and race conditions in Luau.
Use it when
- Scripting gameplay for a Roblox experience in Luau
- Securing RemoteEvents and RemoteFunctions against exploiters
- Players lose data or get rolled back between servers
- Setting up Rojo, Wally and linting for a Roblox codebase
Use something else when
- You are writing plain Lua for another engine or tool: use lua-scripting
- You need general netcode theory beyond Roblox replication: use multiplayer-netcode
How to check the result
Selene, StyLua and luau-lsp report no issues, exploit-style remote calls from the command bar are rejected in a multi-client Studio test, and data survives rejoin and server shutdown.
The mistake it prevents
Letting a player keep playing with default data after a failed DataStore load, so the next save overwrites their real progress.
Done well means: the server owns every outcome and validates every remote, player data saves
reliably with retries and session locking, code lives in typed ModuleScripts under version
control, connections are cleaned up when objects go away, and nothing uses deprecated globals
(wait, spawn, delay).
Before writing code
- Read
AGENTS.md. Check fordefault.project.json(Rojo),wally.toml,selene.toml,stylua.toml,aftman.toml/rokit.toml/foreman.toml. If Rojo is used, edit files on disk; the user syncs them into Studio. - Without Rojo, Codex cannot edit a
.rbxlplace directly; write scripts as files and tell the user where each one goes in the Explorer. - Install tools (Rojo, Wally, Selene, luau-lsp) only with approval; they need network.
Where code goes
| Container | Runs on | Put here |
|---|---|---|
ServerScriptService | Server | Game rules, data saving, remote handlers |
ServerStorage | Server only (not replicated) | Server modules, secret config, maps not yet loaded |
ReplicatedStorage | Both | Shared modules, remotes, shared types and constants |
StarterPlayerScripts | Client | Input, camera, UI controllers |
StarterCharacterScripts | Client, per character | Character-specific client effects |
Anything in ReplicatedStorage is readable by exploiters. Never put secrets, admin lists or
damage formulas the client should not know there unless the client needs them.
Strict, modular Luau
--!strict
-- ReplicatedStorage/Shared/Types.luau
export type PlayerData = {
coins: number,
inventory: { [string]: number },
version: number,
}
return {} -- a ModuleScript must return exactly one value
--!strict
-- ServerScriptService/Services/CoinService.luau
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local DataService = require(script.Parent.DataService)
local Remotes = ReplicatedStorage:WaitForChild("Remotes")
local CollectCoin = Remotes:WaitForChild("CollectCoin") :: RemoteEvent
local MAX_PICKUP_DISTANCE = 12
local lastCollect: { [Player]: number } = {}
local CoinService = {}
function CoinService.init()
CollectCoin.OnServerEvent:Connect(function(player: Player, coin: unknown)
-- validate type, existence, ownership, distance and rate before acting
if typeof(coin) ~= "Instance" or not (coin :: Instance):IsA("BasePart") then return end
local part = coin :: BasePart
if not part:IsDescendantOf(workspace.Coins) or part:GetAttribute("Collected") then return end
local character = player.Character
local root = character and character:FindFirstChild("HumanoidRootPart") :: BasePart?
if not root or (root.Position - part.Position).Magnitude > MAX_PICKUP_DISTANCE then return end
local now = os.clock()
if now - (lastCollect[player] or 0) < 0.1 then return end
lastCollect[player] = now
part:SetAttribute("Collected", true)
part:Destroy()
DataService.update(player, function(data)
data.coins += 1
return data
end)
end)
Players.PlayerRemoving:Connect(function(p) lastCollect[p] = nil end)
end
return CoinService
- One entry
Scriptper side requires andinit()s services in a known order; everything else is a ModuleScript. --!stricton new files; fix type errors rather than casting toany.- Use
task.wait,task.spawn,task.defer,task.delay. The oldwait/spawn/delayare deprecated and throttled. - Use
game:GetService("Players"), nevergame.Players, so renamed or missing services fail clearly.
Remotes: rules
| Rule | Why |
|---|---|
| Client sends intent (“I want to buy item X”), server decides price, stock, currency | Exploiters can call any remote with any arguments |
Type-check every argument (typeof, IsA, ranges, string lengths) | Arguments can be any type or nil |
| Check ownership and distance for anything referencing the world | Clients will reference objects across the map |
| Rate-limit per player per remote | Remote spam can crash or lag servers |
Avoid RemoteFunction:InvokeClient | A client can yield it forever or error; server code hangs |
Use UnreliableRemoteEvent for frequent cosmetic updates | Drops are fine; saves bandwidth |
Saving player data
--!strict
local DataStoreService = game:GetService("DataStoreService")
local Types = require(game:GetService("ReplicatedStorage").Shared.Types)
type PlayerData = Types.PlayerData
local store = DataStoreService:GetDataStore("PlayerData_v1")
local function withRetries<T>(fn: () -> T, attempts: number): (boolean, T?)
for i = 1, attempts do
local ok, result = pcall(fn)
if ok then return true, result end
warn(`DataStore attempt {i} failed: {result}`)
task.wait(2 ^ i) -- 2, 4, 8 s backoff
end
return false, nil
end
local function save(userId: number, data: PlayerData): boolean
local ok = withRetries(function()
return store:UpdateAsync(`user_{userId}`, function(old: PlayerData?)
if old and old.version > data.version then
return nil -- stored data is newer; cancel this write
end
return data
end)
end, 4)
return ok
end
UpdateAsyncoverSetAsync: it reads and writes atomically and lets you refuse stale writes.- Load on
PlayerAddedwith retries; if loading fails, kick the player with a message rather than letting them play on default data that will overwrite their real save. - Save on
PlayerRemoving, periodically (every few minutes), and ingame:BindToClose(server shutdown gives about 30 seconds; save all players in parallel withtask.spawn). - Session locking prevents two servers writing the same player (fast server hops). Use a proven module such as ProfileStore rather than hand-rolling it.
- Respect request budgets (
DataStoreService:GetRequestBudgetForRequestType) and the per-key size limit; store IDs and counts, not instances. - Developer products: grant purchases in
MarketplaceService.ProcessReceipt, record the receipt ID in the player’s data, and returnPurchaseGrantedonly after the save succeeds.
Tags, attributes and cleanup
CollectionServicetags ("Coin","KillBrick") plusGetInstanceAddedSignallet one script handle every tagged part, including ones streamed in later.- Attributes (
SetAttribute) for simple replicated state on instances instead of Value objects. - Store
RBXScriptConnections and disconnect them when the owning object is destroyed (a Trove or Maid-style helper). Connections onPlayersorRunServicefrom per-object code leak. - With
Workspace.StreamingEnabled, client code must not assume parts exist; useWaitForChildwith a timeout or tag signals.
Toolchain
rojo serve # live sync to Studio (user connects the Rojo plugin)
rojo build -o game.rbxl # build a place file from the project
wally install # packages into Packages/ (needs network)
selene src # lint
stylua --check src # formatting
luau-lsp analyze --sourcemap=sourcemap.json src # type check; sourcemap from `rojo sourcemap`
Tests: TestEZ or Jest-Lua run inside Studio or with run-in-roblox / Open Cloud Luau execution; use whichever the project already has.
Gotchas
Touchedfires many times per contact and on the client for local parts; debounce per player and decide on the server.- Client-side changes to the world do not replicate (FilteringEnabled); they only affect that client.
- Network ownership: unanchored parts near a player may be simulated by that client. Set
SetNetworkOwner(nil)for physics the server must control. Humanoid.DiedandCharacterAddedfire per character; reconnect per spawn.- Yielding inside
UpdateAsynccallbacks is not allowed; compute everything before calling it.
Verification
selene,stylua --checkandluau-lsp analyzeclean.- Studio test with 2+ players (Test tab, Clients and Servers): remotes rejected when called with wrong types, far-away objects or spam from the command bar.
- Data: join, earn, leave, rejoin on another server; shut down the server mid-session; values persist. Use a separate DataStore name or test place so production data is never touched; publishing to the live place requires the user’s explicit go-ahead.
Hand-off summary
**Structure:** Rojo project; services CoinService, DataService (ProfileStore), ShopService
**Remotes:** CollectCoin, BuyItem; all type-checked, distance-checked, rate-limited
**Data:** UpdateAsync with version guard, BindToClose save, receipt IDs stored for products
**Checks:** selene/stylua/luau-lsp clean; 2-player Studio test incl. exploit calls from command bar
**Not done:** publishing to the live place (needs your confirmation) What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Roblox Luau Scripting"
short_description: "Exploit-proof remotes and saves that never vanish"
default_prompt: "$roblox-luau Exploiters are giving themselves coins in our Roblox game, lock down the remotes" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.