Luau / Roblox Development Skill
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.
1. Mandatory File Header
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.
2. Service Acquisition
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")
3. Module Pattern
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:
- Module table: PascalCase matching the filename (
EconomyService,HUDController) - Functions: camelCase (
awardKnowledge,tryLevelUp) - Constants: SCREAMING_SNAKE_CASE (
local MAX_ENERGY = 100) - Roblox service refs: PascalCase (
local Players = ...) - Types: PascalCase (
type PlayerData = {...})
4. 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)
5. Client / Server Architecture
| Container | Script type | Runs on |
|---|---|---|
ServerScriptService |
Script |
Server only |
StarterPlayerScripts |
LocalScript |
Client only |
ReplicatedStorage |
ModuleScript |
Both (shared) |
The server is always authoritative. The client:
- Displays UI and shows state
- Fires RemoteEvents to request actions
- Never computes economic values — the server does that
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.
6. RemoteEvent Security
Roblox exploiters can fire any RemoteEvent with arbitrary arguments at any time.
Never trust client-provided amounts
-- 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)
Validate all arguments
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
Rate-limit all remotes
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)
7. DataStore Patterns
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
Merge saved data onto a known default
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
Save cadence
- Auto-save every 30 seconds via a
task.spawnloop - Always save on
Players.PlayerRemoving— usetask.deferto ensure it fires:
Players.PlayerRemoving:Connect(function(player)
saveData(player, DataService.getPlayerData(player))
end)
8. Type Definitions (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.
9. Config Over Magic Numbers
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)
10. Error Handling
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
11. No Globals
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.
12. File Length
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.
13. Incremental Game Patterns (School_Incremental Specific)
Economy tick (1 second cadence)
-- 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 gain calculation
Knowledge per study = BaseStudyGain * knowledgeMultiplier
where knowledgeMultiplier accumulates from Subject levels:
multiplier += mathLevel * MathConfig.bonusPerLevel
Prestige formula
diplomas = math.floor(totalKnowledgeEarned / PrestigeConfig.baseCost)
Boss reward delivery
Boss rewards are rare currencies. Deliver via EconomyService.awardCurrency, not by
direct attribute write, so all validation and logging stays in one place.
Subject upgrade cost
cost = subject.baseCost * (subject.growth ^ currentLevel)
Computed in UpgradeCalculator.lua — never inline this formula in UI or service code.
14. Remotes Naming Convention
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,
}
Quick Reference
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