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.

  • Game Development
  • $roblox-luau
  • 204 lines
  • 2 files
  • ~2,250 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- roblox-luau

Installs to ~/.agents/skills/roblox-luau/ for every project on this machine.

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

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.

~/.agents/skills/roblox-luau/SKILL.md

--- name: roblox-luau description: Writes Roblox experiences in Luau - server and client split across ServerScriptService, ReplicatedStorage and StarterPlayerScripts, ModuleScripts,… license: MIT ---

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

  1. Read AGENTS.md. Check for default.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.
  2. Without Rojo, Codex cannot edit a .rbxl place directly; write scripts as files and tell the user where each one goes in the Explorer.
  3. Install tools (Rojo, Wally, Selene, luau-lsp) only with approval; they need network.

Where code goes

ContainerRuns onPut here
ServerScriptServiceServerGame rules, data saving, remote handlers
ServerStorageServer only (not replicated)Server modules, secret config, maps not yet loaded
ReplicatedStorageBothShared modules, remotes, shared types and constants
StarterPlayerScriptsClientInput, camera, UI controllers
StarterCharacterScriptsClient, per characterCharacter-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 Script per side requires and init()s services in a known order; everything else is a ModuleScript.
  • --!strict on new files; fix type errors rather than casting to any.
  • Use task.wait, task.spawn, task.defer, task.delay. The old wait/spawn/delay are deprecated and throttled.
  • Use game:GetService("Players"), never game.Players, so renamed or missing services fail clearly.

Remotes: rules

RuleWhy
Client sends intent (“I want to buy item X”), server decides price, stock, currencyExploiters 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 worldClients will reference objects across the map
Rate-limit per player per remoteRemote spam can crash or lag servers
Avoid RemoteFunction:InvokeClientA client can yield it forever or error; server code hangs
Use UnreliableRemoteEvent for frequent cosmetic updatesDrops 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
  • UpdateAsync over SetAsync: it reads and writes atomically and lets you refuse stale writes.
  • Load on PlayerAdded with 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 in game:BindToClose (server shutdown gives about 30 seconds; save all players in parallel with task.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 return PurchaseGranted only after the save succeeds.

Tags, attributes and cleanup

  • CollectionService tags ("Coin", "KillBrick") plus GetInstanceAddedSignal let 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 on Players or RunService from per-object code leak.
  • With Workspace.StreamingEnabled, client code must not assume parts exist; use WaitForChild with 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

  • Touched fires 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.Died and CharacterAdded fire per character; reconnect per spawn.
  • Yielding inside UpdateAsync callbacks is not allowed; compute everything before calling it.

Verification

  • selene, stylua --check and luau-lsp analyze clean.
  • 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.yamlgenerated from this page

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.

esc