Skip to main content

Extending Fly.io's Distributed Turn-Based Game System, Part 1: Extism in Elixir

· 5 min read

Part 1: Extism in Elixir

Since I built Elixir support into Extism, I've been thinking about how to demonstrate the power of combining the two.

I wanted to explore wrapping a Wasm module in an Erlang process and see what kind of interesting things you could create when you leverage OTP. Along the way, I discovered that the programming model of an Extism plugin maps almost perfectly to the GenServer behavior.

This has some powerful implications! In this two-part post, I'm going to show why. Our goal is to extend this awesome idea of a turn-based game engine created by Fly.io. With just a couple invocations of the Extism library, we will allow users to create and upload their own turn based games.

Part 1 of this post will be explaining how to use Extism in Elixir and how to create this magic Extism GenServer.

Setting up Extism

Install Depdendencies

All you need to get started is rustup and elixir. If you have these installed you can skip to the next section.

Use Docker

If you want a temporary Dockerized environment, run these commands:

mkdir /tmp/elixir-blogpost
cd /tmp/elixir-blogpost
curl https://extism.org/data/elixir-blogpost/Dockerfile > Dockerfile
docker build -t game_box .
docker run -it game_box bash

Create an Elixir project

Now let's create a new Elixir project with mix:

mix new game_box
cd game_box

Add Extism as a dependency in mix.exs

  defp deps do
[
# ...
{:extism, "~> 0.1.0"},
]
end

Fecth the deps and compile:

mix do deps.get, compile

This should kick off a rust build of the Extism package and runtime and it should end something like this:

Finished release [optimized] target(s) in 3m 39s
Generated extism app
Getting Support

If you do run into a problem at any point please file an issue or reach out on Discord.

Running a plugin

Let's test this out in a repl. Before we do that, let's pull down a compiled wasm plugin to execute: This is our "count-vowels" example plugin:

curl https://raw.githubusercontent.com/extism/extism/main/wasm/code.wasm > code.wasm

Now open an IEx repl:

iex -S mix

Paste in this code. This creates an Extism Context and loads the Plugin. Then it calls the count_vowels function on the plugin with a test string and returns the output of the plugin (which in this case is a JSON encoded result).

ctx = Extism.Context.new
manifest = %{ wasm: [ %{ path: "./code.wasm" } ]}
{:ok, plugin} = Extism.Context.new_plugin(ctx, manifest, false)
{:ok, output} = Extism.Plugin.call(plugin, "count_vowels", "this is a test")
JSON.decode!(output)

If all is working you'll see {"count" => 4} in the output string:

iex(5)> JSON.decode!(output)
%{"count" => 4}

Utilizing OTP

Okay, how do we turn this into a GenServer? There would be two approaches:

  1. Wrap the Extism.Context in the GenServer
    • Store the Context as the state
    • Provide callbacks to load and unload plugins
    • Provide callbacks to lookup and call those plugins
  2. Wrap the Extism.Plugin in the GenServer
    • Store both the context and the plugin as state
    • Provide callbacks to load and reload the plugin code
    • Provide callbacks to call functions on the plugin
What is a Context?

You can think of a Context as an arena of plugins. When a plugin is loaded, the context owns that plugin and is responsible for freeing it. Freeing a context frees all its plugins.

I went with #2 here because I think it's more granular and allows for more flexibility and concurrency. There isn't much overhead to have one context per plugin so it's okay to do it this way.

With that in mind, I implemented this barebones GenServer in lib/game_box.ex. Go ahead and copy paste it into that file:

defmodule GameBox.PluginServer do
use GenServer

@impl true
def init(_init_arg) do
ctx = Extism.Context.new()
# as our state we will store a {Extism.Context, Extism.Plugin} tuple
{:ok, {ctx, nil}}
end

# This special call is for loading or reloading a plugin given a manifest
@impl true
def handle_call({:new, manifest, wasi}, _from, {ctx, plugin}) do
# if we have an existing Plugin let's free it
if plugin do
Extism.Plugin.free(plugin)
end
# Load a new plugin given the manifest and store it in the new state
{:ok, plugin} = Extism.Context.new_plugin(ctx, manifest, wasi)
{:reply, {:ok, plugin}, {ctx, plugin}}
end

# this is a generic way to proxy messages to the underlying Extism.Plugin module
# we're mostly going to use `call` here:
# e.g. call_details = {:call, "count_vowels", "this is a test"}
@impl true
def handle_call(call_details, _from, {ctx, plugin}) do
[func_name | args] = Tuple.to_list(call_details)
response = apply(Extism.Plugin, func_name, [plugin | args])
{:reply, response, {ctx, plugin}}
end
end

Okay, let's try the count vowels example again but now with a GenServer. Start the repl again:

$ iex -S mix

iex(1)> {:ok, pid} = GenServer.start_link(GameBox.PluginServer, nil)
{:ok, #PID<0.220.0>}
iex(2)> GenServer.call(pid, {:new, %{wasm: [%{path: "./code.wasm"}]}, false})
{:ok,
%Extism.Plugin{
ctx: %Extism.Context{ptr: #Reference<0.584822572.876216322.21881>},
plugin_id: 0
}}
iex(3)> GenServer.call(pid, {:call, "count_vowels", "this is a test"})
{:ok, "{\"count\": 4}"}

Now we're doing the same thing as before, but instead of invoking a function on the plugin, we're invoking it on a pid. What does this inversion give us exactly? Well, because it's a pid, it can live anywhere in your cluster. And it can be supervised and registered by OTP.

This also means we can replace any GenServer in our application, or in a framework like Phoenix, with a Wasm module. This would allow your customers to extend your application or maybe allow you to write part of your Elixir application in a language better suited to the task of the GenServer you are replacing.

Check out part II of this blog series where we show how this PluginServer can be used like a Phoenix.LiveView module.

We also invite you to get in on the action! Join us on Discord.