A library, three decades
in the
making.
Lua.ex didn't start in 2026. It started in 1986 with Erlang, kept going in 2012 when Robert Virding gave the BEAM an imperative language, and arrived here after thirteen years of Luerl quietly powering everything from game servers to spaceships.
The tour of paradigms.
Erlang itself began as a Prolog interpreter for a telephony algebra in 1985–86 at Ericsson, before being rewritten as the JAM bytecode machine once Prolog proved too slow. So logic programming was literally in Erlang's DNA from day one.
After Erlang, Robert kept building languages on the BEAM as side projects. He wrote Erlog, a Prolog interpreter in Erlang, and then LFE (Lisp Flavored Erlang), prototyped in 2007 and first released to erlang-questions in March 2008.
With logic programming covered and a functional Lisp covered, the obvious missing paradigm was imperative. He picked Lua — small, clean, embeddable — and had at it. Luerl shipped on February 18, 2012.
"I wrote a Prolog on Erlang, that was fun. Then I wrote a functional Lisp in Erlang, that was fun too. But then I realized that it would be neat to implement an imperative or object-oriented language on the BEAM, so I picked Lua and had at it. That's how Luerl was born."
Mutable global state, on an immutable runtime.
The core challenge was philosophical as much as engineering: how do you build a language whose entire semantic model is mutable global state on top of a language whose entire semantic model is immutable local state?
The answer, it turns out, is what you'd do in Erlang anyway — thread the Lua state through every call as a value. No process dictionary, no ETS escape hatch. Straight Erlang.
#luerl{}
record passed through every call, idiomatic Erlang style.
{ok, _}/{error, _} for safe calls, raw exceptions for do/call.
A scripting language for streaming devices.
At TV Labs, we run vision-based integration tests against real Roku, Fire TV, Apple TV, and game-console hardware. To describe a test — "tap right, wait for the home screen, take a screenshot, assert the carousel" — I needed a scripting language I could embed, sandbox, and run thousands of times a day inside an Elixir release.
Luerl was already the answer. It was the mature, battle-tested Lua VM on the BEAM, and Robert had been quietly shipping it for twelve years. The only thing missing was an Elixir-shaped API.
So in 2024 I built one — the
tv-labs/lua
package on Hex — wrapping Luerl with the
deflua
macro, the ~LUA
sigil for compile-time syntax checking, and a
Lua.API
behaviour for registering Elixir modules with a Lua VM.
defmodule RemoteAPI do
use Lua.API
deflua tap(direction), state do
Device.tap(state.device, direction)
{[], state}
end
deflua screenshot(), state do
{[Vision.capture!(state.device)], state}
end
end
Lua.new()
|> Lua.load_api(RemoteAPI)
|> Lua.eval!("""
tap("right")
local img = screenshot()
assert(vision.contains(img, "Home"))
""")
Lua on the BEAM, together.
At Code BEAM Europe 2024 we ended up giving a talk together — Lua on the BEAM — covering Luerl's history, the Elixir wrapper, and what a next-generation Lua VM on the BEAM could look like.
Backstage, I asked Robert the obvious question: why bother writing a Lua interpreter in Erlang in the first place? That's when he told me the tour-of-paradigms story above. We spent the rest of the conference talking about what a Luerl 2.0 could look like — better error messages, real stack traces, memory sandboxing, all the things that Luerl's age made hard to retrofit.
From wrapper to native VM.
The wrapper got us most of the way there. But as the embedded surface area grew — more tools, more user scripts, more agents — the cracks showed. Error messages were terse. Stack traces stopped at the Luerl boundary. There was no way to see the bytecode the VM was actually running.
What started as a planned Luerl 2.0 became something else: a full rewrite, in Elixir, top to bottom. A new lexer, a new parser, a new register-based VM, a new compiler. Lua.ex 1.0 ships as a pure-Elixir implementation of Lua 5.3 — no Luerl in the dependency graph, no NIFs, no shelling out. Just Elixir.
The goal wasn't to outperform Luerl. It was to give Elixir developers the same developer experience they get with Phoenix or Ecto — beautiful errors, compile-time validation, code you can read when something goes wrong.
Real stack traces
Compile-time validation
~LUA sigil catches syntax errors at compile time.
Add c and ship a pre-compiled chunk in your release.
Bytecode you can read
Luerl is still the right choice for Erlang projects, and Robert is still actively shipping it. Lua.ex is the Elixir-native sibling — born from the same conversations, aimed at the same problem from the other side of the BEAM.
Thanks, Robert.
This library exists because Robert Virding wrote Luerl first. Thirteen years of design decisions, edge cases, and stdlib implementations live inside Lua.ex's lineage. We learned from all of them.
Credit also to the long-time Luerl contributors who shaped the API we ported from — Henning Diedrich, Cees de Groot, Heinz Gies, and dozens of others on the commit log. And to the Lua team in Rio for designing a language small enough to fit in your head and powerful enough to run Roblox.
Lua.ex is built at TV Labs, where we use it every day to script vision-based integration tests against real streaming devices.
Now go write some Lua.
The story is nice, but the VM is more fun. Open the playground and watch the opcodes flow.