Hosting a Lua Script inside an Elixir GenServer for Fun and Games
Before I get into the tech, I want to take a moment to talk about side projects. I have the luxury of enjoying my job and I routinely find the work I do to be fulfilling, educational, and a lot of fun. Despite all that, even the most fun work projects never quite scratch the right itch in my brain.
Writing my fantasy books exercises a totally different part of my brain that is rarely, if ever, involved in any of my work projects. Conversely, when I’m hard at work designing and building cloud native solutions at the office, the “fantasy world builder” part of my brain lies dormant, and starts to get cranky.
The only two times in my life where both of those aspects of my brain found satisfaction at the same time were when I was making levels for video games like Rainbow Six: Raven Shield or Hellboy, or when I was writing code for MUDs.
I recently decided that I needed to scratch that itch again — I needed a project that I would enjoy doing that used as much of my creativity as it did my technological know-how. I don’t intend to profit from this project, nor do I expect it will ever turn into anything other than an outlet for my own restless energy.
Unlike the most common type of MUD, I wanted mine to be player-buildable. Instead of the rooms in the game coming from a fixed set of properties in a database, I want the rooms (and all other objects) to allow for the maximum amount of creativity, and I want them to be editable live, online, by players, without ever taking anything else down.
This led me to my first decision — use GenServers to host tiny processes for individual game objects, and allow OTP and its clustering abilities to take care of my scaling concerns for me.
But how do I allow players to edit game content online? I thought about whether I wanted to build my own DSL or whether I might be able to use WebAssembly modules to control object behavior. I researched Elixir (and Erlang’s) ability to dynamically load code at runtime and eventually dismissed that for a few technical reasons.
While asking for help on the incredibly helpful Elixir slack community, the possibility of Lua came up. Lua is a scripting language that is designed to be embedded. It’s main purpose is to run as a library within some container process. The luerl project is an Erlang library that can load, parse, validate, and execute Lua scripts and thankfully, Erlang libraries are pretty easily called from Elixir.
After you perform any action on the Lua VM (it’s a stack mahine), the new state of that VM is returned as a result. This seemed perfect to me for embedding a script inside a GenServer that controlled a game object’s behavior. Because the script is loaded at runtime, theoretically wizards (administrative players) could write, test, and update scripts live without ever running a risk of bringing the game down. Back in my day, when I had to ride a 2400 baud modem uphill both ways to get to the nearest MUD, careless wizards would routinely take down entire games.
The idea I had for using a GenServer was that as the game engine dispatches events to individual game objects, the handle_cast
for that specific event can simply invoke a function on the GenServer’s embedded Lua script. We should also be able to inject functions into the context of the Lua script, giving developers an SDK that allows their code to affect the game — all safely and in real-time.
I started experimenting and am really pleased with how it’s turning out so far. Again I must thank the folks from the Elixir slack for helping me deal with learning the luerl
library and the Elixir->Erlang bridge. I’m also a Lua newbie, so the “what the hell am I doing?” factor was pretty high with this experiment.
So let’s take a look at how we can initialize a GenServer that hosts room scripts:
defmodule Game.Room do
use GenServer def start_link(room_script) do
GenServer.start_link(__MODULE__,
room_script,
name: pid(room_script), id: room_script)
end def pid(room_script) do
{:global, {Game.Room, room_script}}
end def init(room_script) do
root = :luerl.init()
{_result, state} =
:luerl.dofile(room_script |> String.to_charlist(), state)
{:ok, state}
end
end
This GenServer’s API requires that you specify the file path to a room script when it starts up. It then uses luerl
to execute the contents of that file. If I want to inject a bunch of functions (and I will to expose a game SDK) to allow the Lua script to call out to my own Elixir code, I can set those as tables within the script after calling init()
:
state = :luerl.set_table(["add_action"], fn(args, st) ->
IO.puts "added action"
IO.inspect args
{[], st}
end, state)
This is just a stub function at the moment, but what’s happening here is that a function called add_action
is made available to the script being executed via insertion into the state
variable. I imagine I’ll be using this technique to configure the whole game SDK to expose a suite of functions that allow Lua scripts to interact with players, objects, and the game engine itself.
In my basic room script, I want to expose the short()
and long()
functions which will be used to query the long and short descriptions of the room. I’ll make this API available to Elixir through standard GenServer API design patterns:
def handle_call(:get_long, _from, state) do
{result, state} = :luerl.call_function(["long"], [], state) {:reply, result, state}
enddef long(pid) do
[ long ] = GenServer.call(pid, :get_long) long
end
Since luerl
returns an array of results from each function execution, I just use a little pattern match to extract the string from the result. I’m skimping a little on error handling, but this is a prototype and I’ll tighten stuff like that up before I start pushing to github.
Now let’s take a look at what a basic room script looks like (aka the fun part):
add_item ("lever",
{"switch", "rusty lever"},
"There is a suspicious looking lever here.")add_action("pull", "lever", "pull_lever")function short()
return "A basic room"
endfunction long()
return "You find yourself in the most basic of rooms."
endfunction entered_inv (ob, from)
print("Object " .. ob.name .. " entered my inventory from " ..
from.name)
endfunction pull_lever (ob)
if ob.class == "paladin" then
tell(ob, "The lever glows slightly in your grasp.")
move(ob, "~/paladin_hq")
return true
end tell(ob, "The lever does not budge")
say(ob, ob.name .. " tries to pull the lever, but fails.")
return false
end
This room sits outside the Paladin headquarters. If a player pulls the lever (by typing pull lever
or an equivalent) then they will either be moved into the room managed by paladin_hq.lua
or their attempt will fail and everyone in the room will see their failure.
Already I’m starting to like this script. It gives me far more flexibility, power, creativity, and expressiveness than just defining rows (or JSON objects) in a database. Also, writing code to build worlds is that secret sauce that scratches both the creative and technical itches for me.
Let’s see how this plays out in iex
:
iex> {:ok, pid} = Game.Room.start_link("/home/kevin/game/basic_room.lua")
iex> Game.Room.long(pid)
"You find yourself in the most basic of rooms."iex> GenServer.cast(pid, {:do_action, %{name: "kevin", pid: "12", class: "paladin"}, "pull", "lever"})
telling object
:ok
[
[{"class", "paladin"}, {"name", "kevin"}, {"pid", "12"}],
"The lever glows slightly in your grasp."
]
moving object
[[{"class", "paladin"}, {"name", "kevin"}, {"pid", "12"}], "~/paladin_hq"]
There are some debug prints here, but the mind-blowing piece is that I’ve got logic (“is this player a paladin?”) executing inside the Lua script, and then the Lua script is invoking Lua functions that translate to Elixir functions (like move
and tell
and say
) which then interact with the rest of the game world.
If someone who is not a Paladin attempts to pull the lever, this is what we get:
iex> GenServer.cast(pid, {:do_action, %{name: "kevin", pid: "12", class: "not-a-paladin"}, "pull", "lever"})
telling object
:ok
[
[{"class", "not-a-paladin"}, {"name", "kevin"}, {"pid", "12"}],
"The lever does not budge"
]
emitting to all but
[
[{"class", "not-a-paladin"}, {"name", "kevin"}, {"pid", "12"}],
"kevin tries to pull the lever, but fails."
]
You can see the arguments passed to say
and tell
here that the player would receive the “The lever does not budge” message and all those around them would see the “kevin tries to pull the lever, but fails” message.
I’ve still got a ton of work to do, and I’m only just now discovering the architecture I want to use for the MUD, but I felt compelled to share some initial findings with you. I’m super excited to finally be able to spend some time building a creative project that also happens to have a lot of very cool technology supporting it.
I’ve got a github repo ready to go for this MUD, so I’ll be posting more updates as I get close to an empty scaffold for the project. It’ll all be open source, so hopefully people will have as much fun contributing to it as I will building and playing it.