Crystal to Lua bridge

This post walks through how Crystal and Lua run inside the same program and talk to each other. We’ll use lua.cr, a Crystal shard I built that wraps the Lua 5.4 C API. The whole bridge between the two languages comes down to one small data structure: a stack.

The Lua stack

Lua is designed to be embedded in other programs, so those programs can be scripted or extended at runtime without recompiling. That’s why Lua runs inside Redis, Neovim, and World of Warcraft. To make that work, Lua and its host need a way to exchange values, but they can’t just share memory: different type systems, different garbage collectors, different ideas of what an object is.

So Lua picked the simplest thing that could work: a stack. The host puts values on it, Lua reads values off it, and function calls happen by leaving inputs and outputs on it. Here is an example:

Calling sum(3, 5) through the Lua stack

Watching the middle column reveals the whole conversation: things appear, the CALL happens, things rearrange, and a result pops out. Nothing else moves between Crystal and Lua. Just the stack.

Now let’s slow that down and look at each step.

Step 1. Crystal places things on the counter

Crystal has pushed the function and its arguments

Crystal wants to call sum(3, 5). To set that up, it does three pushes, in order:

  1. The function itself. In Lua, a function is just a value, like a number or a string — it can sit on the stack the same way. It goes on first, at the bottom.
  2. The first argument, 3.
  3. The second argument, 5.

Notice we never said “this is a function call with two arguments.” We didn’t need to. The shape of the stack already says everything: the bottom-most item is the function, the items above it are the arguments in order, the topmost item is the last argument. No extra “argument count,” no struct, no array — the stack itself tells how many things are there and in what order.

Step 2. The handoff

Lua takes the args off and runs

Crystal now says: “call this, with 2 arguments.”

Lua takes over. It peels the two arguments off the top — the very top becomes y, the one below becomes x — and the function underneath them gets activated. From Lua’s point of view this is just a normal function call; its arguments magically appeared as local variables.

The function runs:

function sum(x, y)
  return x + y
end

Lua computes x + y = 8 and is about to return. But there’s only one place where it can leave the result: back on the stack.

Step 3. The result

Lua left 8 on the stack; Crystal pops it

Lua clears out the function and its two arguments — they’ve been consumed — and puts 8 where they used to be. The call is over. The stack is one item tall again. Crystal reads the top, sees 8, and pops it off.

That’s the entire round-trip. Three pushes, one call, one pop.

A small aside: how does a function get on the stack?

We glossed over one thing. We said “push the function sum,” but a function isn’t something Crystal can hand to Lua the way it hands over the number 3. Lua functions live inside Lua — Crystal never holds one directly.

So how does sum end up on the stack? Usually one of two ways:

Either way, the pattern is the same: Lua owns the function, the stack is how it gets handed back and forth, and Crystal only ever holds a ticket to it. The same goes for tables, strings, anything that isn’t a plain number or boolean — Crystal works through the stack, never with Lua’s raw data.

This is why the stack matters so much: it’s not just where arguments go, it’s the only place Crystal can touch anything that lives inside Lua.

Why this works so well

It’s a clever little design once it clicks:

This same trick is used by Python’s C API, the JVM’s JNI, and basically every “embed a scripting language in my program” story. Lua just makes it especially small and especially obvious.

But wait — why not just write sum in Crystal?

Calling a two-line sum function through three pushes and a call is obviously overkill. Nobody embeds Lua to add numbers. The point is what becomes possible once the bridge exists:

The Crystal side keeps doing what Crystal is good at: fast, typed, compiled code. The Lua side does what Lua is good at: small, soft, changeable scripts. The stack is the bridge between them.

What this looks like in Crystal

I built lua.cr as a thin Crystal wrapper around this exact protocol. The class is literally called Lua::Stack, and pushing values uses <<:

require "lua"

lua = Lua.load

lua << 42
lua << "lua"
lua << true

puts lua.size  # => 3
puts lua.pop   # => true
puts lua.pop   # => "lua"
puts lua.pop   # => 42

lua.close

The whole sum(3, 5) round-trip from the animation, in actual Crystal code:

lua = Lua.load
sum = lua.run %q{
  function sum(x, y)
    return x + y
  end
  return sum
}

puts sum.as(Lua::Function).call(3, 5)  # => 8
lua.close

Function#call does exactly what the animation showed: push the function, push the arguments, ask Lua to call, read the result off the top.

Wrap-up

When two languages need to talk inside the same process and they can’t share memory directly, the simplest thing that works is to give them a stack — a tiny shared counter where one side puts things and the other side picks them up.

That’s the whole idea behind every Lua embedding, including lua.cr. Once the stack clicks, the rest of the API stops feeling like magic and starts feeling like exactly what it is: a polite handoff between two worlds that have agreed to meet in the middle.