How the Roblox platform behaves at runtime. Sources: create.roblox.com/docs for APIs and concepts; devforum.roblox.com for engineer clarifications and RFCs.
For deep dives see:
references/replication-model.md— what replicates from where, per-property rules, deferred-signal timingreferences/cloud-services.md— picker for data stores / memory stores / configs / secrets / in-memoryreferences/datastore-rules.md— rate limits, retry/backoff, session lockingreferences/streaming-enabled.md— chunk semantics,ModelStreamingMode, client-side rulesreferences/query-descendants.md— fullInstance:QueryDescendantsselector grammarreferences/buffer-library.md— fullbufferlibrary APIreferences/parallel-luau.md— actor model, ConnectParallel, SharedTable, thread safetyreferences/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.
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
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. Notgame.ServiceName.- Some services aren't available at script runtime by default —
GetServicecreates them; dot-indexing throws. - Some services don't share their class name with their accessor (e.g.
RunServiceis internally "Run Service").
- Some services aren't available at script runtime by default —
- Prefer
WorkspaceviaGetServiceover the lowercaseworkspaceglobal. Both work; the global predatesGetServiceand exists for compatibility. Sticking toGetServicekeeps every service access one consistent shape and surfaces typos as undefined-variable errors. - Put
GetServicecalls 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 = nilAND no script holds a reference. SettingParent = nilis not destruction. Use:Destroy()to disconnect events, mark it as destroyed, and setParentto nil. :Destroy()is irreversible. A destroyed Instance can't be re-parented; further property access throws.:Clone()requiresArchivable = 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 overGetDescendants+ Luau-side filter for large trees. Thread-unsafe. Full grammar + examples inreferences/query-descendants.md.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;
:FindFirstChildis enough. - Never call
WaitForChildwithout a timeout in production code — it yields forever if the child never arrives.:WaitForChild("Name", 5)returnsnilafter 5 seconds.
Script Types & Locations
Three script types:
Script— runs on server or client depending onScript.RunContextand location.RunContext = Legacy(default) → server-only, only runs in server containers (ServerScriptService,Workspace, etc.).RunContext = Server→ server, can run fromReplicatedStoragetoo (but don't — clients can read source).RunContext = Client→ client, runs fromReplicatedStorage. Recommended for new client scripts (clearer thanLocalScript).
LocalScript— client-only. NoRunContext. 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 |
LocalScripts that run per-player |
n/a |
Workspace |
World content. Server-side Scripts 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
RunContextexplicitly 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 fromscript.Parent. LocalScripts inWorkspacedon't run. Client-runnable containers only. Common bug.- Server scripts in
Workspacerun, butRunContext = ServerinServerScriptServiceis clearer and less surprising when geometry moves.
Source: 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 explicitRemoteEventcalls. - Replication is per-property, not all-or-nothing. Some containers exist on both sides but their contents are client-local (
Player.PlayerScripts,Player.PlayerGuiafter initial replication ofStarterGui). - 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-fireChildAddedis brittle. Test with deferred signals on. - See
references/replication-model.mdfor the full table.
RemoteEvents & RemoteFunctions
-- 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)
OnServerEventfirst arg is always the firingPlayer. It's authentic — Roblox provides it. Don't trust anything else.- Type every other parameter as
unknownand validate at the boundary. Typing asnumbermakes the type checker accept exploiter input. (Seeluau-expertskill for the full pattern.) RemoteFunctionis:InvokeServer()/:InvokeClient()— synchronous (yields). Avoid client→serverInvokeClientwhen you can; a malicious client can hang the call. PreferRemoteEvent+ 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
kindfield — types stay clearer and validation simpler.
Character Lifecycle
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
PlayerAddedfires. Checkplayer.Characterand connect to futureCharacterAdded. - Each respawn is a new
Model. References to the old character become stale. Reconnect every per-character signal inCharacterAdded; usecharacter.AncestryChangedorhumanoid.Diedto clean up the previous set. Humanoid.Diedfires once per character. After death the model lingers (default ~5s, controlled byPlayers.RespawnTime) before automatic respawn destroys it.Player.CharacterRemovingfires before the model leavesWorkspace— 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 relevantAdd/RemoveAPI; off by default for others.
- Use
Atomicfor objects whose descendants must arrive together (vehicle assemblies, NPC rigs). UsePersistentsparingly — 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.CFrameupdates on streamed-out objects — wasted bandwidth. - See
references/streaming-enabled.mdfor chunk semantics andModelStreamingModeinteractions.
Physics Ownership
- Each unanchored
BaseParthas 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
CFramefor game-state decisions. - Force ownership server-side:
part:SetNetworkOwner(player)— passnilto give it back to the server.:GetNetworkOwner()reads it. - Anchored parts have no owner.
SetNetworkOwnererrors on an anchored part. Check withBasePart: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, neverSetNetworkOwnershipAuto().
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. UpdateAsyncfor concurrent state (notSetAsync) — gives you the previous value and merges atomically. Returnnilfrom the transform to abort without burning quota.- Session-lock pattern: read once on
PlayerAdded, mutate in-memory for the session, save onPlayerRemoving+BindToClose. Don't read on every change. game:BindToClosehas a ~30-second total budget across all players. Save in parallel viatask.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 for the full picker (when to use which) and 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.
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 section below. |
- Don't use the legacy globals
wait,spawn,delay. They throttle (waitclamps to ~30 Hz) and leak in patterns thetaskversions don't. task.spawninside an event handler lets fast-firing events run concurrently. Add your own queue/lock if the body mutates shared state.task.deferbeforetask.spawnwhen you want the work to happen after the current event-handling pass — useful inside:Connectbodies that mutate the world and trigger more signals.- Track threads from
task.spawn/task.delayif 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.
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.unpackuse cases.
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.createrejects 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/f64and matchingwrite*. - 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.
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
ActorInstance 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:
-- 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 fordesynchronizeinside.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. Source: 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.
Frame budget
- Target: 60 FPS = 16.67 ms per frame for the client. Server has no frame rate but
RunService.Heartbeatplays 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(orPreSimulation/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.PlayerInstances aren't auto-destroyed on leave (seeWorkspace.PlayerCharacterDestroyBehaviorto opt in to auto-destroy). Track and disconnect explicitly, or destroy manually inPlayerRemoving. - Tables that grow without bounds.
playerInfo[player] = ...on join withoutplayerInfo[player] = nilon leave is a classic server leak. - Deep cloning large tables. Use shallow copy; mutate only the path that changed (cross-link
luau-expertskill). - 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 = trueremoves them from physics simulation entirely. - High
CollisionFidelity.Preciseis expensive in CPU and memory. UseBox/Hullfor 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.
PreloadAsyncover 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, /improve.
Bug Catalog
Duplicate Connections
-- ❌ 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
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
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 tonilif 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
-- ❌ 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:
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-expertfor 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
UserIdyou read off any realPlayerInstance (theOnServerEventfirst arg,Players:GetPlayers(),Players:GetPlayerByUserId) is authentic. AUserIdthe 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
MessagingServicealone. 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 inStarterPlayerScripts/StarterCharacterScripts/StarterGui; shared modules inReplicatedStorage. Don't put server-only logic inReplicatedStorage— 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 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
Workspaceproperties, newRunServicemethods, scripting-related betas): verify before recommending. - Don't conflate engine and language. If the question is about typing or
--!strictbehavior, defer toluau-expert. If it's about Rojo or filesystem layout, defer toroblox-toolchain. If it's about UI libraries, defer toroblox-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— canonical service-import block.examples/character-lifecycle.luau— joining, character add/remove, per-character cleanup.examples/remote-handler.luau— RemoteEvent server handler with validation and rate limiting.examples/datastore-session.luau— load on join, session-cached, save on remove + BindToClose.examples/connection-tracker.luau— disposable scope for batched:Disconnect().