스킬 불러오는 중
스킬 불러오는 중
afrxo/roblox-agent-skills · GitHub
How Roblox behaves at runtime. Use whenever writing, reviewing, or debugging server/client Roblox code that touches services, Instances, replication, RemoteEvents/Functions, character lifecycle, physics, DataStore, or platform caveats. Engine layer only — language rules live in luau-expert; UI libraries live in roblox-ui; Rojo/Wally workflow lives in roblox-toolchain.
프로젝트 폴더에서 아래 명령어를 실행하고, 설치할 에이전트를 선택하세요.
npx skills add afrxo/roblox-agent-skills --skill roblox-dev설치 명령을 직접 실행해야 적용됩니다. 지원 에이전트와 필요한 권한·라이선스는 제작자의 안내를 확인하세요.
examples/.gitkeepexamples/character-lifecycle.luau--!strict
-- Per-character setup with proper cleanup across respawns.
-- Each new character gets fresh connections; old connections are disconnected
-- before the next character spawns.
local Players = game:GetService("Players")
local perCharacterConnections: { [Player]: { RBXScriptConnection } } = {}
local function disconnectAll(player: Player)
local list = perCharacterConnections[player]
if list == nil then return end
for _, conn in list do conn:Disconnect() end
perCharacterConnections[player] = nil
end
local function onCharacterAdded(player: Player, character: Model)
-- Clear previous character's connections before binding new ones.
disconnectAll(player)
perCharacterConnections[player] = {}
local conns = perCharacterConnections[player] :: { RBXScriptConnection }
local humanoid = character:WaitForChild("Humanoid", 5) :: Humanoid?
if humanoid == nil then return end
table.insert(conns, humanoid.Died:Connect(function()
print(`{player.Name} died`)
end))
table.insert(conns, humanoid:GetPropertyChangedSignal("WalkSpeed"):Connect(function()
print(`{player.Name} walkspeed = {humanoid.WalkSpeed}`)
end))
end
local function onPlayerAdded(player: Player)
if player.Character ~= nil then
onCharacterAdded(player, player.Character)
end
player.CharacterAdded:Connect(function(character)
onCharacterAdded(player, character)
end)
end
local function onPlayerRemoving(player: Player)
disconnectAll(player)
end
-- Handle players already in the server when this script started, then future joins.
for _, player in Players:GetPlayers() do onPlayerAdded(player) end
Players.PlayerAdded:Connect(onPlayerAdded)
Players.PlayerRemoving:Connect(onPlayerRemoving)
examples/connection-tracker.luau--!strict
-- Disposable scope. Track connections + arbitrary cleanup callbacks; release
-- them all at once. Plain data + free functions — no metatables.
export type Tracker = {
items: { RBXScriptConnection | () -> () },
destroyed: boolean,
}
local function new(): Tracker
return {
items = {},
destroyed = false,
}
end
local function add(tracker: Tracker, conn: RBXScriptConnection): RBXScriptConnection
assert(not tracker.destroyed, "Tracker already destroyed")
table.insert(tracker.items, conn)
return conn
end
local function addCleanup(tracker: Tracker, fn: () -> ())
assert(not tracker.destroyed, "Tracker already destroyed")
table.insert(tracker.items, fn)
end
local function destroy(tracker: Tracker)
if tracker.destroyed then return end
tracker.destroyed = true
for _, item in tracker.items do
if typeof(item) == "RBXScriptConnection" then
item:Disconnect()
else
local ok, err = pcall(item :: () -> ())
if not ok then warn(`Tracker cleanup error: {err}`) end
end
end
table.clear(tracker.items)
end
local Tracker = {
new = new,
add = add,
addCleanup = addCleanup,
destroy = destroy,
}
return table.freeze(Tracker)
--[[
Usage:
local Tracker = require(script.Parent.connectionTracker)
local function setupDoor(door: Model)
local t = Tracker.new()
Tracker.add(t, door.AncestryChanged:Connect(function(_, parent)
if parent == nil then Tracker.destroy(t) end
end))
Tracker.add(t, door:WaitForChild("ClickDetector").MouseClick:Connect(function(player)
toggleDoor(door, player)
end))
Tracker.addCleanup(t, function() print("door cleaned up") end)
end
]]
examples/datastore-session.luau--!strict
-- Session-locked DataStore pattern.
-- Load on PlayerAdded, hold in memory for the session, save on PlayerRemoving
-- and BindToClose. All calls wrapped in pcall with bounded retry.
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local store = DataStoreService:GetDataStore("PlayerData_v1")
export type PlayerData = {
coins: number,
level: number,
lastSeen: number,
}
local DEFAULT_DATA: PlayerData = { coins = 0, level = 1, lastSeen = 0 }
local sessionData: { [Player]: PlayerData } = {}
local function key(player: Player): string
return `Player_{player.UserId}`
end
local function retryAsync<T>(fn: () -> T, attempts: number?): (boolean, T?)
local n = attempts or 5
local delay = 1
local lastErr: unknown
for i = 1, n do
local ok, result = pcall(fn)
if ok then return true, result end
lastErr = result
warn(`DataStore attempt {i} failed: {result}`)
task.wait(delay)
delay = math.min(delay * 2, 30)
end
warn(`DataStore failed after {n} attempts: {lastErr}`)
return false, nil
end
local function load(player: Player): PlayerData
local ok, data = retryAsync(function()
return store:GetAsync(key(player))
end)
if ok and typeof(data) == "table" then
return data :: PlayerData
end
return table.clone(DEFAULT_DATA)
end
local function save(player: Player)
local data = sessionData[player]
if data == nil then return end
data.lastSeen = os.time()
retryAsync(function()
return store:UpdateAsync(key(player), function(prev)
-- If you wanted a real lock, prev would be inspected here for stale sessions.
return data
end)
end)
end
Players.PlayerAdded:Connect(function(player)
sessionData[player] = load(player)
end)
Players.PlayerRemoving:Connect(function(player)
save(player)
sessionData[player] = nil
end)
-- BindToClose has ~30s total across all players. Save in parallel.
game:BindToClose(function()
if RunService:IsStudio() then return end
local pending: { thread } = {}
for player in sessionData do
local co = task.spawn(function() save(player) end)
table.insert(pending, co)
end
-- Best-effort: give threads time to finish before shutdown ends.
local deadline = os.clock() + 25
while os.clock() < deadline do
local stillRunning = false
for _, co in pending do
if coroutine.status(co) ~= "dead" then
stillRunning = true
break
end
end
if not stillRunning then break end
task.wait(0.5)
end
end)
local DataStoreSession = {}
function DataStoreSession.get(player: Player): PlayerData?
return sessionData[player]
end
return table.freeze(DataStoreSession)
examples/remote-handler.luau--!strict
-- Server-side RemoteEvent handler with validated boundary, per-player rate limit,
-- and idempotent application.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PurchaseItem = ReplicatedStorage:WaitForChild("PurchaseItem") :: RemoteEvent
type ItemId = "sword" | "shield" | "potion"
local PRICES: { [ItemId]: number } = {
sword = 100,
shield = 75,
potion = 25,
}
local function isItemId(v: unknown): boolean
return v == "sword" or v == "shield" or v == "potion"
end
-- Per-player rate limit: max 5 purchases per 10 seconds, dropped after.
local rate: { [Player]: { count: number, windowStart: number } } = {}
local WINDOW = 10
local MAX_PER_WINDOW = 5
local function allow(player: Player): boolean
local now = os.clock()
local r = rate[player]
if r == nil or now - r.windowStart > WINDOW then
rate[player] = { count = 1, windowStart = now }
return true
end
if r.count >= MAX_PER_WINDOW then
return false
end
r.count += 1
return true
end
Players.PlayerRemoving:Connect(function(player) rate[player] = nil end)
-- Game-state would live in a dedicated module; this example uses an inline table.
local inventories: { [Player]: { [ItemId]: number } } = {}
Players.PlayerAdded:Connect(function(player) inventories[player] = {} end)
Players.PlayerRemoving:Connect(function(player) inventories[player] = nil end)
local function applyPurchase(player: Player, itemId: ItemId, qty: number)
-- Currency check, deduction, ledger writes would go here.
local inv = inventories[player]
if inv == nil then return end
inv[itemId] = (inv[itemId] or 0) + qty
end
PurchaseItem.OnServerEvent:Connect(function(player, itemId: unknown, qty: unknown)
-- Validate every boundary parameter.
if not isItemId(itemId) then return end
if typeof(qty) ~= "number" or qty ~= qty or qty < 1 or qty > 99 then return end
-- Rate limit after validation so spammers don't even count.
if not allow(player) then return end
applyPurchase(player, itemId :: ItemId, qty)
end)
examples/services-header.luau--!strict
-- The standard top-of-file shape for any server or client script.
-- GetService calls alphabetical, then `local` aliases for non-service modules,
-- then exported types, then implementation. No GetService scattered later.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")
local Inventory = require(ReplicatedStorage.Shared.Inventory)
local Logger = require(ReplicatedStorage.Shared.Logger)
export type SpawnInfo = {
player: Player,
position: Vector3,
}
local function onPlayerAdded(player: Player)
Logger.info(`{player.Name} joined`)
Inventory.init(player)
end
local function spawnAt(info: SpawnInfo)
local character = info.player.Character
if character == nil then return end
local root = character:FindFirstChild("HumanoidRootPart") :: BasePart?
if root == nil then return end
root.CFrame = CFrame.new(info.position)
end
if RunService:IsServer() then
for _, player in Players:GetPlayers() do onPlayerAdded(player) end
Players.PlayerAdded:Connect(onPlayerAdded)
end
return {
spawnAt = spawnAt,
}
references/buffer-library.md# `buffer` Library Reference Fixed-size mutable byte block. Source: [`buffer.yaml` in creator-docs](https://github.com/Roblox/creator-docs/blob/main/content/en-us/reference/engine/libraries/buffer.yaml). - Max size: **1 GiB** (`1_073_741_824` bytes). - Numeric reads/writes are **little-endian**. - All offsets are **byte offsets from 0** (except `readbits`/`writebits` which use **bit offsets**). - Reading/writing past the buffer raises an error. - Sending a buffer through Roblox APIs (RemoteEvents, etc.) **copies** it — the receiver gets a new buffer object. - Cannot share a buffer between Actor scripts (Parallel Luau). --- ## Lifecycle ``` buffer.create(size: number) -> buffer buffer.fromstring(value: string) -> buffer buffer.tostring(b: buffer) -> string buffer.len(b: buffer) -> number ``` `create` zero-initializes. `fromstring` makes a buffer the same byte length as the string. `tostring` returns the full byte content. --- ## Numeric reads / writes For each integer width — i8, u8, i16, u16, i32, u32 — and floats f32, f64: ``` buffer.readi8(b: buffer, offset: number) -> number buffer.writei8(b: buffer, offset: number, value: number) -> () buffer.readu8(b: buffer, offset: number) -> number buffer.writeu8(b: buffer, offset: number, value: number) -> () buffer.readi16 / writei16 (2 bytes) buffer.readu16 / writeu16 (2 bytes) buffer.readi32 / writei32 (4 bytes) buffer.readu32 / writeu32 (4 bytes) buffer.readf32 / writef32 (4 bytes, IEEE 754 single) buffer.readf64 / writef64 (8 bytes, IEEE 754 double) ``` Signed reads sign-extend. Writes truncate to width. All little-endian. --- ## Bit-level ``` buffer.readbits(b: buffer, bitOffset: number, bitCount: number) -> number buffer.writebits(b: buffer, bitOffset: number, bitCount: number, value: number) -> () ``` `bitCount` is `0..32` inclusive. `0` returns `0` (supported so generalized code with dynamic bit widths doesn't error). Convenience equivalences: - `readbits(b, 0, 8)` ≡ `readu8(b, 0)` - `readbits(b, 0, 16)` ≡ `readu16(b, 0)` - `readbits(b, 0, 32)` ≡ `readu32(b, 0)` - `readbits(b, 0, 24)` reads 24 bits — no fixed-width call gives you that. `bitOffset` is a number; since the max buffer is 1 GiB, the bit-offset cannot be assumed to fit in 32-bit ranges in all calculations. --- ## Strings ``` buffer.readstring(b: buffer, offset: number, count: number) -> string buffer.writestring(b: buffer, offset: number, value: string, count: number?) -> () ``` `writestring` with optional `count` writes at most `count` bytes from `value`. `count` cannot exceed `#value`. --- ## Bulk ``` buffer.copy(target: buffer, targetOffset: number, source: buffer, sourceOffset: number?, count: number?) -> () buffer.fill(b: buffer, offset: number, value: number, count: number?) -> () ``` - `copy` accepts `source == target`. Overlapping regions are handled as if the source were copied through a temporary. - `fill` writes `value` (treated as 8-bit unsigned) `count` times. `count` defaults to "to end of buffer". --- ## Use cases - **Compact RemoteEvent payloads.** A packed buffer is smaller than the equivalent Luau table after Roblox's wire format. Worth it for high-frequency events (e.g. position updates, inputs). - **DataStore value compression.** Pack into a buffer, `tostring`, store. Decode on read. - **Reading binary formats** — anything `string.pack`/`unpack` can do, with a stable mutable structure instead of repeated string concatenations. - **Preallocated scratch space** when constructing large strings byte-by-byte (avoids `..` quadratic behavior). --- ## Don't use it for - Tiny payloads. `buffer.create` + reads/writes is overhead vs a 3-field Luau table for occasional events. - Arbitrary structured data — there's no schema. Pair the buffer with a fixed serialization format you control. - Storage you'll mutate concurrently across Actors. Buffers aren't shareable.
references/cloud-services.md# Cloud Services Picker Sources: [data-stores-vs-memory-stores](https://create.roblox.com/docs/cloud-services/data-stores-vs-memory-stores). | Service | Persistence | Scope | In-game access | Closest analogy | |---|---|---|---|---| | Standard data stores | Permanent | Cross-server | Read/write | NoSQL DB | | Ordered data stores | Permanent | Cross-server | Read/write | Sorted DB index | | Memory stores | ≤ 45 days | Cross-server | Read/write | In-memory cache (Redis-ish) | | Configs | Permanent | Cross-server | Read-only | Feature flags | | Secrets stores | Permanent | Cross-server | Read-only | Secrets manager | | In-memory Luau | Session | Single server | Read/write | Server RAM | **Cloud services are server-only.** Clients can't call any of them. --- ## Standard data stores — when - Long-term player data: progression, inventory, settings. - Cross-server: the same key reads the same value on every server. - Automatic version backup: 30 days of past versions retained. - Values can be numbers, strings, booleans, tables. Don't use for: hot in-server state (use in-memory Luau), high-frequency cross-server signals (use memory stores). ## Ordered data stores — when - Sortable numeric data only — typically all-time leaderboards. - `:GetSortedAsync(...)` returns top-N. - No automatic version backup. Don't use for: anything non-numeric. Don't use a separate ordered store for transient leaderboards (use memory store). ## Memory stores — when - Cross-server low-latency state with TTL ≤ 45 days. - Matchmaking queues, party invites, daily/monthly leaderboards. - Higher write throughput than data stores. - Primitives: hash maps, sorted maps, queues. Don't use for: anything that must persist beyond 45 days. Don't use as the primary save layer. ## Configs — when - Tunable in-game values controlled from the Creator Hub. - Feature flags, A/B test parameters, event toggles, balance numbers. - Read-only at runtime; updates propagate over ~5 minutes. Don't use for: anything that needs instant updates or per-player customization. Don't use for: storing user data. ## Secrets stores — when - Third-party API keys, tokens, passwords used by HttpService calls. - Read-only from the game; managed via Creator Hub. Don't use for: anything other than secrets. Storing keys in regular DataStores is a security risk. ## In-memory Luau — when - Anything that doesn't outlive the server session: status effects, current health, timers, in-flight match state, per-player session caches. - Free, instant, zero quota. Don't use for: anything that must survive a server restart. Don't share across servers (it can't). --- ## Decision tree 1. **Can the data be lost when the server shuts down?** → in-memory Luau. 2. **Must it survive forever and persist per-player or per-game?** → standard data stores. 3. **Must it persist forever and sort numerically?** → ordered data stores. 4. **Cross-server, fast, ephemeral (TTL ≤ 45 days)?** → memory stores. 5. **Tunable game-wide setting / feature flag?** → configs. 6. **API key for an external service?** → secrets store. --- ## Common combinations - **Player save** = standard data store (canonical) + in-memory Luau cache (session) + memory store (cross-server presence/lock if needed). - **Matchmaking** = memory store sorted map (queue by skill) + standard data store (player MMR). - **Leaderboard** = ordered data store (all-time) or memory store sorted map (daily/weekly). - **Live event** = config flag (toggle) + standard data store (per-player progress in event). --- ## Common mistakes - Storing API keys in data stores → use secrets stores. - Saving long-term progress to memory stores → expires. - Using standard data stores for high-frequency cross-server signals → throttling. Use memory stores or messaging service. - Using ordered data stores for transient leaderboards → no TTL, fills up. Use memory store sorted map. - Hitting data stores on every player change → session-cache and write on `PlayerRemoving` + `BindToClose`. For data-store-specific rules (rate limits, retry/backoff, session-locking) see [`datastore-rules.md`](datastore-rules.md).
references/datastore-rules.md# DataStore Rules Reference
Roblox persistence is rate-limited, eventually-consistent, and unforgiving of careless code. The rules below are the load-bearing ones; for full quotas and current numbers verify against [create.roblox.com/docs/cloud-services/data-stores](https://create.roblox.com/docs/cloud-services/data-stores).
---
## API surface
- **Standard data stores** (`DataStoreService:GetDataStore("name")`) — durable per-key reads/writes.
- **Ordered data stores** (`DataStoreService:GetOrderedDataStore("name")`) — sortable keys; values must be integers; supports `:GetSortedAsync(...)` for leaderboards.
- **`MemoryStoreService`** — fast, ephemeral cross-server data (queues, sorted sets). Cleared on shutdown.
This file covers standard data stores.
---
## Rate limits (verify against current docs before quoting numbers)
Limits are per-game and per-key. There are separate budgets per operation type:
- `GetAsync`
- `SetAsync` / `IncrementAsync`
- `UpdateAsync`
- `RemoveAsync`
- `GetSortedAsync` (ordered stores)
Each budget refills over time. Bursting past the budget queues your request; quota exhaustion drops it. Check `DataStoreService:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.X)` if you need to gate behavior.
**There's also a hard rule: no more than once every ~6 seconds per key.** Faster writes to the same key get throttled.
---
## Always wrap in `pcall`
Every DataStore call can fail (network blip, throttling, quota). Never call without a `pcall`:
```luau
local ok, result = pcall(function()
return store:GetAsync(key)
end)
if not ok then
warn(`DataStore read failed for {key}: {result}`)
return nil
end
```
Don't suppress the error silently in production — log it.
---
## Use `UpdateAsync` for concurrent state
`SetAsync` overwrites. If another server is writing the same key, you can lose its update.
```luau
-- ❌ overwrite — loses concurrent writes
store:SetAsync(key, newValue)
-- ✅ atomic merge — sees previous value, decides
store:UpdateAsync(key, function(prev)
return mergeOrReject(prev, newValue)
end)
```
`UpdateAsync` also gives you a chance to **reject** the write by returning `nil` from the transform — Roblox does not write and you don't burn the budget.
---
## Session locking pattern
Pattern for player data:
1. **On `PlayerAdded`** — `pcall(store:UpdateAsync(key, ...))` to claim the lock and read the data. The transform writes a `lockedBy = serverJobId` field with a timestamp.
2. **In memory for the session** — keep the data in a Lua table; mutate locally as the player plays.
3. **Periodic autosave** — `task.delay(60, save)` loop; writes the in-memory state back via `UpdateAsync`.
4. **On `PlayerRemoving`** — final save + clear the lock.
5. **`game:BindToClose`** — runs at server shutdown. **Total budget is ~30 seconds across all players**, so save in parallel via `task.spawn`/`task.wait`-coordinated dispatch.
If another server holds the lock when you try to claim it, choose: refuse the join, or wait + retry with backoff (the previous server may be shutting down).
---
## Retry with exponential backoff
```luau
local function retryAsync<T>(fn: () -> T, attempts: number?): T?
local n = attempts or 5
local delay = 1
for i = 1, n do
local ok, result = pcall(fn)
if ok then return result end
warn(`DataStore attempt {i} failed: {result}`)
task.wait(delay)
delay = math.min(delay * 2, 30)
end
return nil
end
```
Don't retry forever — bounded attempts, then surface the failure.
---
## Value rules
- **Strings, numbers, booleans, tables, `nil`** are valid. Tables can nest.
- **No `Instance`s, `RBXScriptConnection`s, functions, or userdata** in the value.
- **64 KB per value** (compressed). Verify the current limit before relying on it.
- **Keys are strings, ≤ 50 chars, ASCII**. Don't include the user-facing display name; use `UserId`.
For values that approach the size limit, serialize to JSON or to a `buffer` and store as a string.
---
## Common mistakes
- Reading on every change. Read once at session start; mutate in memory.
- Using `SetAsync` instead of `UpdateAsync` for concurrent state.
- Saving without `pcall`.
- Saving in a tight loop (write every X seconds, not every action).
- Storing display names instead of `UserId`.
- Large nested tables full of `Instance` references that get serialized as `nil`.
- Forgetting `BindToClose`. On a shutdown, all in-memory unsaved state is gone.
references/parallel-luau.md# Parallel Luau Reference
Actor-based multithreading. Source: [create.roblox.com/docs/scripting/multithreading](https://create.roblox.com/docs/scripting/multithreading).
By default Luau runs on a single thread. Parallel Luau lets independent work run on multiple OS threads, gated through `Actor` Instances.
---
## When to use it
Good fits:
- **Server-side hit validation** — one actor per character, parallel raycasting on RemoteEvent fires.
- **Procedural generation** — terrain chunks, dungeon rooms, large world-build passes.
- **Large independent computations** — pathfinding many NPCs, AI sims.
- **Anything CPU-bound that doesn't need to mutate the DataModel often.**
Bad fits:
- Code that mostly mutates Instances (you'll spend time `task.synchronize`'d).
- Cheap work — actor overhead exceeds the parallelism win.
- Long unyielding loops — even on a parallel thread, they still block that thread.
- Code that needs `require()` at runtime in the parallel phase (not allowed).
---
## Actor model
- An **`Actor`** is an Instance. Scripts that are descendants of an actor are eligible to run in parallel.
- A script's "owning actor" is its closest `Actor` ancestor. Don't nest actors.
- **Scripts in the same actor still run serially with respect to each other.** To get parallelism, split into multiple actors.
- Use **more actors than cores** for load balancing — the engine distributes them. 64 actors on a 4-core box is fine.
- Don't use *too* many — divide by logic units, not micro-units.
Place actors:
```
ServerScriptService/
CombatActors/
Actor (one per character)
HitValidator.luau
```
---
## Phases
### Switching to parallel
```luau
-- Inside a script under an Actor
local function compute()
task.desynchronize() -- now parallel
local result = expensiveMath()
task.synchronize() -- back to serial
Workspace.Score.Value += result -- safe DataModel write
end
```
`task.desynchronize()` yields and resumes at the next parallel-execution opportunity. `task.synchronize()` yields and resumes when the engine reaches the next serial point.
### Parallel-by-default connections
```luau
RunService.Heartbeat:ConnectParallel(function()
-- already parallel; no need for task.desynchronize
local hit = doRaycast()
task.synchronize()
applyDamage(hit)
end)
```
Most signals support `:ConnectParallel(...)`. The callback runs in parallel automatically. Saves the manual `desynchronize`.
### Hard rule
**`require()` cannot run in a parallel phase.** Require modules in serial first; pass them in or call from the serial portion.
---
## Thread safety
API members carry a thread-safety tag. The four levels:
| Level | Properties (parallel) | Functions (parallel) |
|---|---|---|
| Unsafe (default if untagged) | No read, no write | No call |
| Read Parallel | Read OK | n/a |
| Local Safe | Same-actor any; cross-actor read-only | Same-actor only |
| Safe | Read + write | Call from anywhere |
Most reads are safe; most writes are not. Common safe-in-parallel APIs: `Workspace:Raycast`, `BasePart.CFrame` (read), most math/CFrame/Vector3 work, `Random.new` instances.
Common unsafe-in-parallel: parenting changes, `:Destroy()`, `Instance.new` then parenting, most signal-firing operations.
The engine prevents unsafe access at runtime — you'll get an error rather than a corrupted state.
---
## Cross-thread communication
Three mechanisms.
### Actor messaging
```luau
-- Sender (anywhere)
workerActor:SendMessage("GenerateChunk", x, y, z, seed)
-- Receiver (script under workerActor)
local actor = script:GetActor()
actor:BindToMessageParallel("GenerateChunk", function(x, y, z, seed)
local data = generate(x, y, z, seed)
task.synchronize()
writeChunk(data)
end)
```
- Async one-way. Sender doesn't block.
- Each topic can have multiple bindings on the same actor.
- Only descendants of an actor can bind for that actor.
- `:BindToMessage(topic, fn)` for serial callbacks; `:BindToMessageParallel(topic, fn)` for parallel.
### `SharedTable`
Table-like data structure shared across actors. Atomic updates. Sending to another actor doesn't copy the data.
```luau
local SharedTable = require(...) -- or from globals depending on context
local state = SharedTable.new({ score = 0 })
workerActor:SendMessage("Tick", state)
-- in the worker
actor:BindToMessageParallel("Tick", function(state)
SharedTable.increment(state, "score", 1)
end)
```
Use for "common world state not stored in DataModel" — large grids, voting tallies, accumulated counters.
### Direct DataModel reads/writes
Works in serial phases. In parallel phases, most writes are blocked by thread safety. Falling back to this for cross-thread comm forces frequent synchronization and erodes the parallelism gain.
---
## Pattern: per-character parallel raycasting
```luau
-- One actor per character, scripts placed under it on character spawn.
-- Each character's hit validation runs on its own thread.
remoteEvent.OnServerEvent:Connect(function(player, clickLocation: CFrame)
local character = player.Character
local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
params.FilterDescendantsInstances = { character }
task.desynchronize()
local origin = tool.Handle.CFrame.Position
local result = Workspace:Raycast(origin, (clickLocation.Position - origin) * 1.01, params)
if result and result.Instance.Name == "block" then
task.synchronize()
if result.Instance.Parent then
local explosion = Instance.new("Explosion")
explosion.DestroyJointRadiusPercent = 0
explosion.Position = clickLocation.Position
explosion.Parent = Workspace
result.Instance:Destroy()
end
end
end)
```
Multiple characters firing simultaneously each get their own thread; only the final mutation runs in serial.
---
## Best practices
- **Avoid long computations** — break work up with `task.wait` even in parallel.
- **More actors > fewer**, within reason. Divide by logic unit (one actor per NPC, one per chunk worker).
- **Don't share buffers across actors** (`buffer` library limitation; use `SharedTable` instead).
- **Pre-require modules in serial** — design for the parallel-no-require constraint.
- **Test with the actor count you'll ship with** — single-actor mode (the default behavior of code without actors) doesn't surface parallelism bugs.
references/performance-optimization.md# Performance Optimization Reference
Comprehensive pitfall + mitigation list. Sources: [create.roblox.com/docs/performance-optimization](https://create.roblox.com/docs/performance-optimization), [/improve](https://create.roblox.com/docs/performance-optimization/improve).
The cycle: **design** → **identify** (MicroProfiler, Developer Console) → **improve** → **monitor**.
Targets:
- Client: 60 FPS = 16.67 ms/frame.
- Server: `Heartbeat` cadence = the equivalent — keep heartbeat work small.
- Memory: track `LuaHeap`, `InstanceCount`, `PlaceScriptMemory` in the Developer Console.
---
## Script computation
Common problems:
- **Expensive work on every-frame events**: `Heartbeat`, `PreSimulation`, `PostSimulation`, `PreRender`, `PreAnimation`. Anything called from a tight loop body 60×/sec adds up fast.
- **Recursive table operations**: deep clones, deep equality checks, large serialization passes. Cost grows with nesting and table size.
- **Yields in tight per-event handlers** (see `roblox-dev` Bug Catalog).
Mitigations:
- Throttle: keep a `local last = 0` and only do work when `now - last >= interval`.
- Move occasional work off `Heartbeat` to a slower loop:
```luau
task.spawn(function()
while true do
task.wait(0.5)
maintainState()
end
end)
```
- Break long work across frames with `task.wait()` between chunks.
- Move CPU-bound work to **Parallel Luau** when it doesn't need DataModel mutation (see `parallel-luau.md`).
- For hot computational paths, consider `--!native` (Native Code Generation) — verify with the MicroProfiler before adopting.
---
## Script memory leaks
Common sources:
- **Connections that stay connected.** The engine keeps the connection callback (and everything it captures) alive until the connection is disconnected or its Instance is destroyed.
- **`Player` Instances are not auto-destroyed on leave.** Connections to `Player` and per-character signals survive across the leave unless you disconnect them. Multiplied across hundreds of joins/leaves this is a major server leak source.
- **Tables that grow without removal**: `playerData[player] = {...}` on join without `playerData[player] = nil` on leave.
- **Closures capturing large tables in `:Connect(...)`**: the closure keeps the table alive as long as the connection lives.
Mitigations:
- **Disconnect connections** explicitly, or destroy the owning Instance, or rely on automatic disconnect when the Instance is destroyed.
- **Auto-destroy players/characters**: enable `Workspace.PlayerCharacterDestroyBehavior`, or destroy manually:
```luau
Players.PlayerAdded:Connect(function(player)
player.CharacterRemoving:Connect(function(character)
task.defer(character.Destroy, character)
end)
end)
Players.PlayerRemoving:Connect(function(player)
task.defer(player.Destroy, player)
end)
```
- **Always nil out per-player table entries on leave.**
- **Avoid capturing large tables in long-lived closures**; capture references you actually need.
---
## Physics
Common problems:
- **Excessive simulated parts.** Anything unanchored ticks the physics engine.
- **Fixed-rate physics stepping** at 240 Hz (vs adaptive). Quadruples per-frame physics cost.
- **`CollisionFidelity = Precise`** on every mesh part. Massive CPU and memory overhead.
Mitigations:
- **`Anchored = true`** on anything that doesn't need physics.
- **Adaptive timestepping** (`Workspace` setting) instead of fixed.
- **Reduce constraint and joint count**; reduce ragdoll self-collision.
- **Pick the right `CollisionFidelity`**:
- Small/non-interactable → `Box`.
- Small-medium → `Box` or `Hull`.
- Large complex → custom collision built from invisible Boxes.
- Non-collidable → `CanCollide=false; CanTouch=false; CanQuery=false` and still pick `Box` (collision geometry is stored in memory regardless).
---
## Humanoids & avatars
Common problems:
- **All `HumanoidStateType`s enabled** on every NPC. Each enabled state has a cost.
- **Frequent re-instantiation of rigs with `Humanoid` + `MeshPart`s** — particularly with **layered clothing**. `updateInvalidatedFastClusters` MicroProfiler tag is the symptom.
- **Server-side animation playback for many NPCs** — replicates every animation update.
- **Static NPCs with `Humanoid`s** that never move.
Mitigations:
- **`Humanoid:SetStateEnabled(state, false)`** for unused states.
- **`AnimationController`** instead of `Humanoid` for static NPCs or NPCs with custom movement.
- **Play NPC animations on the client** — create the `Animator` client-side; saves server simulation + replication. Bonus: only run animations for nearby NPCs.
- **Pool rigs** instead of instantiating per spawn.
- **Avoid runtime size/scale changes** on rigs — they invalidate FastCluster.
---
## Rendering
Common problems:
- **High draw call count** — too many distinct meshes/materials.
- **High-resolution textures**. A 1024×1024 texture costs **4×** the GPU memory of a 512×512.
- **Asset duplication** — same mesh uploaded multiple times under different IDs.
- **Excessive `BillboardGui`s, `ParticleEmitter`s, lights**.
Mitigations:
- **Reuse asset IDs**. Audit for duplicates; consolidate.
- **Cap texture resolution** to actual screen footprint. Most images need ≤ 512×512; minor images ≤ 256×256.
- **Trim sheets / sprite sheets** to reduce draw calls.
- **`SurfaceAppearance.Color`** to tint a single texture rather than uploading colored variants.
- **LODs** (Levels of Detail) for distant geometry — Roblox handles some automatically; mesh import options expose more control.
---
## Networking & replication
Common problems:
- **Excessive RemoteEvent traffic** — high-frequency fires, large payloads, full-state pushes instead of deltas.
- **Server-side `TweenService`** — every property update of the tweened part replicates to every client every frame. Almost always wrong.
- **Large instance trees created/destroyed at runtime** — replicates the entire descendant set.
- **Animation Editor metadata** in published rigs — large hidden data structures replicating with every clone.
Mitigations:
- **Replicate deltas, not state**. Send the change, not the whole inventory.
- **Throttle client→server inputs** — `task.wait` between sends, or accumulate locally and send on a fixed cadence.
- **Tween on the client.** Server sets the target; client interpolates.
- **Use `buffer` for high-frequency remote payloads** — packed bytes are smaller than equivalent Luau tables.
- **Strip animation metadata** before publishing rigs.
- **Chunk large map loads** across frames with `task.wait` between chunks.
- **Skip replicating ephemeral visuals** — explosions, magic blasts, first-person view models. Server tells clients "thing happened at X"; each client makes its own VFX.
---
## Asset memory
Common problems:
- **Streaming disabled.** Especially on large worlds. Direct ticket to client crashes on lower-end devices.
- **Overuse of `Persistent` `ModelStreamingMode`** — every persistent thing loads on every client.
- **Loading every asset upfront** with `ContentProvider:PreloadAsync(...)`.
- **Audio files not chunked** — every music track loaded at once.
Mitigations:
- **Enable `Workspace.StreamingEnabled = true`**. Tune `StreamingMinRadius` and `StreamingTargetRadius` aggressively.
- **Only `Persistent` what every client must always see.** Defaults beat "I'll just make it persistent to be safe".
- **`PreloadAsync` selectively**: loading-screen images, starting-area essentials, button icons. Not the entire Workspace.
- **Provide a "Skip Loading" button** when preloading large sets — networks vary; users will leave.
- **Lazy-load audio** by region or context.
---
## Load times
- **Don't `PreloadAsync(workspace:GetDescendants())`.** Common anti-pattern; multiplies load time for marginal pop-in benefit.
- **`ContentProvider.RequestQueueSize` polling** is unreliable and prolongs load. Don't gate on it.
- **Loading screen images themselves should be tiny**. Big loading-screen art with massive resolution makes the loading screen the slowest thing.
---
## Useful MicroProfiler scopes
Mark up your own:
```luau
debug.profilebegin("InventoryUpdate")
updateInventory(player)
debug.profileend()
```
Built-in scopes worth knowing:
| Scope | What it measures |
|---|---|
| `RunService.PreRender` | Code on PreRender |
| `RunService.PreSimulation` / `PostSimulation` / `Heartbeat` | Code on each frame phase |
| `physicsStepped` / `worldStep` | Physics computation |
| `ProcessPackets` | Incoming network packets |
| `Allocate Bandwidth` / `Run Senders` | Outgoing replication |
| `updateInvalidatedFastClusters` | Avatar/skinned-mesh churn |
If a scope is consistently > 1 ms on the client or > 4 ms on the server, it's a candidate.
---
## Memory monitoring
Developer Console fields to watch:
- **`LuaHeap`** — your scripts' Luau heap. Growing monotonically = leak.
- **`InstanceCount`** — total Instances. Growing without explanation = orphan Instances retained by references.
- **`PlaceScriptMemory`** — per-script breakdown. Find the offender.
- **`PhysicsParts`** — collision-fidelity memory. Reduce fidelity if high.
references/query-descendants.md# `Instance:QueryDescendants`
```
Instance:QueryDescendants(selector: string) -> { Instance }
```
Returns an array of descendants matching the selector string. Empty array on no match (never `nil` for non-matches). Selector grammar is similar to `StyleRule` selectors, with a few differences.
- **Security:** None.
- **Thread safety:** Unsafe.
- **Tag:** CustomLuaState.
Source: [create.roblox.com/docs/reference/engine/classes/Instance#QueryDescendants](https://create.roblox.com/docs/reference/engine/classes/Instance#QueryDescendants).
---
## Selectors
| Syntax | Matches |
|---|---|
| `ClassName` | Instances where `:IsA("ClassName")` is true |
| `.Tag` | Instances tagged with the CollectionService tag `Tag` |
| `#Name` | Instances whose `Name` is `Name` |
| `[property = value]` | Instances with property `property` equal to `value` (boolean, number, or string; `_` and `-` allowed unquoted) |
| `[$attribute]` | Instances with attribute `attribute` set |
| `[$attribute = value]` | Instances with attribute `attribute` equal to `value` |
Selectors stack on the same instance:
```luau
-- Model tagged "Apple" with attribute Variety = Fuji and Flavor = 4
Workspace:QueryDescendants("Model.Apple[$Variety = Fuji][$Flavor = 4]")
```
To check for the **absence** of an attribute, use `:not([$attribute])` — querying `[$attribute = nil]` is invalid.
---
## Combinators
| Syntax | Meaning |
|---|---|
| `A > B` | `B` is a **direct child** of an `A` match |
| `A >> B` | `B` is a **descendant** of an `A` match (this is the default; `B` alone means `>> B`) |
| `A, B` | Union — instances matching `A` OR `B` |
```luau
-- Direct children of any Model that are tagged "SwordPart"
Workspace:QueryDescendants("Model > .SwordPart")
-- Direct children of `Workspace` that are Parts tagged "Apple"
Workspace:QueryDescendants("> Part.Apple")
-- Descendants of any Model that have attribute OnFire = true
Workspace:QueryDescendants("Model >> [$OnFire = true]")
-- MeshPart tagged "SwordPart" OR MeshPart with OnFire = true
Workspace:QueryDescendants("MeshPart.SwordPart, MeshPart[$OnFire = true]")
```
---
## Pseudo-class selectors
| Syntax | Meaning |
|---|---|
| `:not(complex-selector-list)` | Negation — matches instances that don't match any selector in the list |
| `:has(relative-selector-list)` | Containment — matches instances that contain something matching the inner selector(s) |
```luau
-- Not a SpotLight
Workspace:QueryDescendants(":not(SpotLight)")
-- Not SpotLight and not PointLight
Workspace:QueryDescendants(":not(SpotLight, PointLight)")
-- Without OnFire attribute
Workspace:QueryDescendants(":not([$OnFire])")
-- Everything except children of a Model tagged SwordPart
Workspace:QueryDescendants(":not(Model > .SwordPart)")
-- Anything that has a Tool descendant
Workspace:QueryDescendants(":has(Tool)")
-- MeshPart with a direct child tagged SwordPart
Workspace:QueryDescendants("MeshPart:has(> .SwordPart)")
-- MeshPart whose direct children are NOT SurfaceAppearance or Texture
Workspace:QueryDescendants("MeshPart:has(> :not(SurfaceAppearance, Texture))")
```
---
## When to use vs `GetDescendants`
- **Use `QueryDescendants`** when you want a filtered slice — class match, tag match, attribute filter, hierarchy combinator. Filtering happens engine-side; you skip the Luau-side loop.
- **Use `GetDescendants`** when you genuinely need every descendant and will iterate them all anyway.
- **Use `GetChildren`** for direct children only.
- For one named child, use `FindFirstChild`. For one match by class, `FindFirstChildOfClass` / `FindFirstChildWhichIsA` (descendants: pass `true` as second arg or use `FindFirstDescendant`).
references/replication-model.md# Replication Model Reference What replicates from where, what doesn't, and the timing rules. Source: [create.roblox.com/docs/scripting/networking](https://create.roblox.com/docs) (replication, FilteringEnabled era and current model). --- ## Replication direction | From → To | Replicates? | Notes | |---|---|---| | Server → all clients | ✅ automatic | For Instances under `Workspace`, `ReplicatedStorage`, `ReplicatedFirst`. | | Server → one client | ✅ automatic | For Instances under `Players[X].PlayerGui`, `Players[X].Backpack`. | | Client → server | ❌ | Anything a client creates or modifies in `Workspace` etc. stays local. **Exceptions:** `Player.Character` (filtered), input events, explicit `RemoteEvent`/`RemoteFunction` calls. | | Client → other clients | ❌ | Always goes through the server. | Server-only containers: `ServerStorage`, `ServerScriptService`. Clients never see them. --- ## Per-property replication Replication is per-property, not all-or-nothing. - Visual properties (`CFrame`, `Color`, `Material`, `Transparency`, `Size`) replicate. - Some scripted properties replicate, some don't. Check the [docs](https://create.roblox.com/docs/reference/engine) for the specific class. - Attributes set with `:SetAttribute(name, value)` replicate. - `Tags` from `CollectionService` replicate. - `Player.PlayerScripts` exists on both sides but its contents are owned by the client; server changes don't reach the client. - `Player.PlayerGui` initially populated from `StarterGui` on character spawn; subsequent server-side adds/removes replicate, but client-local additions are client-only. --- ## Replication timing & ordering - **Replication is async.** Anything created server-side appears on clients on a future frame, not the same one. - **Set properties before parenting.** Once an Instance is in a replicated container, every change replicates as its own message. Build it fully, then `Parent = ...` last. - **Don't rely on synchronous-fire signals.** With `Workspace.SignalBehavior = "Deferred"` (modern default), signals batch until the next resumption point. Code that does `child.Parent = parent; assert(parent:FindFirstChild(child.Name))` works; code that registers a one-shot listener and immediately reparents may miss the firing depending on signal mode. - **Initial replication on join** — clients receive a snapshot of replicated state when they join, then incremental updates. `WaitForChild` exists because that snapshot may not include a child you wrote on the server before the client could see it. --- ## Trust boundaries - **`Player` argument on `OnServerEvent`/`OnServerInvoke`** — authentic. Provided by the engine. - **Every other `RemoteEvent` argument** — untrusted. Type as `unknown` and validate. - **Client-set properties** that replicate (e.g. character `CFrame`) — observable but not trustworthy. Validate position deltas, speed, etc. against expectations before acting. - **`MessagingService` payloads** — cross-server messages. Untrusted as far as game state goes; one bad server can publish anything. Validate and rate-limit. --- ## When in doubt - Test in Studio with **Local Server** (multiple clients + one server). Reproduces actual replication behavior; running in Edit mode does not. - For specific properties, the docs page for the class lists `Replication` and `Security` per property. Read it before assuming.
references/streaming-enabled.md# StreamingEnabled Reference `Workspace.StreamingEnabled = true` is the modern default. The world streams to each client based on radius and chunks; clients only see and have to download what's near them. Source: [create.roblox.com/docs/workspace/streaming](https://create.roblox.com/docs). --- ## Workspace properties | Property | Effect | |---|---| | `StreamingEnabled` | Master switch. | | `StreamingMinRadius` | Minimum loaded radius around each player. Larger = more memory + bandwidth. | | `StreamingTargetRadius` | Target loaded radius; the engine streams up to this when bandwidth allows. | | `StreamingIntegrity` (newer) | Tunes how aggressively the engine guarantees content is loaded vs prioritizes bandwidth. | | `StreamingPauseMode` | What the client does when content is streaming in (`Default`, `ClientPhysicsPause`, `Disabled`). | Verify exact names + defaults at [docs](https://create.roblox.com/docs) — these have shifted across releases. --- ## `Model.ModelStreamingMode` Per-Model control over streaming behavior: | Value | Meaning | |---|---| | `Default` | Model streams normally; descendants stream independently. | | `Atomic` | Model streams in/out as a single unit. Descendants are always present together or absent together. Use for assemblies (vehicles, NPC rigs) where partial loads are broken. | | `Persistent` | Model is always loaded for every client, regardless of radius. Costs memory on every client; use sparingly. | | `PersistentPerPlayer` | Always loaded for players added via the per-player API; behaves like `Default` for others. | Don't set `Persistent` on large geometry. Reserve for things every client must always see (e.g. UI props, anchors for global systems). --- ## Client-side rules - **`WaitForChild` with timeout** for any descendant that may not be loaded yet. Without timeout, you risk hanging forever if the part never arrives in your radius. - **Listen to `workspace.PersistentLoaded`** if your client logic depends on persistent content being present. - Use `Player:RequestStreamAroundAsync(position)` to nudge a region into streaming for the local player (e.g. before teleporting). --- ## Server-side rules - The server always sees every Instance — `:FindFirstChild` is sufficient. - Don't drive `CFrame` updates on parts you know are streamed-out for a player; it's bandwidth without visible effect. There's no API to query "is this part streamed in for player X" at high frequency, but designing systems so updates only fire while a player is in range mitigates this. --- ## Common mistakes - Assuming a part exists on the client because it exists on the server. - Making a player's `HumanoidRootPart` non-`Persistent` and getting odd behavior when they fall out of their own streaming radius (rare but possible). - Setting `Persistent` on a large environment and wondering why memory blew up on low-end devices. - Using `Atomic` on a Model that legitimately should stream piece-by-piece (e.g. a large detailed environment with independent props). - Calling `WaitForChild` without a timeout in production code.
SKILL.md---
name: roblox-dev
description: >
How Roblox behaves at runtime. Use whenever writing, reviewing, or debugging
server/client Roblox code that touches services, Instances, replication,
RemoteEvents/Functions, character lifecycle, physics, DataStore, or platform
caveats. Engine layer only — language rules live in luau-expert; UI libraries
live in roblox-ui; Rojo/Wally workflow lives in roblox-toolchain.
---
# Roblox Engine
How the Roblox platform behaves at runtime. Sources: [create.roblox.com/docs](https://create.roblox.com/docs) for APIs and concepts; [devforum.roblox.com](https://devforum.roblox.com) for engineer clarifications and RFCs.
For deep dives see:
- [`references/replication-model.md`](references/replication-model.md) — what replicates from where, per-property rules, deferred-signal timing
- [`references/cloud-services.md`](references/cloud-services.md) — picker for data stores / memory stores / configs / secrets / in-memory
- [`references/datastore-rules.md`](references/datastore-rules.md) — rate limits, retry/backoff, session locking
- [`references/streaming-enabled.md`](references/streaming-enabled.md) — chunk semantics, `ModelStreamingMode`, client-side rules
- [`references/query-descendants.md`](references/query-descendants.md) — full `Instance:QueryDescendants` selector grammar
- [`references/buffer-library.md`](references/buffer-library.md) — full `buffer` library API
- [`references/parallel-luau.md`](references/parallel-luau.md) — actor model, ConnectParallel, SharedTable, thread safety
- [`references/performance-optimization.md`](references/performance-optimization.md) — full pitfall + mitigation list
---
## Integration Policy
This skill describes preferred patterns; it does **not** authorize rewriting existing code. Apply rules to new code and code the user explicitly asks to refactor. Match the codebase's existing style (casing, indentation, quote style, module shape) before applying skill defaults. Don't introduce tooling the project doesn't already use; surface conflicts once and default to the existing pattern.
Full rules: [`../../shared/integration-policy.md`](../../shared/integration-policy.md).
---
## Principles
- **Server is authoritative.** The client lies. Anything that affects game state (currency, inventory, damage, position-to-trust) lives on the server. The client signals intent; the server validates and applies.
- **State ownership is exclusive.** Every piece of state has one owner — server, the controlling client, or replication. Two writers means a race.
- **Replication is async.** Anything created server-side appears on clients on a future frame, not the same one. Anything created client-side does **not** replicate to the server.
- **Yielding is observable.** A yielding call inside an event handler runs other handlers in between. Order is not what you wrote.
- **Verify before quoting an API.** Roblox's API surface is huge and shifts. State uncertainty rather than hallucinate property names.
---
## Services — `GetService` Everything
```luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")
local Players = game:GetService("Players")
```
- Use `game:GetService("Name")` always. Not `game.ServiceName`.
- Some services aren't available at script runtime by default — `GetService` creates them; dot-indexing throws.
- Some services don't share their class name with their accessor (e.g. `RunService` is internally "Run Service").
- **Prefer `Workspace` via `GetService` over the lowercase `workspace` global.** Both work; the global predates `GetService` and exists for compatibility. Sticking to `GetService` keeps every service access one consistent shape and surfaces typos as undefined-variable errors.
- Put `GetService` calls at the top of the file, alphabetical, no others scattered later.
---
## DataModel & Instances
- The DataModel is the tree rooted at `game`. Services are top-level children. Everything else is an Instance.
- An Instance is alive until its `Parent = nil` AND no script holds a reference. **Setting `Parent = nil` is not destruction.** Use `:Destroy()` to disconnect events, mark it as destroyed, and set `Parent` to nil.
- **`:Destroy()` is irreversible.** A destroyed Instance can't be re-parented; further property access throws.
- `:Clone()` requires `Archivable = true` (the default). Cloning a model includes its descendants; references between siblings are NOT rebound.
### Querying Descendants
- `:GetChildren()` — direct children only.
- `:GetDescendants()` — full subtree, unfiltered. Cheap for small trees, costly for large ones (e.g. a streamed Workspace).
- `:FindFirstChild(name)` / `:FindFirstChild(name, true)` — second arg = recursive.
- `:FindFirstChildOfClass(class)` / `:FindFirstChildWhichIsA(class)` — class-specific direct child.
- **`:QueryDescendants(selector: string)`** → `{ Instance }`. Engine-side filtered descendant query with a CSS-like selector grammar (`ClassName`, `.Tag`, `#Name`, `[prop = val]`, `[$attr]`, `[$attr = val]`, combinators `>` `>>` `,`, pseudo-classes `:not(...)` `:has(...)`). Prefer this over `GetDescendants` + Luau-side filter for large trees. Thread-unsafe. Full grammar + examples in [`references/query-descendants.md`](references/query-descendants.md).
```luau
Workspace:QueryDescendants("MeshPart.SwordPart") -- MeshParts tagged SwordPart
Workspace:QueryDescendants("Model >> [$OnFire = true]") -- attribute-matching descendants of any Model
Workspace:QueryDescendants(":not(SpotLight, PointLight)") -- excluding lights
```
### `WaitForChild` Rules
- On the **client**, children of replicated containers (e.g. `ReplicatedStorage`, `Workspace`) may not exist on the first frame. Use `:WaitForChild("Name")` for anything you didn't put there yourself.
- On the **server**, every Instance you placed in Studio exists at script start; `:FindFirstChild` is enough.
- Never call `WaitForChild` without a timeout in production code — it yields forever if the child never arrives. `:WaitForChild("Name", 5)` returns `nil` after 5 seconds.
---
## Script Types & Locations
Three script types:
- **`Script`** — runs on server **or** client depending on `Script.RunContext` and location.
- `RunContext = Legacy` (default) → server-only, only runs in server containers (`ServerScriptService`, `Workspace`, etc.).
- `RunContext = Server` → server, can run from `ReplicatedStorage` too (but don't — clients can read source).
- `RunContext = Client` → client, runs from `ReplicatedStorage`. Recommended for new client scripts (clearer than `LocalScript`).
- **`LocalScript`** — client-only. No `RunContext`. Runs only in client containers: `StarterPlayerScripts`, `StarterCharacterScripts`, `StarterGui`, `StarterPack`, `ReplicatedFirst`.
- **`ModuleScript`** — `require()`-able from either side. Runs in the requirer's context.
### Recommended layout
| Where | What goes here | RunContext |
|---|---|---|
| `ServerScriptService` | Server scripts + server-only ModuleScripts | `Server` |
| `ServerStorage` | Server-only data (assets, tables) — clients never see contents | n/a |
| `ReplicatedStorage` | Shared ModuleScripts (server + client) **and** client scripts | `Client` for client scripts |
| `ReplicatedFirst` | Minimal client loader (e.g. loading screen) | `Client` |
| `StarterPlayerScripts` / `StarterCharacterScripts` / `StarterGui` / `StarterPack` | `LocalScript`s that run per-player | n/a |
| `Workspace` | World content. Server-side `Script`s for object behaviors. | `Server` if you want explicit |
Common practice: keep most code as ModuleScripts in `ReplicatedStorage` (shared) or `ServerStorage`/server-only spots, with **one** server entry-point `Script` in `ServerScriptService` that requires the modules, and **one** client entry-point in `ReplicatedStorage` (`RunContext = Client`).
### Rules
- **Don't put server logic in `ReplicatedStorage`.** Clients can read source; assume anything there is leaked.
- **Set `RunContext` explicitly on scripts inside models or packages** — removes ambiguity when the model is reparented or shared.
- **Use `RunService:IsServer()` / `:IsClient()`** for runtime branching in shared ModuleScripts. Don't infer from `script.Parent`.
- **`LocalScript`s in `Workspace` don't run.** Client-runnable containers only. Common bug.
- **Server scripts in `Workspace`** run, but `RunContext = Server` in `ServerScriptService` is clearer and less surprising when geometry moves.
Source: [create.roblox.com/docs/scripting/locations](https://create.roblox.com/docs/scripting/locations).
---
## Replication
- **Server → client is automatic** for replicated containers (`Workspace`, `ReplicatedStorage`, `Players[X].PlayerGui`, `StarterGui`-cloned-to-PlayerGui). Property changes and parenting changes replicate.
- **Client → server is NOT.** Anything a client creates or modifies stays local — the server never sees it. Exception: `Players[X].Character` (filtering era + character ownership), input events, and explicit `RemoteEvent` calls.
- Replication is **per-property**, not all-or-nothing. Some containers exist on both sides but their contents are client-local (`Player.PlayerScripts`, `Player.PlayerGui` after initial replication of `StarterGui`).
- **Set properties before parenting.** Once an Instance is in a replicated container, every subsequent property change replicates as its own update — clients can observe an intermediate state. Build the Instance fully, then set `Parent`.
- **Signal behavior matters.** `Workspace.SignalBehavior = Enum.SignalBehavior.Deferred` (the modern default in new places) batches signal firings until the next resumption point — code that assumes synchronous-fire `ChildAdded` is brittle. Test with deferred signals on.
- See [`references/replication-model.md`](references/replication-model.md) for the full table.
---
## RemoteEvents & RemoteFunctions
```luau
-- Server
local TakeDamage = ReplicatedStorage:WaitForChild("TakeDamage") :: RemoteEvent
TakeDamage.OnServerEvent:Connect(function(player, amount: unknown)
if typeof(amount) ~= "number" or amount < 0 or amount > 100 then return end
-- validated; apply
end)
-- Client
TakeDamage:FireServer(25)
```
- **`OnServerEvent` first arg is always the firing `Player`.** It's authentic — Roblox provides it. Don't trust anything else.
- Type every other parameter as `unknown` and validate at the boundary. Typing as `number` makes the type checker accept exploiter input. (See `luau-expert` skill for the full pattern.)
- **`RemoteFunction` is `:InvokeServer()` / `:InvokeClient()`** — synchronous (yields). Avoid client→server `InvokeClient` when you can; a malicious client can hang the call. Prefer `RemoteEvent` + reply event.
- **Rate-limit every Remote.** Track per-player call counts; reject obvious spam. Default: bounded queue, drop oldest or reject when full.
- **One Remote per logical action.** Don't multiplex with a `kind` field — types stay clearer and validation simpler.
---
## Character Lifecycle
```luau
local function onCharacterAdded(character: Model)
local humanoid = character:WaitForChild("Humanoid") :: Humanoid
-- ...
end
Players.PlayerAdded:Connect(function(player)
if player.Character then onCharacterAdded(player.Character) end
player.CharacterAdded:Connect(onCharacterAdded)
end)
```
- **A player's first character may already exist** when `PlayerAdded` fires. Check `player.Character` and connect to future `CharacterAdded`.
- **Each respawn is a new `Model`.** References to the old character become stale. Reconnect every per-character signal in `CharacterAdded`; use `character.AncestryChanged` or `humanoid.Died` to clean up the previous set.
- `Humanoid.Died` fires once per character. After death the model lingers (default ~5s, controlled by `Players.RespawnTime`) before automatic respawn destroys it.
- `Player.CharacterRemoving` fires before the model leaves `Workspace` — last chance to read state from it.
---
## Streaming
Modern places enable `Workspace.StreamingEnabled`. Parts of the world load and unload around each player.
- The client may not see a part the server sees. **Never assume client geometry exists** — `WaitForChild` (with timeout) or nil-check.
- Per-Model streaming is controlled via `Model.ModelStreamingMode`:
- `Default` — model streams normally, descendants stream independently.
- `Atomic` — the whole model streams in/out as a single unit (no partial loads).
- `Persistent` — the model is always loaded for every client.
- `PersistentPerPlayer` — always loaded for players added via the relevant `Add`/`Remove` API; off by default for others.
- Use `Atomic` for objects whose descendants must arrive together (vehicle assemblies, NPC rigs). Use `Persistent` sparingly — it costs memory on every client.
- Replication of property changes still occurs for streamed-out parts; the client just doesn't render them. Don't drive `BasePart.CFrame` updates on streamed-out objects — wasted bandwidth.
- See [`references/streaming-enabled.md`](references/streaming-enabled.md) for chunk semantics and `ModelStreamingMode` interactions.
---
## Physics Ownership
- Each unanchored `BasePart` has a network owner. Default: server. When a player's character touches/steps on/holds a part, ownership transfers to that player's client.
- The owner simulates physics; the result replicates back. **A client-owned part is not server-authoritative for position** — never trust its `CFrame` for game-state decisions.
- Force ownership server-side: `part:SetNetworkOwner(player)` — pass `nil` to give it back to the server. `:GetNetworkOwner()` reads it.
- **Anchored parts have no owner.** `SetNetworkOwner` errors on an anchored part. Check with `BasePart:CanSetNetworkOwnership()` first if you don't control the input.
- **Auto-transfer is the default.** Once you call `:SetNetworkOwner(player)` or `:SetNetworkOwner(nil)`, auto-transfer is **off** for that part — ownership stays where you set it. Call `:SetNetworkOwnershipAuto()` to put it back on auto.
- For anything the player must not be able to teleport (chests, NPCs, projectiles, anti-cheat-relevant parts) — `SetNetworkOwner(nil)` once, never `SetNetworkOwnershipAuto()`.
---
## Cloud Services (Persistence & Cross-Server)
Roblox offers several storage options. Pick by access pattern, not by reflex.
| Service | Persistence | Scope | Use for |
|---|---|---|---|
| **Standard data stores** (`DataStoreService:GetDataStore`) | Permanent | Cross-server | User progress, inventory, save data. Numbers/strings/booleans/tables. NoSQL-like. |
| **Ordered data stores** (`:GetOrderedDataStore`) | Permanent | Cross-server | All-time leaderboards (numbers only, sortable). |
| **Memory stores** (`MemoryStoreService`) | ≤ 45 days | Cross-server | Matchmaking, daily/monthly leaderboards, ephemeral cross-server queues. Faster than data stores. |
| **Configs** (Creator Hub) | Permanent | Cross-server | Feature flags, tunable values. Read-only from in-game. |
| **Secrets stores** | Permanent | Cross-server | API keys, third-party tokens. Read-only from in-game. |
| **In-memory Luau table** | Session | Single server | Temporary state (timers, status effects, current health). Free, instant. |
**Cloud services are server-only.** Clients can't read DataStores, MemoryStores, etc. Route any client need through a RemoteEvent.
### Data-store core rules
- **Always `pcall`.** Reads/writes throw on network failure, throttling, and quota exhaustion.
- **`UpdateAsync` for concurrent state** (not `SetAsync`) — gives you the previous value and merges atomically. Return `nil` from the transform to abort without burning quota.
- **Session-lock pattern**: read once on `PlayerAdded`, mutate in-memory for the session, save on `PlayerRemoving` + `BindToClose`. Don't read on every change.
- **`game:BindToClose`** has a ~30-second total budget across all players. Save in parallel via `task.spawn`.
- **Rate limits are per-key + per-game**, refilled over time. There's also a hard ~6-second-per-key rule.
### Memory-store core rules
- **Ephemeral**: data expires after up to 45 days. Don't put save data here.
- Useful primitives: queues, sorted maps, hash maps. Higher write throughput than data stores.
- Server-only API.
See [`references/cloud-services.md`](references/cloud-services.md) for the full picker (when to use which) and [`references/datastore-rules.md`](references/datastore-rules.md) for data-store rate limits, retry/backoff, and session-locking patterns.
Source: [create.roblox.com/docs/cloud-services/data-stores-vs-memory-stores](https://create.roblox.com/docs/cloud-services/data-stores-vs-memory-stores).
---
## Task Scheduling
The `task` library replaces classic `wait`/`spawn`/`delay`. Use it.
| Call | When |
|---|---|
| `task.spawn(f, ...)` | Run `f` on a new thread immediately (resumes after current resumption point). Use to detach a yielding body from an event handler. |
| `task.defer(f, ...)` | Run `f` after the current resumption cycle finishes — later than `spawn`. Use to coalesce work without yielding. |
| `task.delay(t, f, ...)` | Run `f` after `t` seconds on a new thread. Replaces the legacy `delay`. |
| `task.wait(t)` | Yield current thread for `t` seconds (or one frame if omitted). Replaces `wait`. |
| `task.cancel(thread)` | Kill a thread started by `task.spawn`/`task.delay`. Pair with explicit cleanup. |
| `task.synchronize()` / `task.desynchronize()` | Parallel-Luau actor coordination. See the [Parallel Luau](#parallel-luau-multithreading) section below. |
- **Don't use the legacy globals** `wait`, `spawn`, `delay`. They throttle (`wait` clamps to ~30 Hz) and leak in patterns the `task` versions don't.
- **`task.spawn` inside an event handler** lets fast-firing events run concurrently. Add your own queue/lock if the body mutates shared state.
- **`task.defer` before `task.spawn`** when you want the work to happen after the current event-handling pass — useful inside `:Connect` bodies that mutate the world and trigger more signals.
- Track threads from `task.spawn`/`task.delay` if they need cancellation; nothing auto-cancels on Instance destruction.
---
## Buffer Library (Binary Data)
`buffer` is Luau's fixed-size mutable byte block. Source: [`buffer.yaml` in creator-docs](https://github.com/Roblox/creator-docs/blob/main/content/en-us/reference/engine/libraries/buffer.yaml).
Use it for:
- Compact RemoteEvent payloads (a packed buffer is smaller than a Luau table over the wire).
- Custom binary serialization for DataStore values you want to keep small.
- Reading/writing existing binary formats.
- Replacing `string.pack` / `string.unpack` use cases.
```luau
local b = buffer.create(16) -- 16-byte buffer, zero-initialized
buffer.writeu32(b, 0, 0xCAFEBABE) -- 4 bytes at offset 0
buffer.writef32(b, 4, 1.5) -- 4 bytes at offset 4
buffer.writestring(b, 8, "abc") -- 3 bytes at offset 8
local magic = buffer.readu32(b, 0)
local payload = buffer.readstring(b, 8, 3)
```
Key rules:
- **All offsets are byte offsets from 0.** Reading/writing past the buffer raises an error.
- **Little-endian** for every numeric read/write.
- **Max buffer size: 1 GiB** (`buffer.create` rejects larger; allocations near that may fail anyway).
- **Sent across Roblox APIs (RemoteEvents, etc.) by copy.** Identity is not preserved on the receiver. Don't share a buffer between Actor scripts (Parallel Luau).
- **Fixed size.** No grow/shrink — allocate the right size, or copy into a new one (`buffer.copy`).
Functions in three groups:
- **Lifecycle:** `buffer.create(size)`, `buffer.fromstring(s)`, `buffer.tostring(b)`, `buffer.len(b)`.
- **Read/write fixed-width numerics:** `readi8`/`u8`/`i16`/`u16`/`i32`/`u32`/`f32`/`f64` and matching `write*`.
- **Bit-level + bulk:** `readbits(b, bitOffset, bitCount)` / `writebits` (0–32 bits per call), `readstring(b, offset, count)` / `writestring(b, offset, value, count?)`, `buffer.copy(target, targetOffset, source, sourceOffset?, count?)`, `buffer.fill(b, offset, value, count?)`.
Full signature reference in [`references/buffer-library.md`](references/buffer-library.md).
---
## Parallel Luau (Multithreading)
By default Luau is single-threaded. **Parallel Luau** lets independent work run on multiple OS threads via the `Actor` model. Use it for CPU-bound work that can be split: per-player raycast validation, procedural generation, large per-instance computations.
### Actor model
- Scripts under an `Actor` Instance are eligible to run in parallel.
- **One thread per actor.** Multiple scripts in the *same* actor still run serially with respect to each other.
- More actors = better load balancing, even on devices with fewer cores than actors.
- Don't nest actors as descendants of other actors — a script's owning actor is its closest ancestor.
### Phases: serial vs parallel
Code runs **serially** by default. Switch in/out:
```luau
-- Inside an Actor's script
RunService.Heartbeat:ConnectParallel(function()
-- parallel phase
local result = computeExpensive()
task.synchronize()
-- serial phase
applyResult(result)
end)
```
- **`task.desynchronize()`** — switch current thread to parallel. Yields, resumes at next parallel-execution opportunity.
- **`task.synchronize()`** — switch back to serial. Required before mutating most Instances.
- **`Signal:ConnectParallel(callback)`** — schedule a signal callback to run in parallel automatically; no need for `desynchronize` inside.
- **`require()` is not allowed in a parallel phase.** Require modules in serial first.
### Thread safety levels
API members carry a thread-safety tag:
| Level | Properties | Functions |
|---|---|---|
| **Unsafe** | No parallel read or write | No parallel call |
| **Read Parallel** | Read OK, write not | n/a |
| **Local Safe** | Same-actor any; cross-actor read-only | Same-actor only |
| **Safe** | Read + write | Call from anywhere |
Default is `Unsafe` if untagged. Most read-heavy access (geometry queries, raycasts) is safe; most mutations are not. Check the API reference per-member.
### Cross-thread communication
- **`Actor:SendMessage(topic, ...)`** + **`Actor:BindToMessage(topic, fn)`** / **`:BindToMessageParallel(topic, fn)`** — async one-way messaging. Multiple bindings per topic allowed.
- **`SharedTable`** — table-like structure shared across actors with atomic updates. No copy on send. Use for "common world state not stored in DataModel".
- **Direct DataModel writes** in serial phases work but force frequent synchronization.
### When **not** to use it
- **Long uninterrupted computations.** Even on a parallel thread, a 100ms unyielding loop still blocks that thread for 100ms. Break work up.
- **Cheap per-frame work.** Actor overhead dwarfs the savings for trivial tasks.
- **Code that mostly mutates the DataModel.** You'll spend most of your time `task.synchronize`'d, defeating the point.
Full deep-dive: [`references/parallel-luau.md`](references/parallel-luau.md). Source: [create.roblox.com/docs/scripting/multithreading](https://create.roblox.com/docs/scripting/multithreading).
---
## Performance
Performance optimization is a cycle: **design** for it up front, **identify** hot spots with the MicroProfiler, **mitigate**, **monitor**. Below is the high-signal subset; full pitfall list in [`references/performance-optimization.md`](references/performance-optimization.md).
### Frame budget
- Target: **60 FPS = 16.67 ms per frame** for the client. Server has no frame rate but `RunService.Heartbeat` plays the same role.
- Anything Luau-side that consistently runs >1 ms per frame is a candidate for optimization.
### Top mistakes
- **Heavy work on `RunService.Heartbeat`** (or `PreSimulation`/`PostSimulation`/`PreRender`/`PreAnimation`). Heartbeat fires every frame — gate by interval (`if now - last < 0.1 then return end`) or move work to a slower loop.
- **Server-side `TweenService`.** Replicates the tweened property every frame; stutters under latency. Tween on the client unless the result must be authoritative.
- **Connections to `Player` / character that never disconnect.** `Player` Instances aren't auto-destroyed on leave (see `Workspace.PlayerCharacterDestroyBehavior` to opt in to auto-destroy). Track and disconnect explicitly, or destroy manually in `PlayerRemoving`.
- **Tables that grow without bounds.** `playerInfo[player] = ...` on join without `playerInfo[player] = nil` on leave is a classic server leak.
- **Deep cloning large tables.** Use shallow copy; mutate only the path that changed (cross-link `luau-expert` skill).
- **Large RemoteEvent payloads.** Send deltas, not whole inventories. For high-frequency state, pack into a `buffer`.
- **Excessive instance creation/destruction.** Cloning large rigs at runtime replicates the entire descendant tree to clients. Pre-instance pools beat per-event instantiation.
- **Unanchored parts that don't move.** `Anchored = true` removes them from physics simulation entirely.
- **High `CollisionFidelity`.** `Precise` is expensive in CPU and memory. Use `Box`/`Hull` for anything where the player won't notice.
- **Asset duplication.** Same mesh/texture uploaded multiple times = same content in memory multiple times. Reuse asset IDs.
- **Animation metadata in published rigs.** Animation Editor data on a cloned rig replicates with every clone. Strip it before publish.
- **`PreloadAsync` over the entire Workspace** for a "no pop-in" loading screen. Preload only what the loading screen and starting area need.
- **Native code generation** (`--!native`) for hot, unyielding compute paths is a low-effort win — verify the function actually runs hot first.
### MicroProfiler scopes worth knowing
- `RunService.Heartbeat` / `PreRender` / `PreSimulation` / `PostSimulation` — your scripts.
- `physicsStepped` / `worldStep` — physics cost.
- `ProcessPackets` / `Allocate Bandwidth` — network cost.
- `updateInvalidatedFastClusters` — humanoid/skinned-mesh churn.
Tag your own scopes with `debug.profilebegin("Name")` / `debug.profileend()`.
Sources: [create.roblox.com/docs/performance-optimization](https://create.roblox.com/docs/performance-optimization), [/improve](https://create.roblox.com/docs/performance-optimization/improve).
---
## Bug Catalog
### Duplicate Connections
```luau
-- ❌ every CharacterAdded adds another connection; old ones never disconnect
Players.PlayerAdded:Connect(function(player)
player.CharacterAdded:Connect(function(char)
char.Humanoid.Died:Connect(function() ... end) -- leaks per respawn
end)
end)
```
Track and disconnect connections per scope. A common pattern: store `{ RBXScriptConnection }` keyed by the lifecycle owner (player, character, instance), iterate and `:Disconnect()` on cleanup.
### Stale References
```luau
local character = player.Character
task.wait(5)
character.Humanoid.Health = 0 -- ❌ player may have respawned; old model is destroyed
```
Re-fetch `player.Character` after any yield, or capture+verify with `if character.Parent == nil then return end`.
### Yielding in Event Handlers
```luau
RemoteEvent.OnServerEvent:Connect(function(player)
task.wait(1) -- ❌ all subsequent fires from this player wait behind this one
grantReward(player)
end)
```
Either don't yield in handlers, or `task.spawn` the body so each fire runs independently. Be careful with `task.spawn` — concurrent state updates need a lock.
### Memory Leaks
- `Instance:Destroy()` disconnects events and removes from the DataModel, but doesn't break Lua-side references. Set the variable to `nil` if it's long-lived.
- Connections to *non*-Roblox signals (custom signals, libraries) don't auto-clean. Track and disconnect them.
- Closures capturing large tables in `:Connect(...)` keep those tables alive as long as the connection exists.
### CollectionService Cleanup
```luau
-- ❌ tag handler runs once per existing tagged instance, then once per new one
CollectionService:GetTagged("Door"):forEach(setupDoor)
CollectionService:GetInstanceAddedSignal("Door"):Connect(setupDoor)
```
Always pair with `GetInstanceRemovedSignal` for cleanup. Track per-instance state in a `{ [Instance]: State }` map, not in closures. Use `instance.AncestryChanged` (when `parent == nil`) or the removed signal.
### Race: Player Joining Mid-Setup
`PlayerAdded` fires for players already in the server when your script starts (binding via `:Connect` only catches future fires) — handle existing players manually:
```luau
for _, player in Players:GetPlayers() do onPlayerAdded(player) end
Players.PlayerAdded:Connect(onPlayerAdded)
```
---
## Architecture Rules
- **Validate every server boundary.** RemoteEvent params, DataStore reads, MessagingService payloads, HttpService responses, anything user-influenced.
- **Server-authoritative checks beat client checks.** Client-side validation is UX (instant feedback). Server-side validation is security. Never one without the other.
- **One ModuleScript = one concern.** Don't aggregate into giant "Manager" modules — stack focused modules with clear ownership. (See `luau-expert` for module/file structure rules.)
- **One owner per piece of state.** A field is server-owned, replicated-only, or client-owned. Never two writers. If multiple actors must update something, route through one module that serializes the writes.
- **Centralize replication.** One module per replicated domain (inventory, currency, quest progress) — not Remotes scattered through the codebase. Easier to audit, easier to add validation/rate-limit.
- **Don't trust client-supplied UserIds.** A `UserId` you read off any real `Player` Instance (the `OnServerEvent` first arg, `Players:GetPlayers()`, `Players:GetPlayerByUserId`) is authentic. A `UserId` the client sends as a RemoteEvent argument is just a number — validate or ignore.
- **Idempotent server handlers.** Replays from network stutter or replays from exploiters are real. Design RemoteEvent handlers so two identical fires produce the same end state.
- **Expensive work goes server-side.** Pathfinding, large queries, anything CPU-heavy. Client devices vary wildly; server is uniform.
- **Don't build cross-server state on `MessagingService` alone.** It's best-effort, not guaranteed delivery, and rate-limited per-game. Use it for "interesting events" notifications, not for synchronizing state.
- **Match script container to runtime.** Server logic in `ServerScriptService`/`ServerStorage`; client logic in `StarterPlayerScripts`/`StarterCharacterScripts`/`StarterGui`; shared modules in `ReplicatedStorage`. Don't put server-only logic in `ReplicatedStorage` — clients can read it.
---
## Verification Rules
- **Don't quote API signatures from memory** for properties beyond the most stable surface (`Instance.Parent`, `BasePart.CFrame`, `Humanoid.Health`, etc.). Roblox renames, deprecates, and adds. When unsure, point the user at [create.roblox.com/docs](https://create.roblox.com/docs) and state your uncertainty.
- **Behavior beats API.** Phrase rules as "the server is authoritative for…" rather than "use `Workspace.StreamingMinRadius = 256`" — the property may be renamed; the principle won't.
- **Distinguish stable from changing.** DataModel hierarchy, services, replication direction, character lifecycle: stable for years. New APIs (e.g. recent `Workspace` properties, new `RunService` methods, scripting-related betas): verify before recommending.
- **Don't conflate engine and language.** If the question is about typing or `--!strict` behavior, defer to `luau-expert`. If it's about Rojo or filesystem layout, defer to `roblox-toolchain`. If it's about UI libraries, defer to `roblox-ui`.
---
## Anti-Patterns
| ❌ Don't | ✅ Do |
|---|---|
| `game.Workspace` / `workspace` global | `game:GetService("Workspace")` |
| `game.Players.PlayerAdded:Connect(f)` only | Iterate `:GetPlayers()` then `:Connect` for joins-during-startup |
| `child = parent:FindFirstChild("X")` on client | `:WaitForChild("X", timeout)` |
| Trust client RemoteEvent args by type annotation | Type as `unknown`, validate, narrow |
| `RemoteFunction:InvokeClient` | `RemoteEvent` round-trip |
| One Remote multiplexing many actions via `kind` field | One Remote per action |
| `SetAsync` for concurrent state | `UpdateAsync` |
| DataStore call without `pcall` | Always `pcall` + retry/backoff |
| Read DataStore on every change | Session-cached; write on PlayerRemoving + BindToClose |
| Store character ref across yields | Re-fetch `player.Character` after any yield |
| Connect-without-disconnect across respawns | Track + disconnect connections per character |
| `Instance.Parent = nil` for cleanup | `:Destroy()` |
| `WaitForChild` without timeout | `:WaitForChild("X", 5)` and handle nil |
| Client-side validation only | Always validate on server |
| Yield inside an event handler that fires often | `task.spawn` the body; serialize via your own queue |
| Auto network ownership for sensitive parts | Explicit `SetNetworkOwner(nil)` |
| `pairs` over a tag set without removed-signal pair | Pair `GetInstanceAddedSignal` with `GetInstanceRemovedSignal` |
| Server logic in `ReplicatedStorage` | `ServerScriptService` / `ServerStorage` |
| `LocalScript` in `Workspace` | Client containers (`StarterPlayerScripts`, etc.) or `Script` with `RunContext = Client` in `ReplicatedStorage` |
| `wait` / `spawn` / `delay` legacy globals | `task.wait` / `task.spawn` / `task.defer` / `task.delay` |
| Heavy work directly on `RunService.Heartbeat` | Throttle / move to slower loop / `task.delay` |
| Server-side `TweenService` for visual tweens | Tween on the client; replicate target only |
| Cloning large rigs at runtime per event | Pool / pre-instance |
| `CollisionFidelity = Precise` on small parts | `Box` / `Hull` |
| Mutating Instances inside a parallel phase | `task.synchronize()` first |
| Storing API keys in DataStores | Secrets stores |
| Save data in MemoryStore | Standard data stores |
---
## Examples
- [`examples/services-header.luau`](examples/services-header.luau) — canonical service-import block.
- [`examples/character-lifecycle.luau`](examples/character-lifecycle.luau) — joining, character add/remove, per-character cleanup.
- [`examples/remote-handler.luau`](examples/remote-handler.luau) — RemoteEvent server handler with validation and rate limiting.
- [`examples/datastore-session.luau`](examples/datastore-session.luau) — load on join, session-cached, save on remove + BindToClose.
- [`examples/connection-tracker.luau`](examples/connection-tracker.luau) — disposable scope for batched `:Disconnect()`.