references/common-bugs-and-fixes.md
---
read_when: "Trace a symptom to likely causes and a focused fix"
last_reviewed: 2026-06-17
---
# Common bugs and fixes
## Script silently fails
Causes:
- Error before any output; check Developer Console for errors.
- Script in wrong context (server vs client).
- Event handler disconnected or never connected.
- Infinite yield from `WaitForChild` with no timeout.
Fixes:
- Use `instance:WaitForChild("Name", timeout)`.
- Add `print` at key points to trace execution.
- Check script `RunContext` and parent location.
- Use `xpcall` with `debug.traceback` at entry points to capture full stack traces.
## Data not saving
Causes:
- DataStore call from client/LocalScript.
- Non-serializable value (function, metatable, cycle, inf/nan).
- Missing `pcall` around call.
- Studio backend access disabled for a test experience, or enabled against production data.
Fixes:
- Always call DataStores from server `Script`.
- Test serialization with `HttpService:JSONEncode`.
- Wrap every call in `pcall`.
- Do not let Studio sessions access production backend data; use a separate test experience.
## Remote events not working
Causes:
- Handler on wrong side.
- Firing before handler is connected.
- Argument types differ from handler signature.
- Remote instance not replicated yet.
Fixes:
- Define Remotes in `ReplicatedStorage`.
- Connect handlers early.
- Validate argument count and types.
## Lag spikes
Causes:
- Pathfinding every frame.
- Updating UI every frame without throttling.
- Unbatched DataStore operations.
- Spawning many effects/instances at once.
Fixes:
- Throttle expensive work.
- Batch changes.
- Use object pools.
- Profile with MicroProfiler.
## Physics jitter
Causes:
- Conflicting constraints.
- Wrong network ownership.
- Assembly splits from anchoring.
- Parts fighting each other with forces.
Fixes:
- Visualize constraints and ownership.
- Assign vehicle ownership to driver.
- Anchor only root parts of assemblies.
- Tune force limits.
## Memory leaks
Causes:
- `Heartbeat`/`Stepped` connections not disconnected.
- Tables caching instances after removal.
- Animation/Sound objects not cleaned up.
- Circular references with metatables.
Fixes:
- Disconnect connections in cleanup functions.
- Use `Instance.Destroying` or `AncestryChanged` to trigger cleanup.
- Check Scene Analysis → Unparented instances.
- Compare Luau heap snapshots.
## UI not updating
Causes:
- Property change not replicating.
- Layout not recalculating.
- Wrong parent or ZIndex.
- LocalScript not running in expected context.
Fixes:
- Set properties from the correct side.
- Force layout updates with size/position changes.
- Check StarterGui → PlayerGui cloning.
## Pathfinding failures
Causes:
- Agent parameters incompatible with destination.
- Destination beyond 3,000 studs or vertical limits.
- Dynamic obstacle blocking path.
- Node budget exhausted in complex world.
Fixes:
- Validate parameters.
- Recompute on `path.Blocked`.
- Break long paths into segments.
- Simplify complex geometry.
## Leaked event connections
Causes:
- `Heartbeat`/`Stepped` connections never disconnected.
- One-shot connections left connected after firing.
- Cleanup code unreachable on error paths.
Fixes:
- Use a Maid/Janitor-style collector.
- Use `:Once()` for one-shot handlers.
- Disconnect in `Instance.Destroying` or `AncestryChanged` handlers.
## spawn/delay misuse
Causes:
- Legacy `spawn`/`delay`/`wait` resume unpredictably and can defer indefinitely.
Fixes:
- Replace `spawn(fn)` with `task.defer(fn)` or `task.spawn(fn)`.
- Replace `delay(t, fn)` with `task.delay(t, fn)`.
- Replace `wait(t)` with `task.wait(t)`.
## Log spam and leaked secrets
Causes:
- Printing every frame or every event.
- Logging raw player data, identifiers, or API keys.
Fixes:
- Rate-limit logs with a time gate or counter.
- Sanitize values before logging.
- Never commit secrets; load them from secure configuration.
## DataStore throttling and failures
Causes:
- Missing retry on transient errors.
- Calling DataStores from the client.
- Saving non-serializable values.
Fixes:
- Wrap calls with exponential backoff and jitter.
- Validate JSON serialization before saving.
- Always call DataStores from a server `Script`.
## Network ownership issues
Causes:
- Server simulating objects that a player should control.
- Frequent ownership changes.
Fixes:
- Use `BasePart:SetNetworkOwner(player)` for vehicles and held items.
- Visualize ownership in debug builds with `GetNetworkOwner()`.
- Minimize ownership churn.
## Memory snapshot comparison
For a repeatable leak-finding workflow, compare snapshots:
1. Capture a baseline in a stable state.
2. Perform the suspected leak action repeatedly.
3. Return to the stable state and capture again.
4. Compare PlaceMemory, Luau heap, and instance counts.
5. Check Scene Analysis → Unparented instances.
references/debugging-tools.md
---
read_when: "Inspect logs, breakpoints, stack traces, or runtime state"
last_reviewed: 2026-06-17
---
# Debugging tools
Official guides:
- https://create.roblox.com/docs/en-us/studio/developer-console
- https://create.roblox.com/docs/en-us/performance-optimization/microprofiler
- https://create.roblox.com/docs/en-us/performance-optimization/scene-analysis
## Developer Console
Shortcuts:
- `F9`: open console.
- `/console`: in chat.
### Log tab
Filter by Output, Information, Warning, Error.
Toggle Client/Server to see which side produced output.
### Memory tab
- **PlaceMemory**: breakdown by assets and engine systems.
- **Luau heap**: detailed script allocation snapshots.
### Network tab
- Summary of HTTP and service requests.
- Per-request status, time, URL, response.
### Server Stats tab
- Heartbeat steps per second.
- Average ping/data ping.
## Output window
Studio-only output. Shows print/warn/error from edit-mode scripts.
## MicroProfiler
Shortcuts:
- `Ctrl+F6` (`⌘+F6`): open.
- `Ctrl+P` (`⌘+P`): pause.
Workflow:
1. Identify a frame-time spike.
2. Zoom into the timeline.
3. Find the widest task bar.
4. Cross-reference with the tag table.
5. Add `debug.profilebegin`/`debug.profileend` around suspicious script code.
Custom profiling:
```lua
debug.profilebegin("HeavyLoop")
-- expensive work
debug.profileend()
```
## Scene Analysis
Available in Studio under **Window → Performance Summary → Scene Analysis**.
Useful views:
- **Script memory**: which scripts allocate the most.
- **Unparented instances**: references held by scripts after instances are removed.
- **Instance composition**: total instance counts.
- **Audio/Animation memory**: asset retention.
- **Triangle composition**: rendering cost breakdown.
Scene Analysis is a Studio UI tool only; there is no public runtime API for it.
## Script Profiler
Records per-script CPU cost. Good for finding expensive scripts without manual labeling.
## Network simulation
Studio: `Alt+S` or **Test → Network Simulation**.
Simulate latency, jitter, and packet loss to reproduce multiplayer issues locally.
## Testing modes
- **Play**: local client+server.
- **Play Here**: spawn at camera.
- **Multi-client simulation**: test ownership and replication with multiple local players.
## Studio Debugger
Use Studio's built-in debugger for step-through debugging:
- Set breakpoints by clicking the gutter next to a line number.
- Run in **Play** mode and use the debugger controls to step over, into, or out.
- Inspect the call stack, local variables, and upvalues in the debugger panel.
- The debugger works in both client and server contexts when Studio runs both.
## Luau type checking
Use the Luau language server (`luau-lsp`) or Studio's Luau type checker to catch errors before runtime.
- Add type annotations to module exports and pure functions.
- Enable `--!strict` for new modules.
- Run `luau-lsp analyze` in CI when available.
## xpcall and debug.traceback
Use `xpcall` with `debug.traceback` to capture full stack traces from failures:
```lua
local ok, err = xpcall(riskyOperation, debug.traceback)
if not ok then
warn(err)
end
```
Prefer `xpcall` at top-level entry points, scheduled callbacks, and connection handlers.
## Visual debugging
Use `DebugDraw` (see scripts) or temporary `Part` instances to visualize:
- Raycast hits
- Hitboxes
- Path waypoints
- Sensor ranges
- Network ownership boundaries
Always clean up debug visuals in production.
references/network-simulation.md
---
read_when: "Reproduce latency, jitter, or packet-loss problems in Studio"
last_reviewed: 2026-09-19
---
# Studio network simulation
Official source: https://create.roblox.com/docs/studio/network-simulator
Network Simulator is a Studio beta that affects playtests, not live players. Enable **New Device Simulator** under **File → Beta Features**, restart Studio, and open **Test → Device Simulator → Network**.
- Inbound means server to client; outbound means client to server. Configure latency, jitter, and packet loss independently for each direction.
- Preset selection and numeric edits are staged until **Apply**. Applying updates a running playtest immediately.
- The values add to existing conditions, including real internet latency in Team Test. Inbound and outbound delays both contribute to round-trip ping.
- **Ideal Fiber** adds 8 ms per direction. Set all six controls to zero and apply for a zero-added-impairment comparison. **Reset** stages Ideal Fiber; it does not disable simulation.
- Packet loss is a percentage: `0.50%` means half a percent, not 50%.
- Saved custom presets persist across sessions, but applied values reset after a full Studio restart.
Repeat the same server-confirmed interaction under baseline, Wi-Fi/mobile, and poor-connection conditions. Check immediate local feedback, duplicate submissions, timeouts, stale unreliable updates, and recovery. Change one direction at a time to isolate request versus response delays. Record the preset, playtest mode, and observed result so comparisons remain reproducible.
The toolbar uses the same `NetworkSettings` properties as Studio Settings: `InboundNetworkMinDelayMs`, `OutboundNetworkMinDelayMs`, `InboundNetworkJitterMs`, `OutboundNetworkJitterMs`, `InboundNetworkLossPercent`, and `OutboundNetworkLossPercent`. Consult their security requirements before using them in plugin automation.
references/performance-profiling.md
---
read_when: "Find CPU, GPU, memory, or load-time bottlenecks"
last_reviewed: 2026-06-17
---
# Performance profiling
Official guide: https://create.roblox.com/docs/en-us/performance-optimization/identify
## Frame time goals
| FPS | Frame time |
| --- | --- |
| 30 | 33.33 ms |
| 60 | 16.67 ms |
| 120 | 8.33 ms |
| 240 | 4.17 ms |
Consistent frame times matter more than average FPS.
## Server heartbeat
Capped at 60 FPS. Check in Developer Console → Server Stats → Heartbeat → Steps Per Sec.
If below 60, use MicroProfiler on the server to find the bottleneck.
## Client frame rate
Use `Shift+F5` in-game for debug stats, or test on a real mobile device.
High-end PCs can hide performance problems due to thermal/power headroom.
## MicroProfiler colors
- **Orange**: worker thread bottleneck (scripts, physics, pathfinding, animations).
- **Blue**: render thread bottleneck (geometry, effects, UI).
- **Red**: GPU wait (complex geometry, large textures, overdrawing).
## Server profiling
1. Join a live or test game with edit permissions.
2. Open Developer Console (`F9`).
3. Switch to **MicroProfiler** tab.
4. Choose Server, set frames and delay.
5. Click **Begin server recording**.
6. Open the saved HTML dump.
## Common bottlenecks
| Area | Typical causes |
| --- | --- |
| Scripts | Loops every frame, unbatched datastore calls, expensive string/table ops |
| Physics | Too many unanchored parts, conflicting constraints, no sleep |
| Rendering | Too many transparent parts, particles, lights, high-poly meshes |
| Memory | Unloaded assets, leaked instances, growing tables |
| Network | Excessive remote traffic, large replication payloads |
## Optimization workflow
1. Measure with MicroProfiler/Scene Analysis.
2. Identify the biggest single cost.
3. Change one thing.
4. Re-measure.
5. Repeat.
## Mobile profiling
1. Open MicroProfiler on mobile from Settings.
2. Note the IP:port displayed.
3. On a dev machine on the same network, open that IP:port in a browser.
4. Capture frames and analyze in the web UI.
## Counters mode and flame graphs
The web MicroProfiler supports:
- **Flame graphs**: aggregated call stacks.
- **Diff flame graphs**: compare two dumps.
- **X-Ray memory**: highlight allocation-heavy frames.
Use these for regressions and long-term tracking.
## Memory snapshot comparison workflow
Use the Developer Console Memory tab and Scene Analysis to find leaks:
1. Enter a stable baseline state (e.g., standing in lobby with no UI open).
2. Capture snapshot A from Developer Console → Memory or Scene Analysis.
3. Perform the suspected leak action (open/close UI, spawn/despawn enemies).
4. Return to the baseline state.
5. Capture snapshot B.
6. Compare:
- Total PlaceMemory
- Luau heap by script
- Instance counts by category
- Unparented instances in Scene Analysis
7. Investigate categories that did not return to baseline.
references/testing-patterns.md
---
read_when: "Write unit tests, mock dependencies, or test client-server flows"
last_reviewed: 2026-06-17
---
# Testing patterns
Official guide: https://roblox.github.io/testez/
## TestEZ
The standard Roblox testing framework is [TestEZ](https://github.com/Roblox/testez). It supports nested `describe`/`it` blocks, lifecycle hooks, async tests, and a rich matcher API.
```lua
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ)
TestEZ.TestBootstrap:run({ game.ReplicatedStorage.Tests })
```
## When to write tests
- Pure utility functions (math, formatting, validation).
- Data transformation and serialization logic.
- State machines with deterministic transitions.
- Anything that has broken before and must not break again.
## Test structure with TestEZ
```lua
describe("MathUtils", function()
local clamp
beforeEach(function()
clamp = require(ReplicatedStorage.MathUtils).clamp
end)
it("clamps values", function()
expect(clamp(5, 0, 10)).to.equal(5)
expect(clamp(-1, 0, 10)).to.equal(0)
expect(clamp(11, 0, 10)).to.equal(10)
end)
end)
```
TestEZ matchers include `to.equal`, `to.be.near`, `to.be.a`, `to.throw`, `to.be.ok`, `to.be.truthy`, and more.
## Example async test
```lua
it("loads data asynchronously", function()
return expect(Promise.resolve(42)).to.equal(42)
end)
```
For tests that return a Promise, TestEZ waits for resolution. See TestEZ documentation for the exact async API.
## Integration vs unit tests
- **Unit tests** run fast and don't need a running game.
- **Integration tests** require services, instances, or network state. Run them in Studio play mode.
## TestService (legacy)
`TestService:Error(msg)` marks a test failure visibly in Studio output. It is useful for simple in-Studio checks, but new projects should prefer TestEZ.
```lua
local TestService = game:GetService("TestService")
local ok, err = pcall(someTest)
if not ok then
TestService:Error(err)
end
```
## Running tests
- Place TestEZ test scripts under `ReplicatedStorage.Tests` or another discoverable folder.
- Use `TestEZ.TestBootstrap:run({ folder })` from a single entry point.
- For CI-style testing, collect all test modules and run them with a single runner.
- The scripts/TestRunner.lua module is a minimal shim for environments where TestEZ is unavailable.
## Good test habits
- One assertion per behavior.
- Test edge cases (empty tables, nil, huge values).
- Keep tests deterministic; avoid randomness.
- Clean up any instances created during tests.
- Use `beforeEach`/`afterEach` for shared setup and teardown.
- Mock engine services for unit tests instead of relying on the live data model.
scripts/DebugDraw.lua
--!strict
-- Status: experimental
-- Last verified: 2026-06-17
-- Test coverage: no automated coverage
-- Intended use: example; adapt and test before production.
--[[
DebugDraw.lua
Utility for drawing temporary debug visuals in the 3D world.
Usage:
local DebugDraw = require(path.to.DebugDraw)
DebugDraw.point(workspace, Vector3.new(0, 10, 0), Color3.fromRGB(255, 0, 0), 2)
DebugDraw.ray(workspace, origin, direction, Color3.fromRGB(0, 255, 0), 2)
DebugDraw.box(workspace, cframe, size, Color3.fromRGB(0, 0, 255), 2)
DebugDraw.clear() -- removes all tracked debug parts
]]
local DebugDraw = {}
local registry = {}
local function validateParent(parent)
assert(typeof(parent) == "Instance", "parent must be an Instance")
end
local function createBase()
local part = Instance.new("Part")
part.Anchored = true
part.CanCollide = false
part.CanQuery = false
part.CanTouch = false
part.Material = Enum.Material.SmoothPlastic
return part
end
local function track(part, lifetime)
table.insert(registry, part)
if lifetime then
task.delay(lifetime, function()
if part.Parent ~= nil then
part:Destroy()
end
for i = #registry, 1, -1 do
if registry[i] == part then
table.remove(registry, i)
break
end
end
end)
end
end
function DebugDraw.point(parent, position, color, lifetime)
validateParent(parent)
local part = createBase()
part.Shape = Enum.PartType.Ball
part.Size = Vector3.new(0.5, 0.5, 0.5)
part.CFrame = CFrame.new(position)
part.Color = color or Color3.fromRGB(255, 0, 0)
part.Parent = parent
track(part, lifetime)
return part
end
function DebugDraw.ray(parent, origin, direction, color, lifetime)
validateParent(parent)
local distance = direction.Magnitude
assert(distance > 0, "direction must be non-zero")
local part = createBase()
part.Size = Vector3.new(0.1, 0.1, distance)
part.CFrame = CFrame.lookAt(origin, origin + direction) * CFrame.new(0, 0, -distance / 2)
part.Color = color or Color3.fromRGB(0, 255, 0)
part.Parent = parent
track(part, lifetime)
return part
end
function DebugDraw.box(parent, cframe, size, color, lifetime)
validateParent(parent)
local part = createBase()
part.Size = size
part.CFrame = cframe
part.Transparency = 0.7
part.Color = color or Color3.fromRGB(0, 0, 255)
part.Parent = parent
track(part, lifetime)
return part
end
function DebugDraw.clear()
for i = #registry, 1, -1 do
local part = registry[i]
if part and part.Parent ~= nil then
part:Destroy()
end
registry[i] = nil
end
end
return DebugDraw
scripts/Logger.lua
--!strict
-- Status: experimental
-- Last verified: 2026-06-17
-- Test coverage: no automated coverage
-- Intended use: example; adapt and test before production.
--[[
Logger.lua
A simple structured logger with level filtering.
Usage:
local Logger = require(path.to.Logger)
local log = Logger.new("InventorySystem", Logger.Levels.Warn)
log:info("Loaded")
log:warn("Missing item %d", 123)
log:error("Failed to save: %s", err)
Notes:
- log:error halts execution by calling Lua's error() function.
- Contextual fields are not supported by this minimal logger.
Include any context directly in the message or use a structured
logging library for field-based output.
]]
local Logger = {}
Logger.Levels = {
Debug = 1,
Info = 2,
Warn = 3,
Error = 4,
}
Logger.__index = Logger
function Logger.new(name: string?, minLevel: number?)
local self = setmetatable({}, Logger)
self.name = name or "Logger"
self.minLevel = minLevel or Logger.Levels.Info
return self
end
function Logger:_log(levelName: string, levelValue: number, fmt: string, ...: any)
if levelValue < self.minLevel then
return
end
local ok, msg = pcall(string.format, fmt, ...)
if not ok then
msg = "[bad format string: " .. tostring(fmt) .. "]"
end
local output = string.format("[%s][%s] %s", self.name, levelName, msg)
if levelValue >= Logger.Levels.Error then
error(output, 3)
elseif levelValue >= Logger.Levels.Warn then
warn(output)
else
print(output)
end
end
function Logger:debug(fmt: string, ...: any)
self:_log("DEBUG", Logger.Levels.Debug, fmt, ...)
end
function Logger:info(fmt: string, ...: any)
self:_log("INFO", Logger.Levels.Info, fmt, ...)
end
function Logger:warn(fmt: string, ...: any)
self:_log("WARN", Logger.Levels.Warn, fmt, ...)
end
function Logger:error(fmt: string, ...: any)
self:_log("ERROR", Logger.Levels.Error, fmt, ...)
end
return Logger
scripts/TestRunner.lua
--!strict
-- Status: experimental
-- Last verified: 2026-06-17
-- Test coverage: no automated coverage
-- Intended use: example; adapt and test before production.
--[[
TestRunner.lua
A minimal in-Studio test runner shim.
This is NOT a replacement for TestEZ, which is the standard Roblox testing
framework. Use TestEZ for real projects. This module is a small fallback
for environments where TestEZ is not available.
Usage:
local TestRunner = require(path.to.TestRunner)
local runner = TestRunner.new()
runner:describe("Math", function()
runner:it("adds numbers", function()
runner:expect(1 + 1).toBe(2)
end)
end)
runner:run()
]]
local TestService = game:GetService("TestService")
local TestRunner = {}
TestRunner.__index = TestRunner
export type TestRunnerOptions = {
useTestService: boolean?,
timeout: number?,
}
type Hook = () -> ()
type TestCase = {
name: string,
fn: () -> any,
}
type Suite = {
name: string,
tests: { TestCase },
suites: { Suite },
parent: Suite?,
beforeEach: { Hook },
afterEach: { Hook },
}
type TestResults = {
total: number,
passed: number,
failed: number,
errors: { string },
}
type PromiseLike = {
andThen: (
self: PromiseLike,
onResolve: (...any) -> (),
onReject: (any) -> ()
) -> any,
}
export type TestRunner = typeof(setmetatable(
{} :: {
suites: { Suite },
stack: { Suite },
hooks: { beforeEach: { Hook }, afterEach: { Hook } },
useTestService: boolean,
timeout: number,
},
TestRunner
))
function TestRunner.new(options: TestRunnerOptions?): TestRunner
local resolvedOptions: TestRunnerOptions = options or {}
local self = setmetatable({}, TestRunner) :: TestRunner
self.suites = {}
self.stack = {}
self.hooks = { beforeEach = {}, afterEach = {} }
self.useTestService = resolvedOptions.useTestService ~= false
self.timeout = resolvedOptions.timeout or 5
return self
end
function TestRunner._currentSuite(self: TestRunner): Suite?
return self.stack[#self.stack]
end
function TestRunner.describe(self: TestRunner, name: string, fn: () -> ())
local parent = self:_currentSuite()
local suite: Suite = {
name = name,
tests = {},
suites = {},
parent = parent,
beforeEach = {},
afterEach = {},
}
if parent then
table.insert(parent.suites, suite)
else
table.insert(self.suites, suite)
end
table.insert(self.stack, suite)
fn()
table.remove(self.stack)
end
function TestRunner.beforeEach(self: TestRunner, fn: Hook)
local suite = self:_currentSuite()
assert(suite, "beforeEach() must be called inside describe()")
table.insert(suite.beforeEach, fn)
end
function TestRunner.afterEach(self: TestRunner, fn: Hook)
local suite = self:_currentSuite()
assert(suite, "afterEach() must be called inside describe()")
table.insert(suite.afterEach, fn)
end
function TestRunner.it(self: TestRunner, name: string, fn: () -> any)
local suite = self:_currentSuite()
assert(suite, "it() must be called inside describe()")
table.insert(suite.tests, { name = name, fn = fn })
end
local function deepEqual(a: any, b: any): (boolean, any?)
if a == b then
return true, nil
end
if typeof(a) ~= "table" or typeof(b) ~= "table" then
return false, nil
end
for k, v in pairs(a) do
if not deepEqual(v, b[k]) then
return false, k
end
end
for k, _ in pairs(b) do
if a[k] == nil then
return false, k
end
end
return true, nil
end
local function isPromiseLike(obj: any): boolean
return typeof(obj) == "table" and typeof(obj.andThen) == "function"
end
function TestRunner.expect(_self: TestRunner, value: any)
local matchers = {}
function matchers.toBe(expected: any)
assert(
value == expected,
string.format("expected %s, got %s", tostring(expected), tostring(value))
)
end
function matchers.toEqual(expected: any)
local ok, key = deepEqual(value, expected)
if not ok then
if key ~= nil then
assert(false, string.format("deep equality mismatch at key %s", tostring(key)))
else
assert(
false,
string.format("expected %s, got %s", tostring(expected), tostring(value))
)
end
end
end
function matchers.toBeTruthy()
assert(value, "expected truthy value")
end
function matchers.toBeNil()
assert(value == nil, "expected nil")
end
function matchers.toBeType(typeName: string)
assert(
typeof(value) == typeName,
string.format("expected type %s, got %s", typeName, typeof(value))
)
end
function matchers.toBeGreaterThan(threshold: number)
assert(typeof(value) == "number", "value must be a number")
assert(
value > threshold,
string.format("expected value > %s, got %s", tostring(threshold), tostring(value))
)
end
function matchers.toBeGreaterThanOrEqual(threshold: number)
assert(typeof(value) == "number", "value must be a number")
assert(
value >= threshold,
string.format("expected value >= %s, got %s", tostring(threshold), tostring(value))
)
end
function matchers.toBeLessThan(threshold: number)
assert(typeof(value) == "number", "value must be a number")
assert(
value < threshold,
string.format("expected value < %s, got %s", tostring(threshold), tostring(value))
)
end
function matchers.toBeLessThanOrEqual(threshold: number)
assert(typeof(value) == "number", "value must be a number")
assert(
value <= threshold,
string.format("expected value <= %s, got %s", tostring(threshold), tostring(value))
)
end
function matchers.toBeCloseTo(expected: number, tolerance: number?)
local resolvedTolerance = tolerance or 0.00001
assert(typeof(value) == "number" and typeof(expected) == "number", "values must be numbers")
assert(
math.abs(value - expected) <= resolvedTolerance,
string.format(
"expected %s to be close to %s (tolerance %s)",
tostring(value),
tostring(expected),
tostring(resolvedTolerance)
)
)
end
function matchers.toThrow(expectedPattern: string?)
assert(typeof(value) == "function", "value must be a function")
local ok, err = pcall(value :: () -> any)
assert(not ok, "expected function to throw")
if expectedPattern then
local errStr = tostring(err)
assert(
string.find(errStr, expectedPattern, 1, true),
string.format("expected error to contain %q, got %q", expectedPattern, errStr)
)
end
end
return matchers
end
function TestRunner._runHooks(_self: TestRunner, hooksList: { Hook })
for _, fn in ipairs(hooksList) do
fn()
end
end
function TestRunner._collectHooks(
_self: TestRunner,
suite: Suite,
kind: "beforeEach" | "afterEach"
): { Hook }
local collected: { Hook } = {}
local current: Suite? = suite
while current do
local hooks = if kind == "beforeEach" then current.beforeEach else current.afterEach
for _, fn in ipairs(hooks) do
table.insert(collected, fn)
end
current = current.parent
end
if kind == "beforeEach" then
local reversed: { Hook } = {}
for i = #collected, 1, -1 do
table.insert(reversed, collected[i])
end
return reversed
end
return collected
end
function TestRunner._runTest(self: TestRunner, test: TestCase, suite: Suite): (boolean, any?)
local beforeHooks = self:_collectHooks(suite, "beforeEach")
local afterHooks = self:_collectHooks(suite, "afterEach")
local ok, err = pcall(function()
self:_runHooks(beforeHooks)
end)
if not ok then
return false, "beforeEach failed: " .. tostring(err)
end
local testOk, testResult: any = pcall(test.fn)
ok = testOk
err = testResult
if ok then
if isPromiseLike(testResult) then
local resolved = false
local promiseErr: any = nil
local promise = testResult :: PromiseLike
promise:andThen(function()
resolved = true
end, function(e)
resolved = true
promiseErr = e
end)
local start = os.clock()
while not resolved and os.clock() - start < self.timeout do
task.wait(0.05)
end
if not resolved then
ok = false
testResult = "async test timed out"
elseif promiseErr ~= nil then
ok = false
testResult = promiseErr
end
end
end
err = testResult
local afterOk, afterErr = pcall(function()
self:_runHooks(afterHooks)
end)
if not afterOk then
local prefix = err and (tostring(err) .. "; ") or ""
ok = false
err = prefix .. "afterEach failed: " .. tostring(afterErr)
end
return ok, err
end
function TestRunner._runSuite(self: TestRunner, suite: Suite, indent: string?, results: TestResults)
local resolvedIndent = indent or ""
print(resolvedIndent .. "Suite: " .. suite.name)
for _, test in ipairs(suite.tests) do
results.total += 1
local ok, err = self:_runTest(test, suite)
if ok then
results.passed += 1
print(resolvedIndent .. " [PASS] " .. test.name)
else
results.failed += 1
local msg = resolvedIndent
.. "[FAIL] "
.. suite.name
.. " > "
.. test.name
.. ": "
.. tostring(err)
warn(msg)
table.insert(results.errors, msg)
if self.useTestService then
pcall(function()
TestService:Error(msg)
end)
end
end
end
for _, child in ipairs(suite.suites) do
self:_runSuite(child, resolvedIndent .. " ", results)
end
end
function TestRunner.run(self: TestRunner): (boolean, TestResults)
local results: TestResults = { total = 0, passed = 0, failed = 0, errors = {} }
for _, suite in ipairs(self.suites) do
self:_runSuite(suite, "", results)
end
print(
string.format(
"\nResults: %d total, %d passed, %d failed",
results.total,
results.passed,
results.failed
)
)
if results.failed > 0 then
warn("Failed tests:")
for _, err in ipairs(results.errors) do
warn(" - " .. err)
end
end
return results.failed == 0, results
end
return TestRunner
SKILL.md
---
name: roblox-testing
description: "Test, debug, or profile Roblox experiences. Use for Studio debugging, TestEZ, logging, connection cleanup, memory or network diagnostics, MicroProfiler, Scene Analysis, and Script Profiler."
last_reviewed: 2026-10-21
---
# roblox-testing
**Official sources:**
- https://create.roblox.com/docs/en-us/studio/developer-console
- https://create.roblox.com/docs/en-us/performance-optimization/identify
- https://create.roblox.com/docs/en-us/performance-optimization/microprofiler
- https://create.roblox.com/docs/en-us/performance-optimization/scene-analysis
- https://create.roblox.com/docs/en-us/studio/optimization/memory-usage
- https://create.roblox.com/docs/en-us/studio/optimization/scriptprofiler
Start with a reproducible failure and identify whether it occurs on the client, server, or both. Choose a debugging or profiling tool based on that evidence.
## The debugging mindset
1. **Reproduce it.** If you can't reproduce it, you can't fix it.
2. **Isolate it.** Remove systems until the bug disappears; the last thing removed is the cause.
3. **Measure it.** Use tools instead of guessing.
4. **Fix one thing at a time.** Verify the fix and add a regression test if possible.
5. **Log defensively.** Good logs make future debugging faster.
## Logging discipline
Use `print`, `warn`, and `error` deliberately:
- `print` for normal diagnostics.
- `warn` for recoverable problems you should notice.
- `error` for programming errors that should stop execution.
Include context in log messages:
```lua
warn(string.format("[DataStore] Save failed for %d: %s", userId, tostring(err)))
```
Avoid logging secrets, player data, or PII.
## pcall and assertions
Wrap fallible calls, especially cloud services, HTTP, DataStores, Marketplace:
```lua
local ok, result = pcall(function()
return someService:DoSomething()
end)
if not ok then
warn("DoSomething failed:", result)
end
```
Use `assert` for internal invariants that should never fail:
```lua
assert(config.MaxSpeed > 0, "MaxSpeed must be positive")
```
## Developer Console
Open with `F9` in-game or in Studio play mode.
Tabs:
- **Log**: client/server output, errors, warnings.
- **Memory**: categorized memory usage.
- **Network**: HTTP and service requests.
- **Server Stats**: heartbeat, ping, data ping.
- **Script Profiler**: record script CPU usage.
- **MicroProfiler**: capture server dumps.
Toggle Client/Server views to see which side emitted output.
## Unit testing with TestEZ
The standard Roblox testing framework is [TestEZ](https://github.com/Roblox/testez). It supports nested `describe`/`it` blocks, lifecycle hooks, async tests, and a rich matcher API.
TestEZ example:
```lua
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ)
local MathUtils = require(ReplicatedStorage.MathUtils)
describe("MathUtils", function()
it("clamps values", function()
expect(MathUtils.clamp(5, 0, 10)).to.equal(5)
expect(MathUtils.clamp(-1, 0, 10)).to.equal(0)
expect(MathUtils.clamp(11, 0, 10)).to.equal(10)
end)
end)
TestEZ.TestBootstrap:run({ game.ReplicatedStorage.Tests })
```
For legacy in-Studio tests, `TestService:Error(msg)` can mark failures visibly in Studio output, but most projects should prefer TestEZ. The scripts folder contains a minimal `TestRunner.lua` shim only for environments where TestEZ is unavailable.
## Luau type checking
Use the [Luau language server](https://luau.org/) (`luau-lsp`) or the Luau type checker in Studio to catch errors before runtime.
Best practices:
- Add type annotations to module exports and pure functions.
- Run `luau-lsp analyze` in CI or before committing.
- Enable `--!strict` for new modules.
- Do not over-type untyped engine APIs; prefer casts at boundaries.
## Studio Debugger
Use Studio's built-in debugger for step-through debugging:
- Set breakpoints by clicking the gutter next to a line number.
- Run in **Play** mode and use the debugger controls to step over, into, or out.
- Inspect the call stack, local variables, and upvalues in the debugger panel.
- The debugger works in both client and server contexts when Studio runs both.
## xpcall and debug.traceback
Use `xpcall` with `debug.traceback` to capture full stack traces from failures:
```lua
local ok, err = xpcall(function()
riskyOperation()
end, debug.traceback)
if not ok then
warn(err)
end
```
This is especially useful at top-level entry points, scheduled callbacks, and connection handlers where `pcall` alone discards the stack.
## Connection cleanup
Leaked `RBXScriptConnection` objects are a common source of memory leaks and stale state. Prefer explicit cleanup patterns:
- Use a Maid/Janitor-style collector:
```lua
local Maid = require(path.to.Maid)
local maid = Maid.new()
maid:GiveTask(workspace.ChildAdded:Connect(onChild))
maid:GiveTask(RunService.Heartbeat:Connect(onStep))
maid:Destroy() -- disconnects everything
```
- Use `:Once()` for one-shot event handlers.
- Use `Instance.Destroying` to trigger cleanup when an instance is removed:
```lua
obj.Destroying:Connect(function()
cleanup()
end)
```
- Always pair `:Connect()` with a matching `:Disconnect()` or destruction.
## Prefer task over spawn/delay
The `task` library is more predictable than legacy `spawn`, `delay`, and `wait`:
```lua
-- good
task.wait(1)
task.delay(1, callback)
task.spawn(coroutineOrFn)
-- avoid
spawn(callback)
delay(1, callback)
wait(1)
```
`task.defer` is useful for yielding to the next resumption cycle without opening a new thread.
## Log rate limiting and secrets hygiene
- Never log secrets, keys, user identifiers, or personal information.
- Sanitize values before logging:
```lua
local function sanitize(value)
if typeof(value) == "Instance" and value:IsA("Player") then
return "Player(" .. tostring(value.UserId) .. ")"
end
return value
end
```
- Rate-limit noisy logs to avoid flooding the console and network:
```lua
local lastLog = 0
local function throttledLog(message)
local now = os.clock()
if now - lastLog > 5 then
lastLog = now
warn(message)
end
end
```
## Network ownership
Incorrect network ownership causes jittery physics and replication lag. Visualize ownership and assign it explicitly:
```lua
-- Server
part:SetNetworkOwner(player)
-- Client-side visualization (debug only)
local ownershipLabel -- create BillboardGui or use DebugDraw
```
Use `BasePart:GetNetworkOwner()` to inspect ownership. Vehicles and held items should usually be owned by the controlling player.
## DataStore retry pattern
For retry-safe reads, use bounded retries with exponential backoff and jitter. Do not use this generic loop to replay writes: a failed response can hide a committed write, and independently retried requests can execute out of order. See roblox-datastores for write reconciliation and per-key ordering.
```lua
local function readWithRetry(fn, maxAttempts)
maxAttempts = maxAttempts or 5
for attempt = 1, maxAttempts do
local ok, result = pcall(fn)
if ok then
return true, result
elseif attempt == maxAttempts then
return false, result
else
warn("DataStore attempt " .. attempt .. " failed; retrying...")
task.wait(2 ^ attempt * 0.1 + math.random() * 0.5)
end
end
end
```
Always call DataStores from the server and validate serialization before saving.
## Memory snapshot comparison
Use the Developer Console Memory tab or Scene Analysis to compare snapshots:
1. Capture a baseline snapshot in a stable state.
2. Play through the action that may leak (spawn/despawn enemies, open/close UI).
3. Return to the stable state and capture a second snapshot.
4. Compare PlaceMemory, Luau heap, and instance counts.
5. Investigate categories that did not return to baseline; look for unparented instances in Scene Analysis.
## MicroProfiler
Open with `Ctrl+F6` (`⌘+F6`) in Studio or the desktop client.
Use it to:
- Find frame-time spikes.
- Identify whether a bottleneck is script compute, physics, or rendering.
- Capture server dumps from the Developer Console.
- Add custom labels with `debug.profilebegin`/`debug.profileend`.
Key colors:
- **Orange**: worker thread (scripts, physics, animations) bottleneck.
- **Blue**: render thread bottleneck.
- **Red**: GPU wait / render complexity.
## Scene Analysis
Available in Studio under **Window → Performance Summary → Scene Analysis**.
Views:
- **Script memory**: per-script Luau heap.
- **Unparented instances**: potential memory leaks held by scripts.
- **Instance composition**: counts by category.
- **Audio/Animation memory**: asset memory usage.
- **Triangle composition**: draw call breakdown.
Scene Analysis is a Studio UI tool; there is no public `SceneAnalysisService` API.
## Script Profiler
Records CPU time per script. Use it when MicroProfiler points to scripts but you need to know which script.
## Common bug categories
| Symptom | Likely causes |
| --- | --- |
| Script silently fails | Missing `pcall`, error swallowed, wrong script context |
| Data not saving | DataStore called from client, non-serializable value, no `pcall` |
| Remote not working | Wrong side, handler not connected, argument mismatch |
| Lag spikes | Pathfinding every frame, too many particles, unbatched loops |
| Physics jitter | Wrong network ownership, assembly splits, conflicting constraints |
| NPCs stuck | Blocked path not recomputed, bad agent params, streaming issues |
| UI doesn't update | Property not replicated, wrong parent, layout order |
| Memory grows forever | Leaked connections, unparented instances, cached assets |
## Network debugging
- Check **Network** tab in Developer Console for HTTP/DataStore failures.
- Use `Shift+F3` in-game for network debug stats.
- Distinguish network ping (round-trip time) from data ping (replication queue).
- Use Studio's beta Network Simulator for separate inbound/outbound latency, jitter, and packet loss. Enable **New Device Simulator** in Beta Features, restart Studio, then open **Test → Device Simulator → Network**. Select **Apply** to activate staged changes. **Ideal Fiber** still adds 8 ms each way; use all-zero custom values for a zero-added-delay baseline. See [network simulation](references/network-simulation.md).
## Load time debugging
Measure load time:
```lua
local start = os.clock()
game.Loaded:Connect(function()
print("Loaded in", os.clock() - start)
end)
```
Run this from a `LocalScript` in `ReplicatedFirst`. It starts timing when `ReplicatedFirst` replicates and stops when `game.Loaded` fires, so it excludes the time to connect to the server and load the avatar. Load time as players experience it, from clicking **Play** to the world rendering, is longer. Enable **Print Join Size Breakdown** in Studio Settings → Network to see the largest replicated instances.
## Scripts
- `scripts/TestRunner.lua`: a minimal TestEZ fallback with nested suites, lifecycle hooks, matchers, async support, and TestService integration.
- `scripts/Logger.lua`: a structured logger with level filtering and guarded formatting.
- `scripts/DebugDraw.lua`: utility for drawing rays, points, and boxes in 3D for visual debugging.
## How to proceed
1. Reproduce the issue reliably.
2. Check logs and errors in the Developer Console.
3. Determine client vs server scope.
4. Use MicroProfiler/Script Profiler for performance issues.
5. Use Scene Analysis for memory leaks and scene composition.
6. Add targeted logging or tests to confirm the fix.
7. Verify on low-end devices and with network simulation when relevant.
8. Compare memory snapshots before and after suspected leaks.
<!-- catalog:references:start -->
## Reference index
- [common-bugs-and-fixes.md](references/common-bugs-and-fixes.md): Trace a symptom to likely causes and a focused fix.
- [debugging-tools.md](references/debugging-tools.md): Inspect logs, breakpoints, stack traces, or runtime state.
- [network-simulation.md](references/network-simulation.md): Reproduce latency, jitter, or packet-loss problems in Studio.
- [performance-profiling.md](references/performance-profiling.md): Find CPU, GPU, memory, or load-time bottlenecks.
- [testing-patterns.md](references/testing-patterns.md): Write unit tests, mock dependencies, or test client-server flows.
<!-- catalog:references:end -->