I’ve been wanting to integrate Tapestry with a game engine for a while now. Since I often use Unreal when contracting, my original thought had been Unity ( i’ve only used it on one project, and it’s always nice to try something new ), but with every going on there lately, i settled on Godot.

While Tapestry is written in Go, Godot – despite the name – is not. It’s written in C++, supports C# via Mono, and has its own GDScript language.

The main options for crossing that language boundary are:

  1. porting
  2. process hoisting ( communicating via sockets )
  3. cross-compiling

Porting is both fragile and time consuming. Hoisting is easy, but would have significant runtime overhead. Cross-compiling is goldilocks. For Alice ( which ran in the browser; well. electron. ) i used GopherJS. For Godot, the options are either: CGo and direct linking; or, WebAssembly ( probably running on Mono. )

CGo is the simplest route.

Steps Link to heading

  1. Set up the extension
  2. Create a bridge for go and godot
  3. Compile the go code using cgo
  4. Compile the godot extension using scons
  5. Loading and use the extension

Setting up the extension Link to heading

Godot has a template for creating an extension.

Basically, all that’s needed is to download the template and customize it. Their instructions are pretty good, so no need to repeat it all here.

Creating a bridge Link to heading

To talk between godot and go, first we need a bridge: code that interfaces with Godot on one side, and Golang on the other. As a first attempt, this bridge sends json back and forth using the same data language Tapestry already uses for its scripting.

The Godot side of the bridge is here. It takes two Godot strings, converts them to go strings, and calls a function Post() written in go. It expects a c-style ( also containing json ) in response, and turns that into a Godot variant ( of dictionaries, arrays, and primitive types. )

This is the godot/c++ side of things:

Variant Tapestry::post( const String& endpoint, const String& json ) {
  // convert the first string for go:
  CharString endChars = endpoint.utf8(); // copies
  GoString endGo = { endChars.ptr(), endChars.length() };
  // convert the second string for go:
  CharString jsChars = json.utf8();   // copies
  GoString jsGo = { jsChars.ptr(), jsChars.length() };
  // call our go-function, and get a result.
  // warning: the result memory is owned by go!
  const char * result = Post(endGo, jsGo);
  // interpret the response as json, and convert to a variant.
  // ( this copies the result, the variant is owned by godot/cpp. )
  return JSON::parse_string(result);
}

The big gotcha is memory management. Godot allocated memory and Golang allocated memory are unlikely to be pulling from the same heap. If those are different, having one side free memory allocated by the other side would be bad tm. The setup i chose avoids that issue.

Since Post() needs to allocate a string to return it, i also let Post() free that string on the next call ( see below. ) The result memory, therefore, stays valid between calls. That’s more than enough time because JSON::parse_string( result ) actually copies the string anyway. ( Multiple times, unfortunately. ) And therefore we don’t even need the memory after returning from Tapestry::post().

The Golang side of the bridge – its implementation of Post() – is here. It looks like this:

package main

// #include <stdlib.h>
import "C"

// tracks the string Post() hands to godot.
// Go (not godot) is responsible for both allocating it and freeing it.
var result unsafe.Pointer

//export Post
func Post(endpoint, msg string) (ret *C.char) {
  res, e := handlePost(endpoint, msg)
  if e != nil {
    // override "res" with the error;
    // better might be changing the error to json 
    // ex. `{"err":...}`
    res = e.Error()
  }
  // free memory from any prior result
  if result != nil {
    C.free(result)
  }
  // create memory for this new result
  ret = C.CString(res)
  result = unsafe.Pointer(ret)
  return
}

func handlePost(endpoint, msg string) (ret string, err error) {
  // in case anything goes wrong inside tapestry, don't crash godot.
  defer func() {
    if r := recover(); r != nil {
      err = fmt.Errorf("Recovered %s:\n%s", r, debug.Stack())
    }
  }()

  //
  // ...CALL TAPESTRY WITH ENDPOINT AND MSG HERE...
  //
  return
}

func main() {
  // main doesn't need to do anything.
}

The official cgo docs get into the details, but the notable bits are:

  1. Uses package main with a main() function ( which can be empty. )
  2. Must import "C", and use include comments to refer to any c functions it needs. ( especially // #include <stdlib.h> )
  3. Must use export comments to indicate which functions are exposed to godot. ( ex. //export Post gives Post extern c linkage, making it callable by godot. )
  4. Must handle strings and other memory as per the cgo docs.
  5. Should use a “recover” to catch any panics ( otherwise panics crash godot. )

All in all, though, pretty straight forward.

Compiling the go code Link to heading

To compile the golang side of the bridge, on Windows i used mingw via tdm-gcc. ( It should be possible to use the msvc toolchain as well. ) On MacOS, if you have xcode or its command line compiler, that’s all you need.

Once those tools are installed, all that’s necessary is:

> go build -o taplib.a -buildmode=c-archive taplib.go

The c-archive mode tells it to make static lib ( the taplib.a ). The other option is a c-shared dll. Since the godot extension itself is already a dll, using a dll for the go code would result in two dlls. So better to statically link the go code into the extension.

Compiling the extension Link to heading

Godot uses scons to build. Godot’s extension instructions comes with an SConstruct makefile which needs to be modified to include the cgo archive. This was the trickiest bit because i don’t know scons.

At the simplest, it needs the the manually built c-archive added as a dependency:

#!/usr/bin/env python
import os
import sys

env = SConscript("godot-cpp/SConstruct")
env.Append(LIBS=File('src/taplib.a'))  # <--- added this 
env.Append(CPPPATH=["src/"])
sources = Glob("src/*.cpp")

if env["platform"] == "macos":
    library = env.SharedLibrary(
        "demo/bin/libgdexample.{}.{}.framework/libgdexample.{}.{}".format(
            env["platform"], env["target"], env["platform"], env["target"]
        ),
        source=sources,
    )
else:
    library = env.SharedLibrary(
        "demo/bin/libgdexample{}{}".format(env["suffix"], env["SHLIBSUFFIX"]),
        source=sources,
    )
    
Default(library)

I also added some instructions to build the go code automatically. To be correct, it would need to use go list to detect stale dependencies. ( See “Possible Improvements” below. ) The complete SConstruct is here.

Then you need to run scons. I used the mingw option to match the go compiled side.

# windows:
> scons use_mingw=true

# macos
> scons arch=x86_64

On Windows: installing the vcredist might be necessary; i initially had some problems loading the extension without that. On MacOS: i had to get the latest command line tools.

Using the extension Link to heading

The bin directory of the godot project needs the compiled extension and a “manifest”. The one for Tapestry is here. It looks like:

# tapestry.gdextension
[configuration]
entry_symbol = "tapestry_library_init"
compatibility_minimum = 4.1

[libraries]
macos.debug = "res://bin/libtapestry.macos.template_debug.framework"
macos.release = "res://bin/libtapestry.macos.template_release.framework"
windows.debug.x86_64 = "res://bin/libtapestry.windows.template_debug.x86_64.dll"
windows.release.x86_64 = "res://bin/libtapestry.windows.template_release.x86_64.dll"

That’s it. The extension appears in godot as a global class, with the name as it appeared in the extension .cpp.

# send a json-friendly variant to tapestry, and get one in return
func _post(endpoint: String, msg: Variant) -> Variant:
  return Tapestry.post(endpoint, JSON.stringify(msg))

Possible Improvements: Link to heading

  • Move the command line flags into the scons script ( arch=x86_64 for osx, use_mingw=true for windows )
  • Use go list in the scons script to determine when to trigger go build
  • Build a universal macos extension ( requires building both architectures and packaging the results )