Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/brockmartin/roblox-game-skill/llms.txt

Use this file to discover all available pages before exploring further.

Performance in Roblox is a multi-dimensional problem: part count drives physics and rendering costs, script logic drives CPU cost, event connections drive memory cost, and remote call frequency drives network cost. The most important habit is profiling before optimizing — the MicroProfiler and F9 console will tell you exactly where time and memory are going, and guessing wrong wastes development time while leaving the real bottleneck untouched.

Performance Targets

MetricDesktopMobile
Frame rate60 fps30 fps minimum
Memory budget~1 GB~500 MB
Load timeUnder 10 secondsUnder 15 seconds
Remote callsUnder 50/sec per clientSame, smaller payloads
Always measure against the lowest-spec target device, not your development machine. A game running at 120 fps on a gaming PC can easily drop below 30 fps on a mid-range phone. The frame budget at 60 fps is ~16.6 ms per frame; at 30 fps it is ~33.3 ms.

Part Count Best Practices

Limits

  • Per model: aim for a maximum of ~500 parts.
  • Total scene: keep the visible scene under 10,000 parts.
  • Fewer parts means less physics simulation, less rendering overhead, and faster replication on join.

MeshParts Over Unions

UnionOperation recalculates collision geometry at runtime and is more expensive than MeshPart. If you built something with unions, export it to a MeshPart:
  1. Select the union in Studio.
  2. Right-click → Export Selection.
  3. Re-import the exported .obj as a MeshPart.
MeshParts use a fixed collision fidelity that is cheaper to compute.

StreamingEnabled

For large maps, enable Workspace.StreamingEnabled to stream world content in and out based on player proximity:
PropertyRecommended ValueNotes
StreamingTargetRadius256 studsTune down for mobile
StreamingMinRadius64 studsGuaranteed nearby content
StreamingPauseModeDefaultPauses client physics when loading
Mark models that must always be present (spawn locations, critical gameplay objects) with ModelStreamingMode = Persistent.

Anchor Every Static Part

Unanchored parts enter the physics solver even when motionless, consuming CPU every frame. Set BasePart.Anchored = true on all terrain decorations, buildings, props, and anything that should not move.
-- Anchor all static props under a folder at startup
local function anchorFolder(folder: Folder)
    for _, part in folder:GetDescendants() do
        if part:IsA("BasePart") then
            part.Anchored = true
        end
    end
end

anchorFolder(workspace.StaticProps)

Script Optimization Patterns

Consolidated Heartbeat

Scattering RunService.Heartbeat:Connect(...) across dozens of scripts wastes overhead with separate closures and callback dispatch. Consolidate into a single manager.
-- HeartbeatManager ModuleScript
local RunService = game:GetService("RunService")

local HeartbeatManager = {}
HeartbeatManager._callbacks = {} :: { [string]: (dt: number) -> () }

function HeartbeatManager:Register(id: string, callback: (dt: number) -> ())
    self._callbacks[id] = callback
end

function HeartbeatManager:Unregister(id: string)
    self._callbacks[id] = nil
end

RunService.Heartbeat:Connect(function(dt: number)
    for _, callback in HeartbeatManager._callbacks do
        callback(dt)
    end
end)

return HeartbeatManager
Usage from any module:
local HeartbeatManager = require(path.to.HeartbeatManager)

HeartbeatManager:Register("EnemyAI", function(dt: number)
    -- update all enemies
end)

-- When no longer needed:
HeartbeatManager:Unregister("EnemyAI")

Cache Service References

-- GOOD: cache at the top of the script
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

-- BAD: calling GetService repeatedly inside a hot loop
RunService.Heartbeat:Connect(function()
    local players = game:GetService("Players"):GetPlayers() -- wasteful
end)

Avoid FindFirstChild in Tight Loops

-- BAD: searching the hierarchy every frame
RunService.Heartbeat:Connect(function()
    local hrp = workspace:FindFirstChild("Player1"):FindFirstChild("HumanoidRootPart")
end)

-- GOOD: cache the reference once
local hrp = character:WaitForChild("HumanoidRootPart")
RunService.Heartbeat:Connect(function()
    if hrp and hrp.Parent then
        -- use cached reference
    end
end)

Table Pre-allocation

-- Pre-allocate a table with 100 slots to avoid repeated resizing
local results = table.create(100)
for i = 1, 100 do
    results[i] = computeValue(i)
end

String Concatenation

-- BAD: creates a new string object every iteration
local result = ""
for i = 1, 1000 do
    result = result .. tostring(i) .. ","
end

-- GOOD: build a table, join once
local parts = table.create(1000)
for i = 1, 1000 do
    parts[i] = tostring(i)
end
local result = table.concat(parts, ",")

task.wait() vs wait()

Always use task.wait() instead of the deprecated global wait(). The legacy wait() has a minimum yield of ~0.03 seconds regardless of the argument passed, imposes internal throttling, and can delay tasks far longer than intended. task.wait() uses a precise scheduler with no minimum floor. The same applies to task.spawn() over spawn() and task.delay() over delay().
-- BAD: deprecated, minimum ~0.03 s yield, throttled
wait(0.016)  -- actually yields ≥ 0.03 s

