How I made my game moddable using Zig and WebAssembly

I wrote a blog post about my slightly unusual way to add modding support to my game, using Zig and WASM. I’ve been sitting on this blog post for a while. Figured it was finally time to finish it up.

Feedback is welcome!

26 Likes

This is the way :smiley:

I was expecting that this wasm-micro-runtime is interpreting, but apparently it can do either AOT or JIT (GitHub - bytecodealliance/wasm-micro-runtime: WebAssembly Micro Runtime (WAMR) · GitHub). Really cool!

1 Like

That’s a great use of WebAssembly beyond the browser. Basically the c++ game engine becomes the kernel/runtime. The WebAsm becomes the scripting intermediate language calling/called by the engine. Potentially you could use any language that can target WebAsm for the mod/scripting part. Thanks for sharing the details of how each component interact. Kudos for a cool project.

2 Likes

Yep, I’ve actually done minimal mod tests using Odin, C3 and Jai, just for fun, and I’ve done it all on live stream. You can find the relevant streams here:

Odin (and a bit of C3): https://www.youtube.com/watch?v=cCdbhAWj9Ig

C3: https://www.youtube.com/watch?v=x_Bv5l2qpxI

Jai: https://www.youtube.com/watch?v=v4tZ4az2n98

I’ve only done the dll part though for those languages, but transitioning to WASM should be much the same as for Zig. Not saying I would be providing official support for any of those languages, but trying different ones is fun :slight_smile:

EDIT: Changed the links to code tags, as this reply becomes quite obnoxious when all of the YT links expand into embedded players.

5 Likes

Did you look into other solutions btw? Recently emulating RISCV has become quite popular:

Your project is very cool btw!

4 Likes

Thanks!

I am not very familiar with RISC-V but looking though those github repos it looks like it might be a viable alternative to wasm. I went for wasm since it is pretty widely supported and understood (despite the name being somewhat misleading) and I am trying to ship a game here so I figured it would be the pragmatic choice.

As I wrote in the blog post, I am trying to make using different runtime formats for mods as transparent as possible both for the mod writer and the game/engine developer, so perhaps RISC-V support could be added without having to change how the wasm/dll support works.

There are some pretty wild claims in those github repos about being 15-50 times faster than “other solutions”, so it should be profiled and compared against wasm. Performance hasn’t really been an issue for me so far though, so I’m not sure I would benefit all that much from another format.

I also don’t know how memory allocation, threading etc. work in the context of RISC-V, so all that would have to be figured out.

3 Likes

There wouldn’t be a standard for this like wasm has WASI, you’d have to do the platform interface yourself. As in the blog post you already stumbled on this issue by not using WASI, the answer is same, you’d have to provide your own thread_spawn thread_join functions :slight_smile:

1 Like

It’s not necessarily quite that simple. What surprised me with the WAMR runtime was that turning off WASI support seemed to completely disable multithreading support for the modules. I don’t need WASI threading since I don’t spawn any threads from the mod code, but I do need to run wasm code on multiple threads spawned by the engine and with WAMR that didn’t seem to be possible without WASI. That might have changed now, I should update my runtime version, but that was the case about 6 months ago.

Regardless, my original point with “I also don’t know how memory allocation, threading etc. work in the context of RISC-V, so all that would have to be figured out.” was that there can be surprises with these VMs even though things “should work” :slight_smile:

1 Like

The RISC-V idea is interesting indeed.

Small note about RISC-V: It’s just another instruction set for computers (as wasm is, as x86 is, as arm or aarch64 is, etc.). What makes it so interesting is that the base instruction set is extremely simple, but it is used widely enough (for example, as hardware; but them mostly with some more extensions) to have great build tools.
And because the base instruction set is so simple, it’s not that difficult to implement a RISC-V emulator.
I think that it could be a competitor to WASM because both are relatively simple to emulate, so they work on all host architectures (they work on x86 laptops, they work on aarch64 laptops, etc.).

