01Why port?
The production HUD is a two-script linkset: a Root Core that does radar scanning, display and menu UI, and a List Manager that handles HTTP and list storage. Both scripts have been in production for a while. Both work. The question was never whether LSL could do the job — it does — but whether SLua would do it better.
Three numbers made the case worth investigating:
Beyond the raw numbers, the HUD leans on several patterns that SLua handles
natively: parallel lists used as key/value caches, manual date arithmetic because
LSL has no ternary operator, a single shared timer juggled between scan interval
and menu timeout, and a stateful paginated HTTP fetch gated by a
fetchInProgress flag. Each of those maps onto one SLua feature.
Together they pointed at a substantial rewrite — but a structurally
straightforward one.
02The process
I approached the port in two passes, one script at a time, keeping the existing LSL version running in parallel on the HUD throughout. The cross-script protocol — four messages from Root Core to List Manager, four the other way, all on channel 400 — meant I could swap either script out independently and confirm mixed LSL/SLua operation still worked. That turned out to be the single most practically useful property of SLua for a port like this one, and it gets its own section below.
Three things to try before you port anything
-
Download the SLua Project ViewerNeeded to compile SLua. Grab it from the Second Life Alternate Viewers page. Works alongside your normal viewer; switch between them freely.
-
Visit a SLua Beta regionSLua scripts only compile in designated beta regions. Open the map, search
SLua, teleport to any result.SLua Beta Landingis the main sandbox. -
Rez a prim, open the script editor, switch the compilerThere's a new dropdown beside the Save button. Switch it from
MonotoSLua. Write the defaultll.Say(0, "Hello, Avatar!"), save, touch the prim. You're now running Luau bytecode on the SL simulator.
What I didn't try to optimise
The temptation with a rewrite is to redesign everything. I deliberately resisted that for Root Core. The goal for script one was a faithful translation — same behaviour, same message protocol, same UI — so any memory or performance difference was attributable to the language, not to design changes. Root Core's structure is identical to its LSL ancestor.
List Manager got the opposite treatment. Its shape — event-driven state machines around HTTP, notecard reading, and dialog menus — is exactly what coroutines were designed for. A faithful port there would have missed the point, so I let that script become what SLua wanted it to be.
03Migration strategy
The single most practically useful finding from this port isn't about memory
savings or coroutines — it's about the shape of the transition itself. If
you have a multi-script HUD or object, you can port it one script at a time,
with the other scripts still in LSL, and the whole thing keeps working. SLua and
LSL scripts in the same linkset can exchange link_message calls
cleanly, in either direction. I'd expected this to be rough at the edges. It
wasn't.
How mixed mode works
The HUD's two scripts talk to each other exclusively through
link_message on channel 400 — four messages one way, four the
other, all strings. The SLua documentation notes in passing that the fourth
parameter of link_message is typed as string in SLua
but as key in LSL, and that the runtime typecasts values
automatically when they cross the boundary.
In practice that means a SLua script sending
ll.MessageLinked(LINK_SET, 400, "MENU_OPEN", tostring(userId))
lands in an LSL script's link_message handler with id
holding the UUID as a key, ready to use. Going the other way, an LSL script
sending a plain string lands in a SLua handler with the fourth parameter as a
string. Neither side needs to know what the other is written in.
The staged port in practice
I ported Root Core first and spent the next day running it against the original LSL List Manager. Everything worked: version checks, list updates, menu open/close, notecard imports triggered by inventory changes. Only after I'd confirmed the mixed-mode setup held up under realistic use did I start on the List Manager port. At no point was the HUD non-functional.
That's not a safety feature of SLua — it's a consequence of the fact that the protocol between scripts was already a small, well-defined surface. LSL projects with clean inter-script boundaries get this for free. Projects with scripts that share state via other mechanisms (Linkset Data, object description, region channels) would still get most of the benefit, because the boundaries those mechanisms create are similarly language-agnostic.
What this unlocks
For a staged rollout, three things become possible:
- Port in priority order, not in dependency order. Pick the script that benefits most from SLua first — usually the one with the tightest memory or the most async state — and ship it while the rest remain LSL.
- A/B the same script in both languages. Keep a copy of the original LSL version alongside the SLua port in the same inventory. If a customer reports a regression, flip one script's "running" state and reproduce against the previous implementation. No rollback branching in source control required.
- Delay the deployment decision. Because SLua only runs on SLua-enabled regions, a mixed-mode linkset effectively runs in pure-LSL mode everywhere else. You can develop the SLua port and have it ready to go, while the product continues to ship as 100% LSL until SLua goes grid-wide.
Where it doesn't help
Mixed mode covers link_message cleanly. It doesn't automatically
bridge everything else that's changed between languages. If two scripts share
state through a convention — say, both writing to the same Linkset Data
keys with a specific format — both scripts need to agree on that format
regardless of language. SLua's native tables make it tempting to store
structured data as JSON (via lljson) in LSD, but the moment an LSL
script has to read that data, you're back to string parsing.
For this port, I kept the LSD schema exactly as the LSL v3.1 version defined it
(m_<uuid> = "1", i_<uuid> = "1",
list_version = "<integer>") precisely so that mixed
mode would work without translation shims. That's the shape of decision worth
making early in a staged port: keep the cross-language interfaces as boring as
possible until both sides are in the same language.
04Root Core — the easy win
First-paste compile, no errors. That alone was surprising: around 650 lines of
LSL translated idiom-by-idiom to SLua, and the compiler accepted it on the first
try. Credit goes largely to the structural similarity — LL functions under
the ll. namespace, list arguments becoming table literals, events
becoming LLEvents:on() registrations — which is less a language
design choice and more a deliberate ergonomic decision by the SLua team.
Memory: the numbers
| Script | LSL used / free / total | SLua used / free / total | Delta |
|---|---|---|---|
| Root Core | 44,278 / 21,258 / 65,536 | 24,460 / 106,612 / 131,072 | −45% used 5× headroom |
Both the reduced footprint and the doubled ceiling come from the runtime change,
not from rewriting the code. The parallel list caches — ageCache,
usernameCache, languageCache — which were the
Root Core's memory drivers in LSL, became native tables with zero redesign beyond
the obvious stride-to-key translation.
Parallel lists become tables
The single biggest readability change is the one SLua advertises most, and it earns the advertising. The LSL cache lookup:
integer idx = llListFindList(ageCache, [uuid]);
if (idx != -1) {
integer days = llList2Integer(ageCache, idx + 1);
// ...
}
local days = ageCache[uuid]
if days then
-- ...
end
The effect isn't just brevity. Every stride arithmetic error, every off-by-one, every "did I remember to read index + 1 not index + 2?" bug becomes structurally impossible. A full day of debugging vanishes from some future version of you.
Multiple timers
LSL has one timer per script. Root Core was using it for a 3-second radar scan, stealing it for menu timeout, then restoring it afterward. In SLua:
-- independent timers, no sharing, no state tracking
LLTimers:every(3.0, scanTick)
LLTimers:once(30.0, closeMenu)
The menu timeout no longer interferes with the scan interval. LLTimers:off()
using the handler function as the identifier removes the need to track timer
state at all.
Conditional expressions
LSL's lack of a ternary operator meant the HUD had a chunky
date2daysSince2000() helper doing manual arithmetic to avoid it.
In SLua that whole function becomes a one-liner because Luau has
if-expressions:
local colour = if isListed then COL_LISTED else COL_NEW
Small thing, but LSL scripts accumulate a remarkable amount of scaffolding to work around this single missing feature. Removing it made several helper functions redundant.
05List Manager — where coroutines earn their keep
If Root Core was a translation, List Manager was a rewrite. The script has four
asynchronous flows — paginated HTTP fetch, version check, notecard import,
and dialog menus — and every one of them in LSL was a state machine: flags,
timers, nested if (state == N) branches in a single event handler.
Coroutines turn all of those into straight-line code. One helper does the heavy lifting:
local status, body = httpRequestAwait(url)
if not status then return end -- timeout or error
Internally it yields the calling coroutine, registers itself in a pending table
keyed by request id, and sets a timeout via LLTimers:once. The
http_response event handler looks up the coroutine and resumes it.
Everything downstream of the HTTP call — the response parsing, the state
transitions, the error handling — becomes plain sequential code.
Paginated fetch: before and after
In LSL, the paginated list fetch required: a fetchInProgress
flag, a fetchPage_(page) sender, a timer for timeout handling, and
an http_response event that branched on success / more-pages / done /
error and re-entered the sender. In SLua it's one function:
local function doFetchList()
local page = 1
while true do
local status, body = httpRequestAwait(urlFor(page))
if not status or status ~= 200 then report() return end
parseAndWrite(body)
if isDone(body) then notify() return end
page += 1
end
end
One function. No flags. Termination is a return. The timeout lives
inside httpRequestAwait alone; the caller just treats a nil status
as an error. This is the pattern I'd point to first if someone asked "what does
SLua actually give you?" It's the difference between maintaining a state machine
in your head and reading top-to-bottom like any other program.
Menus: 45 lines collapse to a flow
The old LSL menu handler was a 45-line listen event nested around a
menuContext integer that tracked which of six submenus the user was
in. Every "back" button had to know which state to return to. In SLua the menu
becomes a set of functions that call each other with awaitDialog
yields:
local function mainMenu()
while true do
local choice = awaitDialog("Manage Lists", {...})
if choice == nil or choice == "✖ Close" then return end
if choice == "Ignore List" then submenu("i_", "Ignored Avatars") end
if choice == "Main List" then submenu("m_", "Main List") end
end
end
Flow is linear. Back-and-close are return statements. Timeouts are
a nil response from awaitDialog. Adding a confirmation step is a
one-line insert, not a new state. This is the textbook coroutine-menu pattern
from the SLua wiki, applied to production code and working exactly as advertised.
Notecard import: same pattern, different yield
The LSL notecard import branched on dataserver and re-fired
llGetNotecardLine() on every response, needing a line counter
tracked as script state. In SLua:
local lineNum = 1
while true do
local data = awaitNotecardLine(notecard, lineNum)
if data == EOF then break end
process(data)
lineNum += 1
end
The pattern is identical to the HTTP loop: a yielding helper, a while-true loop,
a clean exit condition. Once awaitSomething is in your toolbox every
async flow reduces to it.
06Gotchas worth knowing
These aren't bugs — they're behaviours that are correct-but-surprising, the kind that cost you an afternoon the first time and five minutes every time after. Documenting them here so future-me (and anyone else porting an HUD) doesn't repeat the lookup.
Dialog "close" is not the same as the dialog disappearing
When a script stops listening on a dialog channel — either via
ll.ListenRemove or the LLEvents:off pattern —
the dialog floater stays on the user's viewer. Pressing a button on the
now-stale dialog silently does nothing, because no script is listening.
This is unchanged from LSL behaviour but looks bug-like when you hit it in the new coroutine-based menu patterns. The timeout is firing correctly; the dialog is simply a viewer-side artifact after the script has moved on.
math.randomseed is a no-op — and you don't need it
My instinct from Lua-elsewhere was to seed math.random at startup
so each freshly-rezzed object wouldn't pick the same "random" dialog channel.
In SLua that instinct is wrong on two counts.
First, math.randomseed is a no-op. Calling it has no effect on
subsequent math.random output. The
SLua source
spells out the reasoning in a comment: "We don't support seeding the RNG
in this configuration, just ignore. Some Lua scripts try to seed rand with
time() and such manually in an attempt to get more 'random'
values which doesn't really work. We stay silent to not break them."
Second, you don't need it. The random state is shared by everything running
in the SLua VM and is continuously consumed, so by the time your script
calls math.random() for the first time the state has already
been advanced by every other script that touched it. Two freshly-rezzed
copies of the same object will not produce the same channel — they're
drawing from the same continuously-evolving stream.
The official wiki's example scripts include calls to
math.randomseed(ll.GetUnixTime()) as a habit imported from
LSL/Mono and other Lua environments. Those calls don't do anything in SLua.
They're harmless, but they suggest a problem that doesn't exist — and
following the pattern in your own code creates the impression that
randomness needs careful handling when it doesn't.
With thanks to Wolfgang Senizen for the correction.
ll.GetNotecardLine is 1-indexed, not 0-indexed
This one is mentioned on Suzanna Linn's guide but easy to miss when you're
head-down in a port. LSL starts notecard lines at 0; SLua starts at 1. If
you're directly translating a notecard-reader, change importLine = 0
to lineNum = 1. Everything else follows.
More broadly: SLua's "1-based LL functions" list is worth a careful read before porting. Detected-event accessors, notecard lines, and several others shifted index base.
Signposts from Suzanna's guide
These next three I didn't hit first-hand — I caught them reading Suzanna Linn's LL Functions page while double-checking my own notes. Documenting them here because they're the kind of thing you'd only discover when something unexpectedly breaks, and if you're porting a script that touches these areas, you'll want them on your radar before you compile.
Some LSL functions are removed outright
ll.SetTimerEvent, ll.ResetTime,
ll.GetAndResetTime, and ll.SetMemoryLimit don't
exist in SLua. If you need them, the llcompat library provides
them — but the three time functions can't coexist with
LLTimers in the same script. Pick one timing system.
For a port this usually isn't a problem because LLTimers is the
better replacement anyway, but if you're doing a quick-and-dirty compatibility
pass first you'll hit this immediately.
Boolean-ish functions now return real booleans
A cluster of LL functions that returned 0 or 1 in
LSL now return actual true / false. Examples:
ll.IsFriend, ll.SameGroup,
ll.OverMyLand, ll.DetectedGroup,
ll.GetStatus, ll.EdgeOfWorld.
Also affects boolean-valued items returned inside lists by
ll.GetPrimitiveParams and ll.GetObjectDetails.
Comparisons like if ll.SameGroup(k) == 1 will break silently
— the comparison is always false. Use the value directly:
if ll.SameGroup(k) then.
Linked messages: the 4th parameter is a string, not a UUID
In LSL the id parameter of llMessageLinked could
carry any string. In SLua the equivalent slot is typed as a string in both
ll.MessageLinked and the link_message event. SLua
UUIDs cannot hold non-UUID strings.
Good news for mixed-mode ports: when an LSL script and a SLua script in the same linkset exchange link messages, the values are typecast internally. That's the mechanism that made the staged port described earlier work without changes to the wire protocol.
07Feedback for Linden Lab
Rough edges worth raising with the SLua team — none blocking, all documentation or ergonomic. Listed roughly in order of how often they'd come up for someone porting an existing HUD.
Document the dialog-vs-listen distinction
The coroutine menu patterns will hit this immediately. A short note in the menu-related examples explaining that the dialog floater is viewer-side and outlives the script's listen state would save a lot of confusion.
Ship a canonical "HTTP with coroutine" helper
The httpRequestAwait pattern is obvious once you've seen it, but
everyone will reinvent it independently. Either ship it in SLua's built-in
libraries or feature it prominently in the async-programming tutorial. It's
the single biggest ergonomic win from the language and it's currently
hidden behind "you'll figure it out".
Clarify ll.HTTPRequest return value
Is the return a uuid or a string? I treat it as something I can tostring()
and match against the req parameter of http_response,
and that works, but the function reference doesn't explicitly state the type.
One sentence would close the gap.
Clarify yielding inside multi-event callbacks
Some LL events — notably touch_start — deliver an
array of detected events. Can the handler yield mid-array? What happens to
the remaining events? The current docs don't address this and the behaviour
matters for any menu that opens on touch.
Complete the nil-vs-sentinel return-value list
The llfunctions page lists functions that changed from -1 to nil
(ListFind*, SubStringIndex). It doesn't explicitly
cover ll.Key2Name on unknown keys, ll.RequestAgentData
failure paths, and a few others. A complete table would remove guesswork
during porting.
Compile-time lint for 0-indexed LL calls
For LSL-to-SLua ports specifically: when someone writes
ll.GetNotecardLine(name, 0) or ll.DetectedKey(0),
the compiler could emit a note — "index 0 here is likely a port bug;
SLua detected functions are 1-indexed". Low-risk, port-specific, and
potentially saves the exact class of bug most likely to bite a seasoned
LSL scripter on day one.
08Validation log
All paths exercised on a running HUD on April 17, 2026. Both scripts compiled clean on first paste. Every structural change — table-keyed caches, coroutines, LLTimers, LLEvents — behaved as expected at runtime.
Root Core
LLTimers:everyll.GetAgentLanguage ("Dutch" correct)List Manager
httpRequestAwait coroutinell.LinksetDataWriteNot yet exercised
09Resources
Everything I referenced while porting, grouped by who maintains it. The community guides — Suzanna's especially — filled gaps the official docs haven't caught up with yet. If you read just one thing before starting your own port, make it her "From LSL to SLua" chapter.
The newest and best-organised SLua reference. Still a work in progress but already the clearest entry point.
Canonical examples, region list, and the dropdown-compiler walkthrough. Named "Alpha" despite the current beta status.
Why Lua, why not C#, what happens to LSL, memory characteristics. Answers the strategic questions, not the implementation ones.
Running changelog from the SLua team. Worth subscribing — beta API changes land here first.
You'll need this specific viewer to compile SLua scripts. Works alongside your normal viewer.
The single most practical resource for an LSL-to-SLua port. "Moving from LSL to SLua" covers the differences chapter by chapter; the "Scripts" section has worked examples for menus, async/await, notecards and more.
The specific page I'd point to first: 1-based indexing changes, removed functions, boolean-return changes, and the llcompat library all in one place.
The coroutine-menu pattern applied in full. Directly informed the List Manager menu rewrite described above.
The template the httpRequestAwait helper is built on. Good short read if coroutines feel abstract.
Syntax highlighting, preprocessing, require inclusion, error tracking, in-editor object-chat and debug output. Worth setting up on day one.
Linden Lab's public fork of Luau. Not required reading, but the diff against upstream Luau is useful if you want to understand the VM-state serialisation layer.
Everything non-SL-specific: string, table, math, bit32, coroutine. If a function isn't in the ll. namespace, it's probably here.
A mechanical first-pass conversion for LSL scripts. Useful as a starting point, not a finished product — the output still needs review.
10Conclusion
The port is structurally complete and the numbers are real: 45% less memory, five times the headroom, and several hundred lines of state-machine scaffolding replaced by coroutines that read top-to-bottom. Nothing I hit during the port felt like a language limitation. The gotchas were documentation gaps, not design flaws.
The rewrite isn't deployed — it can't be, until SLua goes grid-wide — but it's ready when SLua is. In the meantime this page is a living record, and I'll be adding to it as I exercise the paths I haven't yet, as the SLua beta changes, and as new surprises surface.
If you're thinking about porting your own scripts, three things from my experience that might save you time:
- Port the easy script first, verbatim. Confirms your mental model of the translation before you start making design changes.
- Keep both versions swappable during the port. The mixed-mode message-passing across channel 400 was more robust than I expected, and it meant every stage was testable against the live HUD.
- Only let the second script become what SLua wants. Coroutines aren't free weight; they earn their keep when your code is genuinely async. Root Core had no async flows — it stayed imperative and that was correct.
This is a page that will keep growing. If you've ported something and hit something I haven't, please get in touch in-world (Joshua Lit) — I'd like to add your findings here too.
11Changelog
The original Gotcha 02 incorrectly stated that math.randomseed
is needed for per-object randomness uniqueness. In fact math.randomseed is a no-op in SLua, and per-script uniqueness comes automatically from the VM-shared random state. Section rewritten to reflect the actual behaviour, with a citation to the SLua source. With thanks to Wolfgang Senizen for the correction.
Reworded the page to refer to the ported object as a generic production HUD rather than naming a specific use case. The findings apply to any two-script LSL HUD; removing the domain-specific framing makes that more obvious to readers porting their own projects.
Expanded the mixed-mode finding into its own section covering how LSL and SLua scripts coexist in a linkset, what a staged port looks like in practice, the three concrete benefits it unlocks for commercial creators, and where the technique stops helping. Existing Process section tightened to avoid overlap.
Added a Resources section indexing official docs, Suzanna Linn's guide, community tools, and where to ask questions. Added a three-step quick-start for LSL scripters wanting to try SLua before committing to a port. Three new gotchas lifted from Suzanna's LL Functions page: removed functions, boolean-return changes, and the string-typed linked-message parameter.
Both scripts ported and validated. All documented findings, gotchas and feedback reflect the state of the SLua beta as of mid-April 2026.