Practice your Go WebAssembly with a Game
Elevator Saga ported to Go WASM input
A few days ago, I wanted to take Go WebAssembly out for a spin, but I wasn’t sure what to build. Maybe a game ? but I didn’t really want to build a game from scratch. So I remembered the Elevator Saga, a programming game by Magnus Wolffelt I stumbled upon a few years ago: The goal is to transport people up and down building floors by controlling elevators, writing the rules in Javascript. What if I modified it to accept WebAssembly flavoured Go instead of JS !
Meet the Go Wasm Elevator Saga. The github repo is available here.
How it works
I’ve forked the original repository for the purposes of this exercise.
The original JS game is written as a Javascript App that takes the user input as text, runs a js eval() on it to transform it to a JS object and displays the results on the screen, moving the elevators accordingly.
In order to accept Go WASM as input we’ll need to compile it server-side. I’ve built a small Go API service that sits behind an Nginx reverse proxy, takes the user input, creates a .go source file, compiles it to WASM in a new docker container via the Docker Go API and returns the output binary to the browser.
Compiling the Go WASM
I’ve written a boilerplate main.go file that provides the setup required for the game to run and makes use of the functions Init and Updated that are provided by the user:
package main
import (
"syscall/js"
)
func main() {
// exit channel
ch := make(chan struct{})
js.Global().Get("console").Call("log", "Starting Wasm module ...")
// init game callback from user input
init := js.NewCallback(Init)
defer init.Release()
// update game callback from user input
update := js.NewCallback(Update)
defer update.Release()
// exit callback
exitWasm := js.NewCallback(func(args []js.Value) {
ch <- struct{}{}
})
defer exitWasm.Release()
// create js objects
c := make(map[string]interface{})
c["init"] = init
c["update"] = update
js.Global().Get("GoWasmBuilder").Set("codeObj", c)
js.Global().Get("GoWasmBuilder").Set("exitWasm", exitWasm)
// wait for exit signal
<-ch
js.Global().Get("console").Call("log", "Exiting Wasm module ...")
}
The default/starter user input displayed in the game is the following:
package main
import (
"syscall/js"
)
func Init(args []js.Value) {
elevators := args[0]
//floors := args[1]
elevator := elevators.Index(0) // Let's use the first elevator
// Whenever the elevator is idle (has no more queued destinations) ...
idleCb := js.NewCallback(func(args []js.Value) {
// let's go to all the floors (or did we forget one?)
elevator.Call("goToFloor", 0)
elevator.Call("goToFloor", 1)
})
// Attach callback
elevator.Call("on", "idle", idleCb)
}
func Update(args []js.Value) {
// We normally don't need to do anything here
}
The Init and Update functions above are the equivalents of the init and update functions required by the original game, as documented here https://didil.github.io/gowasm-elevatorsaga/documentation.html#docs
The API Server
The API/build server takes care of 2 important steps:
Copying the main.go file and the input.go file (from the HTTP post request) to a temp folder.
Running a Docker container which will perform the build, passing the previous temp folder as input. The Docker run is done via the Docker API and not via the shell, allowing for safer error handling.
package api
import (
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client"
"github.com/rs/cors"
)
// Start the api server
func Start() error {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/compile", handleCompile)
// cors
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowCredentials: true,
})
router := c.Handler(mux)
fmt.Println("Listening on port 3000")
return http.ListenAndServe(":3000", router)
}
type compileRequest struct {
Compiler string `json:"compiler,omitempty"`
Input string `json:"input,omitempty"`
}
// http handler
func handleCompile(w http.ResponseWriter, r *http.Request) {
cRequest := &compileRequest{}
err := json.NewDecoder(r.Body).Decode(cRequest)
if err != nil {
handleErr(w, err)
return
}
dir, err := createSourceFiles(cRequest)
if err != nil {
handleErr(w, err)
return
}
err = runCompile(dir)
if err != nil {
handleErr(w, err)
return
}
w.Header().Set("Content-Type", "application/wasm")
wasmF, err := os.Open(filepath.Join(dir, "app.wasm"))
if err != nil {
handleErr(w, err)
return
}
defer wasmF.Close()
io.Copy(w, wasmF)
}
func handleErr(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
// create source files
func createSourceFiles(cRequest *compileRequest) (string, error) {
dir, err := ioutil.TempDir("/tmp/", "gowasmbuilder-")
if err != nil {
return "", err
}
fmt.Println("Saving data to ", dir)
mainF, err := os.Open("./gowasm/main.go")
if err != nil {
return "", err
}
defer mainF.Close()
mainFCopy, err := os.Create(filepath.Join(dir, "main.go"))
if err != nil {
return "", err
}
defer mainFCopy.Close()
_, err = io.Copy(mainFCopy, mainF)
if err != nil {
return "", err
}
inputF, err := os.Create(filepath.Join(dir, "input.go"))
if err != nil {
return "", err
}
defer inputF.Close()
_, err = inputF.WriteString(cRequest.Input)
if err != nil {
return "", err
}
return dir, nil
}
// compile wasm using Docker API
func runCompile(dir string) error {
ctx := context.Background()
cli, err := client.NewEnvClient()
if err != nil {
return err
}
resp, err := cli.ContainerCreate(ctx, &container.Config{
Image: "didil/gowasmbuilder",
}, &container.HostConfig{
Mounts: []mount.Mount{
mount.Mount{Type: mount.TypeBind, Source: dir, Target: "/app/"},
},
}, nil, "")
if err != nil {
return err
}
if err := cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil {
return err
}
statusCh, errCh := cli.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning)
select {
case err := <-errCh:
if err != nil {
return err
}
case status := <-statusCh:
if status.StatusCode != 0 {
out, err := cli.ContainerLogs(ctx, resp.ID, types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true})
if err != nil {
return err
}
logs, err := ioutil.ReadAll(out)
if err != nil {
return err
}
return fmt.Errorf("Container exited with code %d\nlogs: %v", status.StatusCode, string(logs))
}
}
return nil
}
Dockerfile
The Dockerfile in itself is very simple and equivalent to running the following command in the directory containing our source files:
GOARCH=wasm GOOS=js go build -o app.wasm .
FROM golang:1.11.5-alpine
WORKDIR /app/
ENV GOARCH wasm
ENV GOOS js
CMD ["go", "build", "-o", "app.wasm" , "."]
JS Integration
The actual JS/WASM integration is done in this JS file :
window.GoWasmBuilder = {
exitWasm: null,
codeObj: null,
mod: null,
inst: null,
apiRoot: null,
async init(bytes) {
// load the go module
GoWasmBuilder.go = new Go();
let result = await WebAssembly.instantiate(bytes, GoWasmBuilder.go.importObject);
GoWasmBuilder.mod = result.module;
GoWasmBuilder.inst = result.instance;
},
run() {
// run the go module
GoWasmBuilder.go.run(GoWasmBuilder.inst)
},
async getCodeObjFromCode(code) {
// build json input
let json = JSON.stringify({ compiler: "go1.11.5", input: code, })
// hash json input
let hash = SparkMD5.hash(json);
// perform POST request
let resp = await fetch(GoWasmBuilder.apiRoot + "/api/v1/compile", {
method: 'POST',
headers: {
'Accept': 'application/json, application/xml, text/plain, text/html, *.*',
'Accept-Encoding': 'gzip',
'Content-Type': 'application/json',
'Code-Hash': hash,
},
body: json
})
if (!resp.ok){
let text = await resp.text()
throw new Error(text)
}
let bytes = await resp.arrayBuffer()
if (GoWasmBuilder.exitWasm) {
GoWasmBuilder.exitWasm()
GoWasmBuilder.exitWasm = null
}
// init module and run
await GoWasmBuilder.init(bytes)
GoWasmBuilder.run()
// get result object
let codeObj = GoWasmBuilder.codeObj
if (typeof codeObj.init !== "function") {
throw new Error("Code must contain an init function");
}
if (typeof codeObj.update !== "function") {
throw new Error("Code must contain an update function");
}
return codeObj
}
}
if (window.location.href.includes("localhost")){
GoWasmBuilder.apiRoot = "http://localhost:3000";
}
else {
GoWasmBuilder.apiRoot = "https://gowasm-elevatorsaga.leclouddev.com";
}
The JS code parses the server output, loads it into a WASM module and runs it.
Results
We run the game by clicking on Apply and … it works !
Compression
The Nginx reverse proxy performs GZIP compression on the WASM output, decreasing the size from ~1MB down to ~300KB. I might revisit the results with TinyGo at some point as they promise much smaller binaries.
Server Side Caching
In the JS code above, you might have noticed the line:
let hash = SparkMD5.hash(json);
We hash the JSON input, which contains the input code and the compiler version (always go1.11.5 at the moment). We send that hash in the HTTP header Code-Hash. Nginx uses that header to cache WASM output. If you run an input that has already been compiled, the compiled WASM will return directly from the Nginx cache and the request will never touch the Go server, decreasing latency and saving resources. Client Side Caching could also be added in the future.
Conclusion
I hope you’ll have fun playing with Go WASM ! Here are a few resources to help you get started with the syntax: