Living document · Updated April 17 2026

Porting a production HUD
from LSL to SLua

Notes from a real port: a two-script production HUD, roughly 1,300 lines of LSL, rewritten in SLua (Luau) and validated against a running attachment on the beta grid. Memory usage dropped ~45%. Several patterns collapsed into single coroutines. A few surprises worth documenting.

Status Port complete · all paths validated
Memory saved ~45% · Root Core
Scripts ported 2 of 2
Runtime Luau · SLua beta

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:

Root Core used
44,278
bytes of 65,536 in LSL
Headroom
21,258
bytes free — running tight
SLua limit
128 KB
2× the LSL ceiling

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.

Quick start for LSL scripters

Three things to try before you port anything

  1. Download the SLua Project Viewer
    Needed to compile SLua. Grab it from the Second Life Alternate Viewers page. Works alongside your normal viewer; switch between them freely.
  2. Visit a SLua Beta region
    SLua scripts only compile in designated beta regions. Open the map, search SLua, teleport to any result. SLua Beta Landing is the main sandbox.
  3. Rez a prim, open the script editor, switch the compiler
    There's a new dropdown beside the Save button. Switch it from Mono to SLua. Write the default ll.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:

  1. 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.
  2. 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.
  3. 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:

LSL
integer idx = llListFindList(ageCache, [uuid]);
if (idx != -1) {
    integer days = llList2Integer(ageCache, idx + 1);
    // ...
}
SLua
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:

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:

SLua
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:

SLua · httpRequestAwait
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:

SLua
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:

SLua
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:

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.

Gotcha 01

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.

Gotcha 02

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.

Gotcha 03

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.

Gotcha 04

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.

Gotcha 05

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.

Gotcha 06

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.

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

First-paste compile — no errors
Memory: 24,460 bytes used (vs 44,278 in LSL, −45%)
Radar scan tick every 3s via LLTimers:every
Dataserver event for DATA_BORN age lookup ("1374d" correct)
Language detection via ll.GetAgentLanguage ("Dutch" correct)
Menu open/close, 30s timeout
Touch handling via multi-event table
Root prim visual on/off + radar on/off sound
Unicode header rendering (emoji, box-drawing chars)

List Manager

First-paste compile — no errors
VERSION_CHECK round-trip via httpRequestAwait coroutine
UPDATE_AVAILABLE detection (server list 126 → 127)
FETCH_LIST paginated fetch: 4 pages, 127 entries parsed exactly
LSD write per entry via ll.LinksetDataWrite
LISTS_CHANGED message → Root Core redraw with new status
Manage Lists menu: main menu, submenu, textbox flow
Dual name-lookup (DATA_NAME + RequestUsername), count verified 127 ↔ 128
Mixed LSL/SLua operation (SLua Root Core + LSL List Manager)

Not yet exercised

Notecard import (requires notecard in HUD inventory)
DEACTIVATED path from server
HTTP timeout (requires server-side slowness)
CHANGED_REGION cache purging

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.

Official · Linden Lab
Community guides
Tools & code
Where to ask

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:

  1. Port the easy script first, verbatim. Confirms your mental model of the translation before you start making design changes.
  2. 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.
  3. 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

27 April 2026 · correction
Gotcha 02 rewritten

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.

17 April 2026 · update
Domain-neutral language

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.

17 April 2026 · update
Added Migration strategy section

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.

17 April 2026 · update
Resources, quick-start, three extra gotchas

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.

17 April 2026
Initial publication

Both scripts ported and validated. All documented findings, gotchas and feedback reflect the state of the SLua beta as of mid-April 2026.