Home How To Finally Kill Lua
Post
Cancel

How To Finally Kill Lua

Why Kill Lua

We live in a world that increasingly uses software that needs to be embedded in other software. People use scripting to create game mods (with Epic Games releasing Verse as a dedicated game scripting language), Auth0 allows you to embed javascript functions directly into workflows, even basic tooling like nginx and redis allow you to embed scripts to run directly in process.

For years the king of embedded programming languages has been Lua. Lua, particularly with LuaJIT, is extremely fast, is sandboxed by default, and is easy to write in, being a very small language. It’s not hard to see why people choose it, unless you want to build your own language it’s the only game in town (this is not actually true, but existing alternatives like Wren and Squirrel have failed to dethrone it).

So today I’ve decided to write what properties I think a language would need to be a proper Lua killer, and build a language off of that.

Goals

  1. Fast. With LuaJIT running nearly as fast as C, we’re not dethroning it with Python-like speeds. We’ll need a language that runs at least in the same category as Go, if not rust.
  2. Easy. It should be a scripting language, if people need to care about manual memory management or a borrow-checker, it rules out kids building game mods with no prior experience or who have only learned scratch.
  3. Embeddable in other languages. You need to be able to call it from other languages and pass it functions that it can call without significant overhead.
  4. General Purpose. It should work equally well if we’re using it for making games, if we’re embedding it in a router or a database, if it’s part of a hosted low-code service, or if it’s a standalone language we can write programs in.
  5. Run untrusted, even malicious code. If we want to support hosted low-code solutions and game mod marketplaces, we’ll need to be able to run code from untrusted third parties, and trust that it cannot harm the system.

Syntax

This is probably the least important factor for the language. I’m going to assume a C-like language in the direction of Go, but this could just as easily be a Python-like. Because we want it to be easy to pick up, it probably will not be a haskell-like.

Compilation

Because it needs to be fast, we’ll go with a compiled language. Languages can obviously have different implementations, Lua certainly does, but I’m imagining an LLVM based compiler as a library that you can call from C++ or Rust and exec the binary directly or a Web Assembly based compiler that’s portable anywhere.

Static Typing

With it being a compiled language, we’ll want static typing so we don’t encounter the extra hashtable lookup you see in python any time you access an attribute. Because we’re designing a new language we’ll pick the standard 2026 programming language trade-offs for a compiled language:

  • Structs with Traits, but no classes or inheritance
  • Algebraic Data Types (Result/Optional)
  • Generics, but no higher kinded or higher rank types.

We’ll also want bit based number types (u8, i64, usize) because that matters to things like game performance.

Garbage Collected

We’ll go with a mark and sweep garbage collector similar to Go. You could implement a borrow-checker while still not allowing direct memory access but if we’re looking for something that’s easy for children to pick up for modding games, garbage collection feels like the way to go.

Capabilities

I talked previously about a programming language model where all programming language capabilities (ability to have side effects) are passed into the main() function. This model is perfect for an embedded language as you only need to pass in the capabilities you want the embedded language to have, and you don’t need to disable builtin functions like some javascript sandboxes do. Since you control the compiler, you can trust that even if you run malicious code it cannot do anything you didn’t allow it to do. Even deep CPU timing or row hammering attacks mean little if you don’t give it access to the current time and don’t give it the network ability to phone home.

Threading Model

Since our capabilities model takes care of software trying to have side effects it shouldn’t, we still have two vectors for malicious code to cause problems - denial of service by taking up the entire CPU, and denial of service by taking up too much memory.

CPU

If an attack writes a busy hot-loop that does nothing, it could take up 100% of the CPU and degrade service for others. While I originally looked into solving this by something like total functional programming to remove turing completeness and prove that the program must exit in some fixed number of operations, this is incredibly difficult to implement without going against our goal of being easy to use. The answer I found is that like most things, erlang already has an answer that other languages just haven’t figured out yet. Erlang uses a preemptive multithreading model rather than a cooperative model, like go and javascript have. It works by giving every thread a counter and every time it hits a fixed number of units of work the thread can be interrupted and something else gets scheduled. In our language, each embedded process is an actor, similar to erlang’s actor model, able to send messages back to the main program but fundamentally acting as its own process. If the number of units of work goes too high, we can schedule something else, or even cancel it as misbehaving. In an embedded game mod this would allow us to hard cap cycles so that we can ensure performance, at the cost of the mod’s actions not completing for some frames.

Memory

Similarly, when you spawn the actor you can set a fixed amount of memory it’s allowed to use. The actor may even spawn sub-actors of its own that share its memory pool and it can set limits on them. If an actor uses over the allotted memory it panics, which kills the actor allowing the parent to either respawn it, or mark it as misbehaving and let it stay dead. If some actors are expected to only use a certain amount of memory or live very short lives, they can be spawned with specific memory arenas such that garbage collection never has to run - you simply deallocate the entire block when the thread exits.

Wrapping Up

I said a lot about language options without providing any examples of code, which are always my least favorite programming language articles, so here are some examples, and keep in mind this is not about the syntax:

Embedded in Redis:

1
2
3
4
5
6
7
8
// Create counters based on the value passed in
fn main(argv: List<str>, redis: Redis): RedisResult {
  let count = redis.call("GET", argv[0])?;
  for i in range(int32(count)) {
    redis.call("SET", "counter_" + i.to_string(), "0"f);
  }
  return RedisResult.Ok();
}

What if kubernetes mutating webhooks were embedded programs, rather than webhooks:

1
2
3
4
5
// add the label 'foo: bar'
fn main(api: KubernetesAPI, deployment: AppsV1Deployment): AppsV1Deployment {
  deployment.metadata.labels["foo"] = "bar";
  return deployment;
}

In a game mod:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn use_health_potion(game: Game, player: Player, item: Item): void {
  // heal 1hp for 10 seconds
  player.add_buff("heal", 1, 10.0);
}

fn add_items(item_list: ItemList): void {
  let hp_def = Item{
    icon: "/assets/icons/health_potion.png",
    model: "/assets/models/health_potion.glb",
    stackable: true,
    consumable: true,
    is_tool: false,
    use_fn: use_health_potion,
  };
  item_list.register("health_potion", hp_def);
}

This is a space I’m extremely interested in exploring, and the goals here are fairly similar to my eternal side project, boringlang so I may try my hand at building something in this vein in the coming months. Feel free to reach out and let me know if you have any comments on the properties I think such a language should have, or if you’ve come up with a better way of solving these problems.

This post is licensed under CC BY 4.0 by the author.