backend/luau-roblox/SKILL.md
Luau and Roblox development patterns for strict typing, Roblox service architecture, client/server security, DataStore persistence, RemoteEvent design, and incremental game systems.
npx skillsauth add kursku/skills luau-robloxInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Applies to any work inside a Roblox game project written in Luau. Covers correct patterns for strict typing, service architecture, security, persistence, and game-loop design.
Every .lua / .luau file must start with:
--!strict
No exceptions. This enables Luau's full type-checker and prevents entire classes of runtime
errors. Never omit it, never suppress it with --!nonstrict.
Always use game:GetService() at the top of the file, before any logic.
Never access services through game.ServiceName (breaks in some load orders).
--!strict
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local DataStoreService = game:GetService("DataStoreService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
Every service / controller / module exports a single table named identically to the file.
Always export an init() function for one-time setup called from the bootstrap script.
--!strict
local MyService = {}
-- Called once by the bootstrap (init.server.luau / init.client.luau)
function MyService.init()
-- setup, bind events, start loops
end
-- Public API
function MyService.doThing(player: Player, value: number): boolean
-- logic
return true
end
return MyService
Rules:
EconomyService, HUDController)awardKnowledge, tryLevelUp)local MAX_ENERGY = 100)local Players = ...)type PlayerData = {...})task.* API — Never Use Deprecated Alternatives| Deprecated | Correct replacement |
|---|---|
| wait(n) | task.wait(n) |
| spawn(fn) | task.spawn(fn) |
| delay(n, fn) | task.delay(n, fn) |
| coroutine.wrap(fn)() | task.spawn(fn) |
wait() is deprecated, inaccurate on frame boundaries, and will be removed.
task.* is the only correct async primitive in modern Luau.
Server tick loop example:
task.spawn(function()
while true do
task.wait(1)
MyService.tick()
end
end)
| Container | Script type | Runs on |
|---|---|---|
| ServerScriptService | Script | Server only |
| StarterPlayerScripts | LocalScript | Client only |
| ReplicatedStorage | ModuleScript | Both (shared) |
The server is always authoritative. The client:
Never expose a RemoteFunction from client to server (blocks the thread and is exploitable).
Use RemoteEvent only. The server fires back data via a separate event if needed.
Roblox exploiters can fire any RemoteEvent with arbitrary arguments at any time.
-- BAD — exploitable
remoteEvent.OnServerEvent:Connect(function(player, upgradeId, cost)
playerData.knowledge -= cost
end)
-- GOOD — server looks up the real cost
remoteEvent.OnServerEvent:Connect(function(player, upgradeId)
local config = UpgradeConfig[upgradeId]
if not config then return end
local data = DataService.getPlayerData(player)
if not data or data.knowledge < config.cost then return end
data.knowledge -= config.cost
-- apply effect
end)
local function onBuyUpgrade(player: Player, upgradeId: unknown)
-- type check
if type(upgradeId) ~= "string" then return end
-- membership check
if not UpgradeConfig[upgradeId] then return end
-- data check
local data = DataService.getPlayerData(player)
if not data then return end
-- economy check
if data.knowledge < UpgradeConfig[upgradeId].cost then return end
-- safe to proceed
end
local lastFire: {[Player]: number} = {}
local DEBOUNCE = 0.5 -- seconds
remoteEvent.OnServerEvent:Connect(function(player: Player, ...)
local now = os.clock()
if lastFire[player] and (now - lastFire[player]) < DEBOUNCE then return end
lastFire[player] = now
-- proceed
end)
-- Clean up on player leave to avoid memory leaks
game:GetService("Players").PlayerRemoving:Connect(function(player)
lastFire[player] = nil
end)
Always wrap DataStore calls in pcall. Always log failures with a consistent prefix.
local function saveData(player: Player, data: PlayerData)
local key = "player_" .. player.UserId
local ok, err = pcall(function()
dataStore:SetAsync(key, data)
end)
if not ok then
warn("[DataService] SetAsync failed for " .. key .. ": " .. tostring(err))
end
end
Never trust that a saved record has all fields (new fields added after initial save, corrupted data, etc.). Always merge onto the default template:
local function mergeWithDefault(saved: {[string]: unknown}, default: PlayerData): PlayerData
local result = table.clone(default)
for key, value in saved do
if result[key] ~= nil and type(value) == type(result[key]) then
result[key] = value
end
end
return result
end
task.spawn loopPlayers.PlayerRemoving — use task.defer to ensure it fires:Players.PlayerRemoving:Connect(function(player)
saveData(player, DataService.getPlayerData(player))
end)
Types.lua)Define all shared data shapes in src/shared/Types.lua. Import them in every file that uses them.
--!strict
export type PlayerData = {
knowledge: number,
energy: number,
diplomas: number,
mathLevel: number,
scienceLevel: number,
historyLevel: number,
peLevel: number,
totalKnowledgeEarned: number,
}
export type UpgradeConfig = {
id: string,
displayName: string,
baseCost: number,
growth: number,
bonusPerLevel: number,
}
Always annotate function parameters and return types explicitly — never rely on inference when the function is part of a public API.
All gameplay numbers live in config files under src/shared/config/. Never hardcode values
in service logic.
-- BAD
local cost = 25 * (1.25 ^ level)
-- GOOD
local cfg = SubjectsConfig[subjectId]
local cost = cfg.baseCost * (cfg.growth ^ level)
Upgrade cost formula: cost = baseCost * (growth ^ level)
Use pcall around any throwable operation. Log with the format [ServiceName] message.
local ok, result = pcall(function()
return dataStore:GetAsync(key)
end)
if not ok then
warn("[DataService] GetAsync failed: " .. tostring(result))
return nil
end
Never write to _G or shared. Pass dependencies as arguments or module-level locals
set during init(). This keeps modules testable and avoids order-of-require issues.
Keep files under 300 lines. If a service grows past that, extract a sub-module.
Example: StudyService grows large → extract StudyCalculator as a pure-function module.
-- In EconomyService.init()
task.spawn(function()
while true do
task.wait(1)
for _, player in Players:GetPlayers() do
EconomyService.tick(player)
end
end
end)
Knowledge per study = BaseStudyGain * knowledgeMultiplier
where knowledgeMultiplier accumulates from Subject levels:
multiplier += mathLevel * MathConfig.bonusPerLevel
diplomas = math.floor(totalKnowledgeEarned / PrestigeConfig.baseCost)
Boss rewards are rare currencies. Deliver via EconomyService.awardCurrency, not by
direct attribute write, so all validation and logging stays in one place.
cost = subject.baseCost * (subject.growth ^ currentLevel)
Computed in UpgradeCalculator.lua — never inline this formula in UI or service code.
All RemoteEvents and RemoteFunctions live in src/shared/Remotes.lua.
Name them PascalCase verb-noun: RequestStudy, BuyUpgrade, RequestPrestige, BossDefeated.
-- Remotes.lua
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Remotes = ReplicatedStorage:WaitForChild("Remotes")
return {
RequestStudy = Remotes:WaitForChild("RequestStudy") :: RemoteEvent,
BuyUpgrade = Remotes:WaitForChild("BuyUpgrade") :: RemoteEvent,
RequestPrestige = Remotes:WaitForChild("RequestPrestige") :: RemoteEvent,
SyncPlayerData = Remotes:WaitForChild("SyncPlayerData") :: RemoteEvent,
}
Strict mode: --!strict (every file, no exceptions)
Async: task.wait / task.spawn / task.delay (never wait/spawn/delay)
Services: game:GetService() (never game.ServiceName)
Module table: PascalCase, one per file
Functions: camelCase
Constants: SCREAMING_SNAKE_CASE
Remotes: PascalCase verb-noun, all in Remotes.lua
Security: validate type + membership + economy on every OnServerEvent
Rate limit: 0.5s debounce per player per remote
DataStore: always pcall, always merge onto default template
Save cadence: 30s auto + PlayerRemoving
File length: max 300 lines
Magic numbers: forbidden — use config files
Upgrade cost formula: baseCost * (growth ^ level)
Prestige formula: floor(totalKnowledgeEarned / baseCost)
Economy tick: 1 second server loop
development
You are an expert error analysis specialist with deep expertise in debugging distributed systems, analyzing production incidents, and implementing comprehensive observability solutions.
development
Search logs and codebases for error patterns, stack traces, and anomalies. Correlates errors across systems and identifies root causes.
development
Use when working with error debugging multi agent review
development
You are an error tracking and observability expert specializing in implementing comprehensive error monitoring solutions. Set up error tracking systems, configure alerts, implement structured loggi...