The only thing to consider is how you actually deliver the functionalities. WASM just provides a simple mechanism to “export” functions, because (as far as I know) it is an instruction set and a binary format at the same time.
For RISC-V though, you’d have to think about how to call functions. You’d essentially have to make something similar to how DLLs work: they expose symbol names, and you can load the DLL and run the functions corresponding to the symbol names.

uvm32 explicitly states that it isn’t that well suited for FFI (foreign function interface), which means in this case that your RISC-V emulator host can’t interface that well with the RISC-V code you’re loading. The other project, rvscript, is written to be used with C++ (as far as I understood it).

But for uvm32 you could theoretically create an own host (that uses the uvm32 library) similar to the host from “host-mini” described in the README. There, you could define own syscalls to make threading etc. actually work. You could also define a system call that can be used to define functions (the user code in RISC-V calls a given syscall to export a function, and delivers e.g. the address and the name of the function; the host code sees that syscall and then can (in theory) call that exported function, similar to how interrupts work in hardware).

So it is theoretically possible with RISC-V (though if we speak about theoretically possible, it’s also theoretically possible to do it with Brainf**k). It would be an interesting journey to do, but I don’t know if it’s actually beneficial if you just want to get a moddable game.

1 Like

Thanks for the write-up! I think I’ll stay on wasm for now since it seems to be working pretty well, but I’ll keep RISC-V in mind for the future!

Neat! I guess in some ways this is theoretically possible for games that use Lua as the scripting layer too, but WASM is fantastic as a standard compiler target in the case that you want to use the full heft of Lua (meaning dynamic C libraries)…

I say with WASM you have ecosystem advantage for sure. And lots of tooling.

I’m not familiar with WAMR, but that’s definitely not an intrinsic limitation of WASM. However, the embeding environment is the one that needs to spawn the threads. WASI has a spec around how the embeder needs to spawn the threads. I’d bet that’s why WAMR has that implemented only for WASI.

As an FYI, I made a minimal example of getting WASM multithreading to work in the browser recently: https://codeberg.org/ScottRedig/zig-wasm-minimal-multithreading That’d show the Zig side of what needs to be done, though you’ll still need to figure out the WAMR side.

On the topic of RISC-V: A key aspect of WASM’s design is its verification. With this, code can be safely compiled directly to native code be verified to not escape the sandbox. Compiling to a different architecture and then emulating that will inherently lose a significant amount of this safety so it’ll have to, at a minimum, spend more time on runtime checks. It looks like WAMR specifically supports ahead of time compilation with llvm, so you might not be losing that much speed compared to native compilation.

6 Likes

yes, this matters a lot when running wasm on embeded devices. This ensures we don’t break / introduces vulnerabilities in consumer devices ! It’s a pity wamr can’t time limit the execution though.

There is eBPF (used to run code in linux kernel) as alternative if you want both static verification and more strict runtime execution limits. Unfortunately I’m only aware of https://github.com/iovisor/ubpf for userspace eBPF and then compiler / language support is less great than WASM and riscv.

With this, code can be safely compiled directly to native code be verified to not escape the sandbox.

I don’t see why this wouldn’t be possible with RISCV either. All you really need to make sure is that no syscalls can ever be made. Whether the code can crash and burn is different issue however :slight_smile:

All you really need to make sure is that no syscalls can ever be made.

See also: seccomp to restrict syscalls in Linux.

2 Likes

That locks down the entire process, right? We still need the game to be able to make syscalls. Only the user mod libraries should be sandboxed.

seccomp-bpf filters can filter based on specific memory region. This is only possible on linux though, I don’t think any other OS can do this.

With JIT however the JIT compiler is the one responsible emitting native code that can’t escape.

EDIT: I think openbsd is interesting because openbsd by default doesn’t allow syscalls. All system calls have to go through libc or the information of the call sites has to be given to the kernel pinsyscalls(2) - OpenBSD manual pages .