-- GOOD: precise, no throttling
task.wait(0.016)

Memory Management

Disconnect Events and Destroy Instances

Every :Connect() call returns an RBXScriptConnection. If you never disconnect, the connection (and everything it captures in its closure) lives until the server restarts.
-- Event Cleanup Pattern
local Cleaner = {}
Cleaner.__index = Cleaner

function Cleaner.new()
    local self = setmetatable({}, Cleaner)
    self._connections = {} :: { RBXScriptConnection }
    self._instances = {} :: { Instance }
    return self
end

function Cleaner:Add(connection: RBXScriptConnection)
    table.insert(self._connections, connection)
    return connection
end

function Cleaner:AddInstance(instance: Instance)
    table.insert(self._instances, instance)
    return instance
end

function Cleaner:Clean()
    for _, conn in self._connections do
        if conn.Connected then
            conn:Disconnect()
        end
    end
    table.clear(self._connections)

    for _, inst in self._instances do
        inst:Destroy()
    end
    table.clear(self._instances)
end

return Cleaner
Usage:
local Cleaner = require(path.to.Cleaner)
local cleaner = Cleaner.new()

cleaner:Add(workspace.ChildAdded:Connect(function(child)
    print(child.Name, "added")
end))

cleaner:Add(Players.PlayerRemoving:Connect(function(player)
    print(player.Name, "left")
end))

-- When this system shuts down or the player leaves:
cleaner:Clean()

Always Use :Destroy()

Always call :Destroy() rather than setting Parent = nil. :Destroy() locks the instance, disconnects all events on it, and marks it for garbage collection. Setting Parent = nil keeps the instance alive if anything still references it.

Debris Service for Temporary Instances

local Debris = game:GetService("Debris")
local bullet = Instance.new("Part")
bullet.Parent = workspace
Debris:AddItem(bullet, 5) -- destroyed automatically after 5 seconds

Object Pooling

Reuse instances instead of creating and destroying them every time. Creating Instance.new() in a Heartbeat loop is a common source of memory pressure and GC pauses.
-- Object Pool Pattern
local ObjectPool = {}
ObjectPool.__index = ObjectPool

function ObjectPool.new(template: Instance, initialSize: number)
    local self = setmetatable({}, ObjectPool)
    self._template = template
    self._available = table.create(initialSize)
    self._active = {} :: { [Instance]: boolean }

    -- Pre-populate
    for i = 1, initialSize do
        local clone = template:Clone()
        clone.Parent = nil
        table.insert(self._available, clone)
    end

    return self
end

function ObjectPool:Get(): Instance
    local obj: Instance
    if #self._available > 0 then
        obj = table.remove(self._available)
    else
        obj = self._template:Clone()
    end

    self._active[obj] = true
    return obj
end

function ObjectPool:Return(obj: Instance)
    if not self._active[obj] then return end

    self._active[obj] = nil
    obj.Parent = nil

    -- Reset state
    if obj:IsA("BasePart") then
        obj.CFrame = CFrame.new(0, -1000, 0) -- move off-screen
        obj.Anchored = true
        obj.Velocity = Vector3.zero
    end

    table.insert(self._available, obj)
end

function ObjectPool:ReturnAll()
    for obj in self._active do
        self:Return(obj)
    end
end

function ObjectPool:Destroy()
    for obj in self._active do obj:Destroy() end
    for _, obj in self._available do obj:Destroy() end
    table.clear(self._active)
    table.clear(self._available)
end

return ObjectPool
Usage:
local bulletPool = ObjectPool.new(ReplicatedStorage.Assets.Bullet, 50)

-- Spawn a bullet
local bullet = bulletPool:Get()
bullet.CFrame = firePoint
bullet.Parent = workspace

-- Return when done (on impact or timeout)
bulletPool:Return(bullet)

Network Optimization

-- BAD: three separate FireClient calls per player
remoteHealth:FireClient(player, health)
remoteAmmo:FireClient(player, ammo)
remoteStamina:FireClient(player, stamina)

-- GOOD: one call with a table
remotePlayerState:FireClient(player, {
    health = health,
    ammo = ammo,
    stamina = stamina,
})

UnreliableRemoteEvent for High-Frequency Data

For position/rotation sync and other data where a dropped packet is harmless (the next update corrects state), use UnreliableRemoteEvent instead of RemoteEvent:
local posSync = ReplicatedStorage:WaitForChild("PositionSync")
-- UnreliableRemoteEvent — dropped packets are acceptable

RunService.Heartbeat:Connect(function()
    for _, player in Players:GetPlayers() do
        posSync:FireClient(player, npcPositions)
    end
end)

Delta Compression

Only send values that changed since the last update:
local lastSentState: { [Player]: { [string]: any } } = {}

local function sendDelta(player: Player, currentState: { [string]: any })
    local last = lastSentState[player] or {}
    local delta = {}

    for key, value in currentState do
        if last[key] ~= value then
            delta[key] = value
        end
    end

    if next(delta) then
        stateRemote:FireClient(player, delta)
        lastSentState[player] = table.clone(currentState)
    end
end

Reduce Replicated Property Changes

Properties changed on the server replicate to all clients automatically. Set visual-only properties (particle colors, decoration tweens) on the client to avoid flooding replication bandwidth.

Mobile Performance Considerations

  • Part count: target 30–50% fewer parts than desktop. If your desktop budget is 10,000 parts, aim for 5,000–7,000 on mobile.
  • Particle effects: halve Rate on mobile; reduce Lifetime; disable non-essential emitters entirely.
  • Touch-optimized UI: minimum touch target size is 44×44 points. Avoid hover-dependent UI — mobile has no hover state.
  • Reduced streaming radius:
local UserInputService = game:GetService("UserInputService")

if UserInputService.TouchEnabled then
    workspace.StreamingTargetRadius = 128 -- lower than desktop default
    workspace.StreamingMinRadius = 48
end
  • Memory monitoring:
-- Warn when approaching the 500 MB mobile limit
task.spawn(function()
    local lastMemory = 0
    while true do
        local current = game:GetService("Stats"):GetTotalMemoryUsageMb()
        local delta = current - lastMemory
        if delta > 10 then
            warn(string.format("Memory spike: +%.1f MB (total: %.1f MB)", delta, current))
        end
        lastMemory = current
        task.wait(10)
    end
end)

Profiling Tools

MicroProfiler (Ctrl+F6)

Real-time flame graph of what the engine does each frame. Press Ctrl+P to pause on a frame. Wide bars are hot paths.Labels to watch:
  • Heartbeat — your per-frame scripts
  • Physics — unanchored parts
  • Render/Perform — GPU load
  • Replication — network overhead

F9 Developer Console

  • Memory tab: breakdown by Instances, Scripts, Signals, Sounds
  • Stats tab: FPS, ping, data send/receive rates
  • Log tab: errors and warnings with stack traces

Stats Service (Programmatic)

local Stats = game:GetService("Stats")

-- Total memory in MB
local totalMemory = Stats:GetTotalMemoryUsageMb()

-- Specific categories
local instanceMemory = Stats:GetMemoryUsageMbForTag(Enum.DeveloperMemoryTag.Instances)
local scriptMemory = Stats:GetMemoryUsageMbForTag(Enum.DeveloperMemoryTag.LuaHeap)

print(string.format("Total: %.1f MB | Instances: %.1f MB | Lua: %.1f MB",
    totalMemory, instanceMemory, scriptMemory))

Spatial Queries Over Brute Force

-- BAD: loop over every part in workspace
for _, part in workspace:GetDescendants() do
    if (part.Position - origin).Magnitude < 50 then
        -- ...
    end
end

-- GOOD: let the engine do a spatial query
local params = OverlapParams.new()
params.FilterType = Enum.RaycastFilterType.Include
params.FilterDescendantsInstances = { workspace.Enemies }

local parts = workspace:GetPartBoundsInBox(
    CFrame.new(origin),
    Vector3.new(100, 100, 100), -- 50-stud radius as a box
    params
)

Rendering Budgets

Particle Emitters

PropertyRecommended Max
Rate per emitter~200
Total active emitters in view~20
Beam Segments10–20
Trail MaxLengthKeep short for mobile
Set ParticleEmitter.Enabled = false when the emitter is off-screen or far away.

Texture Resolution

Use CaseMax Resolution
General props, walls, floors512×512
Hero assets (characters, key items)1024×1024
UI icons, decals256×256–512×512
Sky/environment1024×1024
Use Decal over Texture for single-face coverage — decals are simpler to render.

Common Anti-Patterns

-- ANTI-PATTERN: new allocation every frame = constant GC pressure
RunService.Heartbeat:Connect(function()
    local part = Instance.new("Part") -- allocation every frame
    part.Parent = workspace
    task.delay(1, function()
        part:Destroy()
    end)
end)

-- FIX: use an object pool (see Memory Management section above)
-- ANTI-PATTERN: entire inventory table every update
remote:FireClient(player, fullInventoryTable) -- could be thousands of entries

-- FIX: send only changes
remote:FireClient(player, { added = { itemId }, removed = { oldItemId } })
A game that runs at 60 fps on a gaming PC can easily drop below 15 fps on a mid-range phone. Always test on actual mobile hardware, not just the Studio emulator. Check for thermal throttling during extended play sessions on devices with 2–3 GB RAM.
Common leak sources:
  • Event connections never disconnected — use the Cleaner pattern above.
  • Instances removed from the hierarchy but still referenced in a module-level table.
  • Closures capturing large upvalues that outlive their usefulness.
  • Module-level tables (playerData, cache, etc.) that grow indefinitely without cleanup on PlayerRemoving.
If Stats:GetTotalMemoryUsageMb() climbs continuously during gameplay without stabilizing, there is a leak.
Optimizing code that is not a bottleneck wastes development time and makes code harder to read. Always profile first with the MicroProfiler. Optimize only what the profiler proves is hot.

Build docs developers (and LLMs) love