Tiny Roblox debug UI for traces, live rows, timings, casts, and graphs.
Install · How It Works · Using It · Graphs · API
Lori is a little client overlay for debugging while you play.
It is for the stuff you normally print, but want to actually see in-game: FPS, current state, what part you are standing on, what a raycast hit, how long something took, memory, script activity, or any number you want graphed.
|
|
|
|
Put Lori.rbxm in ReplicatedStorage/Packages/Lori, then require it from a LocalScript.
local Lori = require(game.ReplicatedStorage.Packages.Lori)For roblox-ts/npm:
npm install @kyrorblx/loriimport Lori = require("@kyrorblx/lori");For Wally:
[dependencies]
Lori = "kyrorblx/lori@0.1.7"WALLY IS SUPPORTED :D!
Lori is client-side. Put your demo or setup code in StarterPlayerScripts, StarterGui, or another client place. Server scripts should not mount the overlay because the UI lives in PlayerGui.
Lori has two layers:
Lori.create()makes an isolated client with its own rows, graphs, theme, config, and template providers.Lori.trace(...),Lori.mount(...), and the other directLori.*calls use one shared default client.
Use Lori.create() for real projects so different systems do not fight over one global overlay.
Mounting creates a ScreenGui under PlayerGui. After that, traces and graphs create compact UI rows inside the configured stack position. If you call a trace before mounting, Lori mounts itself automatically.
By default, studioOnly = true, so Lori does nothing in live published servers unless you pass studioOnly = false.
Most of the time you want one Lori client:
local lori = Lori.create()
lori:mount(nil, {
studioOnly = false,
maxItems = 8,
})Then send rows:
lori:success("demo", "loaded")
lori:warn("net", "retry", { count = 2 })
lori:trace("weapon", "fire", { ammo = 12 })Rows are built from:
scope: where it came from, likeweaponaction: what happened, likefirefields: extra valuestone: color style, liketrace,success,warn
The displayed row is basically scope action key=value key=value. Field order is alphabetical so rows do not jump around when tables are built in a different order.
Use these for events. If something changes constantly, use a keyed row or a watch.
A normal trace makes a new row. A keyed row updates the same row.
lori:push({
key = "player.ground",
scope = "player",
action = "standing",
tone = "phase",
sticky = true,
fields = {
part = "Baseplate",
},
})Push the same key again and it changes that row instead of adding another one.
This is useful for state, target, ammo, current room, current floor part, and anything else where there should only be one visible line.
Use sticky = true for rows that should stay until changed or dismissed. Without sticky, the row fades out after duration seconds.
Watches are keyed rows where fields can be functions.
lori:watch("player.stats", {
health = function()
return math.floor(humanoid.Health)
end,
speed = function()
return math.floor(root.AssemblyLinearVelocity.Magnitude)
end,
})Lori keeps refreshing the row. You can also push the same key later with different functions and it will use the new ones.
Watch fields are called with pcall, so one bad field shows err instead of killing the overlay. Use options.interval to control refresh rate:
lori:watch("player.stats", fields, {
interval = 0.25,
tone = "success",
})Use time when you want to know how long something took.
local done = lori:time("weapon", "raycast")
local result = workspace:Raycast(origin, direction, params)
done({ hit = result and result.Instance.Name or "none" })There are also cast helpers:
lori:raycast("gun", origin, direction, params)
lori:spherecast("sensor", origin, radius, direction, params)
lori:blockcast("hitbox", cframe, size, direction, params)They return the normal Roblox result, but Lori records timing for the perf rows.
time is best when you already own the code block. measure wraps a callback and rethrows errors after recording the time. Cast helpers are just convenience wrappers around Workspace casts.
Graphs are for any number over time.
Not just memory or FPS. Anything numeric works: raycast time, casts per second, script activity, AI count, ping, queue size, damage, pathfinding time, network messages, whatever.
Create one:
local graph = lori:graph("ray ms", {
unit = "ms",
warnAt = 1,
failAt = 3,
})Push values:
graph:push(0.42)That is it. Lori keeps the last samples and draws the columns.
Example idea for script activity:
local activity = lori:graph("activity", { unit = "/s", tone = "phase" })
local count = 0
local elapsed = 0
local function recordActivity()
count += 1
end
game:GetService("RunService").Heartbeat:Connect(function(dt)
elapsed += dt
if elapsed >= 1 then
activity:push(count / elapsed)
count = 0
elapsed = 0
end
end)Call recordActivity() wherever your system does work.
Graph options:
tone: graph colorstyle: currently onlybarsunit: text after the number, likems,fps,/srange: fixed{ min, max }; without it Lori auto-rangeswarnAt: column turns warn color at this valuefailAt: column turns error color at this valueformat: custom display function
The bar gradient uses the graph tone color. Change the tone or override that tone with lori:setTheme(...).
Graphs keep a rolling sample window. If no fixed range is provided, Lori auto-ranges from visible samples and adds padding so small changes are readable.
Graph labels show the current value plus min, max, and avg for the visible window.
Hover a graph column to show a tooltip for that sample. While the tooltip is open, that graph visually pauses so the value does not move under your mouse. Moving away resumes it.
The performance template includes ping, FPS, and memory graphs by default.
Lori starts empty unless you ask for the template:
lori:mount(nil, {
studioOnly = false,
template = Lori.Templates.Performance,
})That adds:
- FPS/frame row
- ping graph
- FPS graph
- memory graph
You can remove built-in template providers with templateBlacklist.
lori:mount(nil, {
studioOnly = false,
template = Lori.Templates.Performance,
templateBlacklist = Lori.Templates.ItemBlacklister.New().Memory().FPS(),
})That example keeps the frame row and ping graph, but removes the memory and FPS graphs.
Plain string tables also work:
lori:mount(nil, {
template = Lori.Templates.Performance,
templateBlacklist = { "MemGraph", "FpsGraph" },
})Provider names:
FramePingGraphFpsGraphMemGraph
Use Lori.Templates.Empty if you want only your own rows.
Themes are just colors.
lori:setTheme({
warn = Color3.fromRGB(255, 180, 60),
background = Color3.fromRGB(12, 12, 16),
})Main color keys:
trace,phase,success,warn,errorkey,value,mutedtext,background
Common options:
studioOnly: only run in Studioenabled: disables Lori entirely when falseuiVisible/visible/showUI: mount and keep updating Lori, but hide or show theScreenGuimaxItems: max trace rowsinterval: update delay in seconds, default0.1minInterval: lowest allowed update delay, default0.04anchor: stack position, defaulttrrightOffset,leftOffset,topOffset,bottomOffsetposition: legacy top-right offset aliaswidth: max row width,0means automaticfontSize,rowHeight,gap,padX,cornerRadiusgraphWidth,graphHeight,graphColumns,statsSamplesmotionTime,exitTime,fadeTimeuiScale,scaleBaseWidth,minScale,maxScale
Anchors:
tr/topRight/top-righttl/topLeft/top-leftbr/bottomRight/bottom-rightbl/bottomLeft/bottom-left
Bottom anchors stay pinned to the bottom and grow upward as rows appear.
cornerRadius can be a scale value like 0.3 or a pixel value like 6. Rows, graph cards, graph labels, and tooltips all share the same radius.
You can pass config directly or under config; these are equivalent:
lori:mount(nil, { maxItems = 8 })
lori:mount(nil, { config = { maxItems = 8 } })Viewport scaling changes the actual text size, row size, padding, and graph size together, so the text measurement stays correct.
Use enabled = false when you want Lori to do nothing. Use uiVisible = false when you want Lori mounted and collecting/updating rows, but hidden from the player.
local lori = Lori.create({
uiVisible = false,
})
lori:mount()
lori:show()
lori:hide()
lori:toggleVisible()
lori:setVisible(true)The aliases visible and showUI are accepted in config too, so teams can use whichever reads best in their settings module.
Lori.create(options?)
lori:mount(parent?, options?)
lori:setVisible(visible)
lori:toggleVisible(force?)
lori:show()
lori:hide()
lori:trace(scope, action, fields?, duration?)
lori:phase(scope, action, fields?, duration?)
lori:success(scope, action, fields?, duration?)
lori:warn(scope, action, fields?, duration?)
lori:error(scope, action, fields?, duration?)
lori:push(event)
lori:watch(key, fields, options?)
lori:graph(name, options?)
lori:time(scope, action)
lori:measure(scope, action, callback)
lori:record(scope, action, elapsedMs, fields?)
lori:raycast(scope, origin, direction, params?)
lori:spherecast(scope, origin, radius, direction, params?)
lori:blockcast(scope, cframe, size, direction, params?)
lori:dismiss(key)
lori:clear()
lori:unmount()
lori:destroy()Graph handles:
graph:push(value)
graph:rescale(factor)
graph:remove()You can use the global Lori.trace(...) style too, but for real projects Lori.create() is cleaner.



