Stream: platform development

Topic: godot


view this post on Zulip Scott Campbell (Sep 11 2026 at 13:00):

Godot platform - Phase 1 - Hello World edition.

Screenshot_20260911_191228-1.png

Implementation details... lifecycle walkthrough...

Step 0 - Create or have a basic host platform + roc app, ready to be intergrated.

Step 1 - define a godot extenstion .so, with entry point

[configuration]
entry_symbol = "roc_godot_library_init"

Step 2 - Dump the godot headers... gdextension_interface.h

godot --headless --dump-gdextension-interface

Step 3 - import headers, and make a shared object that has said entry point symbol and build the .so, don't forget the build.zig changes...

pub const gd = @cImport({
    @cInclude("gdextension_interface.h");
});

export fn roc_godot_library_init(
    p_get_proc_address: gd.GDExtensionInterfaceGetProcAddress,
    p_library: gd.GDExtensionClassLibraryPtr,
    r_initialization: *gd.GDExtensionInitialization,
) callconv(.c) gd.GDExtensionBool {
    get_proc_address = p_get_proc_address;
    library = p_library;

    r_initialization.* = .{
        .minimum_initialization_level = gd.GDEXTENSION_INITIALIZATION_SCENE,
        .userdata = null,
        .initialize = initialize,
        .deinitialize = deinitialize,
    };
    return 1;
}

fn initialize(userdata: ?*anyopaque, level: gd.GDExtensionInitializationLevel) callconv(.c) void {
    _ = userdata;
    if (level != gd.GDEXTENSION_INITIALIZATION_SCENE) return;

    std.debug.print("[./platform/src/gdextension.zig]: initialize at SCENE level\n", .{});

    registerRocHello();
}

# ... etc.

Step 4 - register a Class (node)..

fn registerRocHello() void {
    const register_class = load(
        "classdb_register_extension_class6",
        *const fn (
            gd.GDExtensionClassLibraryPtr,
            gd.GDExtensionConstStringNamePtr,
            gd.GDExtensionConstStringNamePtr,
            *const gd.GDExtensionClassCreationInfo6,
        ) callconv(.c) void,
    );

    var class_name = makeStringName("RocHello");
    var parent_name = makeStringName("Node");

    var info: gd.GDExtensionClassCreationInfo6 = std.mem.zeroes(gd.GDExtensionClassCreationInfo6);

    info.is_virtual = 0;
    info.is_abstract = 0;
    info.is_exposed = 1; // show in editor / ClassDB
    // info.is_runtime = 0; // if field exists
    // info.icon_path = null;

    info.get_virtual_func = getVirtual;

    info.create_instance_func = createInstance;
    info.free_instance_func = freeInstance;

    register_class(library, @ptrCast(&class_name), @ptrCast(&parent_name), &info);

    std.debug.print("[./platform/src/gdextension.zig]: registered RocHello\n", .{});
}

fn createInstance(
    class_userdata: ?*anyopaque,
    notify_postinitialize: gd.GDExtensionBool,
) callconv(.c) gd.GDExtensionObjectPtr {
    _ = class_userdata;
    _ = notify_postinitialize;

    const classdb_construct = load(
        "classdb_construct_object2",
        *const fn (gd.GDExtensionConstStringNamePtr) callconv(.c) gd.GDExtensionObjectPtr,
    );
    const object_set_instance = load(
        "object_set_instance",
        *const fn (
            gd.GDExtensionObjectPtr,
            gd.GDExtensionConstStringNamePtr,
            gd.GDExtensionClassInstancePtr,
        ) callconv(.c) void,
    );

    var parent_name = makeStringName("Node");
    var class_name = makeStringName("RocHello");

    const obj = classdb_construct(@ptrCast(&parent_name));
    if (obj == null) return null;

    const self = std.heap.c_allocator.create(RocHello) catch return null;
    self.* = .{ .object = obj };

    object_set_instance(obj, @ptrCast(&class_name), @ptrCast(self));

    return obj;
}

# .. etc

Step 5 - provide virtual func(s), aka router function; _ready -> fn my_ready().

fn getVirtual(
    class_userdata: ?*anyopaque,
    name: gd.GDExtensionConstStringNamePtr,
    hash: u32,
) callconv(.c) gd.GDExtensionClassCallVirtual {
    _ = class_userdata;
    _ = name;

    const _ready_HASH = 3218959716;
    if (hash == _ready_HASH) { // OR if (stringNameEq(name, "_ready")) {
        return rocHelloReady;
    }
    return null;
}

Step 6 - Import host platform from step 0

const abi = @import("roc_platform_abi.zig");
const host = @import("host.zig");

Step 7 - handle virtual func call

fn rocHelloReady(
    instance: gd.GDExtensionClassInstancePtr,
    args: [*c]const gd.GDExtensionConstTypePtr,
    ret: gd.GDExtensionTypePtr,
) callconv(.c) void {
    _ = instance;
    _ = args;
    _ = ret;
    std.debug.print("[./platform/src/gdextension.zig]: RocHello._ready()\n", .{});

    host.ensureRocHost();
    const args2 = abi.RocList(abi.RocStr).empty();
    _ = host.roc_main(args2);
}

Step 8 - Ensure roc host... make sure things are initialised & ready to go, unsure if we can call this just once, globally & earlier? so it's available for all godot scenes across the entire godot project at the top level, instead of checking per instance of a node?

Step 9 - Call host.roc_main(args)... [easier to get started with a export fn stub, prior to linking an extern fn.]

/// Roc entrypoint exported by the app under `provides { "roc_main": main_for_host! }`.
pub extern fn roc_main(args: abi.RocList(abi.RocStr)) callconv(.c) i32;
// pub export fn roc_main(args: abi.RocList(abi.RocStr)) callconv(.c) i32 {
//     _ = args;
//     std.debug.print("roc_main stub (Roc not linked yet)\n", .{});
//     return 0;
// }

Step 9 - Swap out the export stub with extern linked function... a royal pain, godot needs glibc linked library, not statically linked musl executable... and we also need the platform & roc app's code statically linked inside the single all-in-one .so file that we provide to godot... so we dig into the roc build...

Step 10 - build the roc app... roc build --no-cache --keep-temp --verbose ./examples/hello_godot/main.roc & pull out the roc_app_llvm_x64musl_speed_8398d73f.o or whatever temp files with the required symbols... and copy it into the zig project so we can link it in the build.zig for the .so godot entry-point build in step 1...

Step 11 - build the entry point project again, linked with the roc_main symbol... now we have a roc platform+app, as a zig .so lib... Unsure if roc can do something like roc build --lib with multiple specified exports... I struggled to get anything other then just roc_main.

This is the absolute bare minimum proof of concept hello world edition to prove that it's possible, with a not-so-great workflow. It's not a usable nor useful platform, with exposed godot APIs, nor full roc lang godot editor integration support, with incremental per-node-instance builds on save & hot reloading, and optimised release builds etc.

Probably need to make some kind of API generator and godot-to-roc type mapping / conversion.

And unsure, how this would compare to gdscript, C# or lua in terms of performance & ergonomics considering the OOP class paradigm and mutations etc. Or if roc is even a good fit or candidate.

And tbh, a lot of this is beyond my pay grade... I got a lot of help from LLMs. I have a general grasp of the high level concepts... not so much the technical low level implementation details. So don't expect much help, support or answers.

I'll git commit && push when I can be bothered... probably sooner rather then later.

Phase 2

...

Phase 3

...

view this post on Zulip Luke Boswell (Sep 11 2026 at 13:07):

You can have Roc spit out a .so directly if you prefer, see https://github.com/roc-lang/roc/blob/main/docs/langref/modules.md#targets

view this post on Zulip Luke Boswell (Sep 11 2026 at 13:08):

You could use something like

x64linux: { inputs: ["host.o", app], output: Shared },

Then roc build my_extension.roc would produce an my_extension.so

view this post on Zulip Anton (Sep 11 2026 at 13:22):

Roc as a gdscript alternative is something we've been interested in for a while!
I don't have time to dig into it right now but we definitely want this working well sometime in the future.

view this post on Zulip Dan G Knutson (Sep 11 2026 at 15:28):

The .so method might be a nice way to get back hot reload too. Like have only one gdextension for the Roc integration, and then that static integration is made to hot reload a .so spat out by the platform.

view this post on Zulip Scott Campbell (Sep 11 2026 at 16:24):

Luke Boswell said:

You can have Roc spit out a .so directly if you prefer, see https://github.com/roc-lang/roc/blob/main/docs/langref/modules.md#targets

Thanks, that helps!

No more fishing through temp directories.

view this post on Zulip Scott Campbell (Sep 13 2026 at 05:26):

Progress Update:
https://github.com/scottc/godot-roc
Screenshot_20260913_153248-1.png

Consider this to be the "pre-pre-pre-alpha-0 barely working" release, nothing has been implemented & tested except for "bootstrapping to 3D gameplay" path. Here be dragons.

For now... the "supported use case", is a single CharacterBody3D class, no hot reloading multi CharacterBody3D classes, hotreloading if you recompile a .so and replace the file... godot will detect and reload it, but editor and gameplay state preservation is NOT implemented & no in-editor support.

Separate text editor window, multiple tabs... productive workflow if you have short compile times & file change watcher->recompile... not perfect but good enough for the interm.

Just finished implementing the bare minimum apis for a "hello world" 3d platformer interactive gameplay simulation; core gameloop event handlers, keybinds, velocity impulses & physics processing.

view this post on Zulip Scott Campbell (Sep 13 2026 at 10:13):

Example:

app [main!, ready!, process!, init!, physics_process!] {
    roc: "nightly-2026-09-08-39a3f89",
    pf: platform "../../platform/main.roc",
}

import pf.Stdout
import pf.Godot

# Lifecycle - scene initialization hook.
# Called immediately after Godot base classes are avaliable @ scene initialization.
# register classes here.
init! : {} => {}
init! = |_| {
    _ = Stdout.line!("...") # some reason this line is needed, or it crashes. TODO: fix.
    # A class handle, can reference later, if needed.
    _handle = Godot.register_class!(
        # Class name:
        "RocPlayer",
        # Parent class (inherited):
        "CharacterBody3D"
        # A Godot physics body with agency, in 3D space.
        # Player characters & NPCs.
    )
    {}
}

gravity = 9.8 # TODO: Godot.get_gravity!(handle)
movement_speed = 2.0
idle_speed = 0.0
jump_force = 50.0

physics_process! : U64, F64 => {}
physics_process! = |handle, _delta| {
    # Note: physics_process!, runs at a fixed delta, so delta is optional to use here...
    # Note: process!, runs at a variable delta, once per render cycle.
    # Read the godot docos, to understand the differences.

    # Don't forget to set Godot's keybind to action mappings!
    is_forward = Godot.is_action_pressed!("forward") == 1
    is_left = Godot.is_action_pressed!("left") == 1
    is_right = Godot.is_action_pressed!("right") == 1
    is_back = Godot.is_action_pressed!("back") == 1
    is_jump = Godot.is_action_pressed!("jump") == 1

    # current velocity
    velocity = Godot.get_velocity!(handle)

    # next velocity
    vx =
        if is_right
            movement_speed
        else if is_left
            -movement_speed
        else
            idle_speed

    vz = if is_back
            movement_speed
        else if is_forward
            -movement_speed
        else
            idle_speed

    vy =
        velocity.y # preserve existing y-axis momentum, plus add vector modifiers:
        + -gravity
        + if is_jump and Godot.is_on_floor!(handle)
            jump_force
        else
            idle_speed

    Godot.set_velocity!(handle, { x: vx, y: vy, z: vz })

    # Process physics for this class / node.
    Godot.move_and_slide!(handle)

    {}
}

view this post on Zulip Scott Campbell (Sep 17 2026 at 18:34):

Progress Update (informal change log):

Fixed a few bugs with hot reloading & the multiple classes example.

...

Added engine support

godot:    4.7.2.stable.nixpkgs.ed1daf0bf
godot4.5: 4.5.1.stable.nixpkgs.f62fdbde1
redot:    26.2.stable.official.4f5b14aba
rex:      0.0.1.alpha.898. (aka "draconic engine")

This was achieved by targeting godot 4.5.1 ABI & runtime, which has the most compatibility... there wasn't many changes downgrading from 4.7.2 (previous target), was almost a drop-in replacement, except for 1 code change and 1 configuration change.

The Redot Engine fork maintains compatibility with godot 4.5.1, pulls features from godot, while adding it's own features (a in-built 3d terrain editor) & optimisations... (apparently it runs 5%~30% faster, depending on load).

The Draconic Engine fork, aka ReX (first alpha 0.0.1 release), but allows for breaking changes, with the intention of improving performance, to try catch up with unity & unreal. It seems to be compatible with 4.5.1 gdexts... but we could expect them to break the whole entire node/class concept, in favour of an ECS or something more CPU cache friendly etc. Which point it would break the godot 4.5.1 gdextension compatibility.And I'd probably need to drop support and launch a new language bindings project.

But the general point is... any engine/version/fork host runtime is supported, if the engine host runtime supports the godot 4.5.1 gdextensions ABI... and ideally some runtime behaviours.

And now you have some more options to choose from. Or can implement your own alternatives.

Added initial web (wasm32-emscripten) support

Just an entrypoint for godot emscripten dylink.0 (SIDE_MODULE=2).

It loads, no errors or crashes, just no behaviour either.

I just need to rework the existing native desktop code to work with web wasm target.

Started work on generating API bindings, for api completeness

I think this is the main blocker preventing this from actually being useful, having full godot API support.

If there are any high priority apis, I can just manually add those.

print, get_position, set_position, get_rotation, set_rotation, collisions & signals... come to mind.

I don't want to manually add every godot api... when there is a detailed 350_000 line json dump.

The json decoder was giving me grief, so i put this on pause... do the wasm support first, and revisit this later.

Added :roc: roc icon, to the scene panel (most important feature)

Screenshot_20260918_010430-1.png

It's... optional & configurable, or you can inherit the parent class icon (default behavior).

(I need to move the configuration for it, so it's co-located in the roc code next to class declaration, so it's nice to use, there are some godot apis for that. Low priority.)

view this post on Zulip Anton (Sep 18 2026 at 14:45):

The json decoder was giving me grief, so i put this on pause... do the wasm support first, and revisit this later.

Feel free to have your LLM create some issues (assuming that Roc bugs are the cause)

view this post on Zulip Scott Campbell (Sep 18 2026 at 16:37):

Ok, I think I've hit a roadblock with the godot wasm support...

So for Godot... it's not a traditional standalone wasm32 build, like most wasm modules.

It's specifically wasm32-emscripten that is required by godot.

emscripten has a concept of MAIN_MODULE & SIDE_MODULE.
MAIN_MODULE = godot

And godot(emscripten), is expecting the GDExtension roc wasm module to be built as a SIDE_MODULE.

And there are some tools (emcc) in the emscripten toolchain to build said file.

# input -> my_file.o   (relocatable, ie from zig or anywhere)

emcc my_game/my_file.o \
  -o my_game/my_file.wasm \
  -sSIDE_MODULE=2 \
  -sERROR_ON_UNDEFINED_SYMBOLS=0 \
  -sEXPORTED_FUNCTIONS='["_roc_godot_library_init"]' \
  -O2

# output -> my_file.wasm    (a non-relocatable linked emscripten side module that godot can use.)

I can build host.zig -> relocatable my_file.o.

So that's all well and good... I can make a functioning web wasm32 godot game that way... with JUST the zig host.

BUT... we're here to build a game with roc.

Roc takes a relocatable my_file.o (from zig), and produces a non-relocatable my_file.wasm (the final linked file).

And then! emcc needs a relocatable file, to produce the SIDE_MODULE.

So, for this to work, either roc needs produce either:

A) a relocatable file.

or

B) a final emscripten compatible SIDE_MODULE for godot (a special kind of wasm).

I guess I'll create a github issue for relocatable file output support? (Maybe output: Archive works?)
Unsure how much work that is, or if it's even feasible.

view this post on Zulip Scott Campbell (Sep 18 2026 at 16:45):

Anton said:

The json decoder was giving me grief, so i put this on pause... do the wasm support first, and revisit this later.

Feel free to have your LLM create some issues (assuming that Roc bugs are the cause)

Yeah maybe, when I'm working on the json again.

Unfortunately I don't have a LLM subscription.

view this post on Zulip Anton (Sep 18 2026 at 17:18):

I guess I'll create a github issue for relocatable file output support? (Maybe output: Archive works?)

I will check if we already support this.

view this post on Zulip Anton (Sep 18 2026 at 17:34):

output: Archive works but it needs a small change to work with emscriptem SIDE_MODULE linking, I'm looking into it now.

view this post on Zulip Richard Feldman (Sep 18 2026 at 18:30):

yeah we shouldn't couple to emscripten itself, but being able to emit compatible binaries sounds good

view this post on Zulip Anton (Sep 19 2026 at 12:14):

it needs a small change to work

My macOS 27 upgrade broke some stuff, so I need to fix that first.

view this post on Zulip Anton (Sep 19 2026 at 14:00):

PR#11474

view this post on Zulip Scott Campbell (Sep 19 2026 at 16:41):

I checkout the branch.

roc does indeed seem to produce a relocatable wasm file.

I gave it to emcc, after changing some flags it compiled.

Overall the PR is doing what it claims. You’re now past the relocation barrier and

view this post on Zulip Scott Campbell (Sep 19 2026 at 16:42):

Then running the resulting file in the browser, with godot.

index.js:1 Uncaught (in promise) CompileError: WebAssembly.instantiate(): Compiling function #5 failed: expected 0 elements on the stack for fallthru, found 3 @+1847
installHook.js:1 still waiting on run dependencies:
overrideMethod @ installHook.js:1
onPrintError @ index.js:467
(anonymous) @ index.js:1
installHook.js:1 dependency: loadDylibs
overrideMethod @ installHook.js:1
onPrintError @ index.js:467
(anonymous) @ index.js:1
installHook.js:1 (end of list)
overrideMethod @ installHook.js:1
onPrintError @ index.js:467
(anonymous) @ index.js:1
installHook.js:1 still waiting on run dependencies:
...

view this post on Zulip Scott Campbell (Sep 19 2026 at 16:42):

The hang is caused by a hard validation failure in the side module.

CompileError: WebAssembly.instantiate(): Compiling function #5 failed: expected 0 elements on the stack for fallthru, found 3 @+1847

This is not a Godot/loadDylibs progress-bar quirk (those exist, but they don’t produce this error). The browser’s Wasm validator is rejecting the module because function #5 leaves three extra values on the operand stack at a point where the control-flow type expects zero (a fall-through / end-of-block with empty result). Instantiation never succeeds, so loadDylibs never finishes and the engine stays stuck on the progress bar.-O0 only papered over the earlier Binaryen/wasm-opt failures; it did not produce a correctly typed module. -msimd128 was orthogonal—the stack imbalance is a pure control-flow / type-checking problem, not a missing SIMD feature.What this almost always meansIn the Wasm that Roc emitted (or that emcc/wasm-ld produced from the relocatable object) there is a block, if/else, or loop whose stack height at the end does not match the declared result type. Classic causes:

Function #5 is almost certainly the same function that previously showed the large block with the v128 stores and the “non-final block elements…” complaints.

view this post on Zulip Scott Campbell (Sep 19 2026 at 16:42):

I'll try troubleshoot some more, see if i can workaround it... or perhaps make a mini success or failure for reproduction to narrow down the issue.

view this post on Zulip Scott Campbell (Sep 23 2026 at 06:29):

FYI, I'm still working on this...

Just did a bit of a detour, re-doing my build system and ci pipeline.
Now it's just 1 command to go from platform source, to final game web app.

And now I'm now i'm actually investigating... adding debug symbol information so the names show.

with emcc flags:
-g --profiling --emit-symbol-map

emcc produces:

wasm-objdump -x my_game.wasm | grep -A200 'Function\['

Function[59]:
 - func[0] sig=9 <__wasm_call_ctors>
 - func[1] sig=9 <__wasm_apply_data_relocs>
 - func[2] sig=8 <roc_llvm_rc_decref_30>
 - func[3] sig=8 <roc_llvm_rc_decref_29>
 - func[4] sig=8 <roc_llvm_rc_decref_1>
 - func[5] sig=8 <roc_llvm_rc_decref_1_single_thread>
 - func[6] sig=9 <godot_roc_scene_init>
 - func[7] sig=9 <godot_roc_ready>
 - func[8] sig=10 <godot_roc_process>
 - func[9] sig=11 <godot_roc_physics_process>
... etc

And the problematic function index now in question is #6..

wasm-validate my_game.wasm

my_game.wasm:00008e2: error: type mismatch at end of function, expected [] but got [i32, i32, i32, i32]
my_game.wasm:0000a1e: error: type mismatch at end of function, expected [] but got [i32]
my_game.wasm:0000bb4: error: type mismatch in call, expected [i32, i32] but got [i32, i64]
my_game.wasm:0000d4e: error: type mismatch in call, expected [i32, i32] but got [i32, i64]
my_game.wasm:0000de8: error: type mismatch in call, expected [i32] but got [... i64]
my_game.wasm:0000e07: error: type mismatch in call, expected [i32] but got [... i64]
my_game.wasm:0000e3d: error: type mismatch in call, expected [i32, i32] but got [i64, i32]
my_game.wasm:0000e69: error: type mismatch in call, expected [i32, i32] but got [i64, i32]
my_game.wasm:0000e72: error: type mismatch in call, expected [i32] but got [i64]
my_game.wasm:0000fea: error: type mismatch at end of block, expected [] but got [i32]
my_game.wasm:0006a2c: error: invalid initializer: instruction not valid in initializer expression: i32.add
my_game.wasm:0006a9d: error: invalid initializer: instruction not valid in initializer expression: i32.add
my_game.wasm:0006ab9: error: invalid initializer: instruction not valid in initializer expression: i32.add
my_game.wasm:0006ad7: error: invalid initializer: instruction not valid in initializer expression: i32.add

3 kinds of errors...

So if we just take the first validation error, and look at the first "line number" 00008e2.

my_game.wasm:00008e2: error: type mismatch at end of function, expected [] but got [i32, i32, i32, i32]

objdump -d my_game

 0008db: 6a                         | i32.add
 0008dc: 24 80 80 80 80 00          | global.set 0 <__stack_pointer>
 0008e2: 0b                         | end
0008e5 func[7] <godot_roc_ready>:
 0008e6: 04 7f                      | local[0..3] type=i32

And sure enough, yup, end of function#6 = godot_roc_scene_init

Ok, so there are 4 i32's left on the stack at the end of the function... when they should have all been consumed... so there is a code generation bug somewhere in the final .wasm output.

In theory, just adding 4 drop calls at the end should "fix", the invalid wasm, but then could have bugs etc.

Now... i just need to find at which stage in the build pipeline... zig -> roc -> emcc. And wasm-validate at each step etc.

Scene init is already quite small:

extern fn godot_roc_scene_init() callconv(.c) void;
scene_init_for_host! : {} => {}
scene_init_for_host! = |{}| {
    _ = Godot.print!("[platform/main.roc] init_for_host!")
    _result = scene_init!({})
    {}
}

scene_init! : {} => {}
scene_init! = |_| {
    _ = Godot.print!("Hello World!")

    MyPlayerCharacter.register_class!()
    Npc.register_class!()
}

class_name = "MyPlayerCharacter"
parent_class = "CharacterBody3D"

register_class! = || {
    _handle = Godot.register_class!(
        class_name,
        parent_class
    )
    {}
}

So i assume it's roc, unless it's something in print, or register_class...

roc produces an archive with 2 valid wasm files, the zig host is valid, same with the roc app...

# extract
[anon@nixos:~/Projects/godot-roc]$ ar x roc-out/my_game.a

# validate
[anon@nixos:~/Projects/godot-roc]$ wasm-validate libhost.o.wasm

[anon@nixos:~/Projects/godot-roc]$ wasm-validate roc_app_llvm_wasm32_speed.o

# no validation errors...

Seems like an emcc failure...?

Ok, after digging into emcc... it seems emcc is a driver for wasm-ld

[anon@nixos:~/Projects/godot-roc]$ wasm-ld \
  --no-entry \
  --export-dynamic \
  --import-memory \
  --import-table \
  -shared \
  -o test.wasm \
  libhost.o.wasm \
  roc_app_llvm_wasm32_speed.o
wasm-ld: warning: creating shared libraries, with -shared, is not yet stable
wasm-ld: warning: function signature mismatch: godot_roc_register_class
>>> defined as (i32, i32, i32) -> void in roc_app_llvm_wasm32_speed.o
>>> defined as (i32, i32) -> void in libhost.o.wasm

wasm-ld: warning: function signature mismatch: godot_roc_set_velocity
>>> defined as (i64, i32) -> void in roc_app_llvm_wasm32_speed.o
>>> defined as (i32, i32) -> void in libhost.o.wasm

wasm-ld: warning: function signature mismatch: godot_roc_is_on_floor
>>> defined as (i64) -> i32 in roc_app_llvm_wasm32_speed.o
>>> defined as (i32) -> i32 in libhost.o.wasm

wasm-ld: warning: function signature mismatch: godot_roc_print
>>> defined as (i32, i32) -> void in roc_app_llvm_wasm32_speed.o
>>> defined as (i32) -> void in libhost.o.wasm

wasm-ld: warning: function signature mismatch: godot_roc_get_velocity
>>> defined as (i32, i64) -> void in roc_app_llvm_wasm32_speed.o
>>> defined as (i32, i32) -> void in libhost.o.wasm

wasm-ld: warning: function signature mismatch: godot_roc_move_and_slide
>>> defined as (i64) -> void in roc_app_llvm_wasm32_speed.o
>>> defined as (i32) -> void in libhost.o.wasm

So it seems the problem is with mismatching ABIs, aka glue.

Let's investigate just the first one...

wasm-ld: warning: function signature mismatch: godot_roc_register_class
>>> defined as (i32, i32, i32) -> void in roc_app_llvm_wasm32_speed.o
>>> defined as (i32, i32) -> void in libhost.o.wasm

host.zig

export fn godot_roc_register_class(
    class_name: abi.RocStr,
    parent_class_name: abi.RocStr,
) callconv(.c) void {

roc_platform_abi.zig

/// Hosted symbol for Host.register_class!
/// Roc signature: Str, Str => Try({}, [RegisterClassErr(Str)])
/// Owned arguments. Release each exactly once before returning, unless it is
/// moved into storage or into the result:
///     arg0.decref(roc_host);
///     arg1.decref(roc_host);
/// The result is owned by Roc: return exactly one owned reference.
pub extern fn godot_roc_register_class(arg0: RocStr, arg1: RocStr) callconv(.c) HostRegister_classResult;

platform/Host.roc

Host := [].{
    register_class! : Str, Str => Try({}, [RegisterClassErr(Str)])

Ah yup... I miss matched the return argument, I assume the 3rd i32... is the return value, as a writeable output "ret" (return) parameter.

Once, I correct these... hopefully it'll "just work".

view this post on Zulip Scott Campbell (Sep 23 2026 at 06:34):

TLDR: I mismatched the zig platform host <-> roc platform args.

platform/Host.roc:

Host := [].{
register_class! : Str, Str => Try({}, [RegisterClassErr(Str)])

glue extern:

pub extern fn godot_roc_register_class(
    arg0: RocStr,
    arg1: RocStr
) callconv(.c) HostRegister_classResult;

host.zig impl:

export fn godot_roc_register_class(
    class_name: abi.RocStr,
    parent_class_name: abi.RocStr,
) callconv(.c) void {} # <--- MISMATCH! wrong return type!

For 6 functions... I'll post an update once I've fixed my code.

view this post on Zulip Scott Campbell (Sep 23 2026 at 07:12):

Seems I'll need to have two platforms...

I need to pass around a GDExtensionObjectPtr, which is "pointer sized"... aka usized...

But there are no usized types on the roc side of things...

For wasm32, we need u32.
For x86_64 native linux, we need u64.

So the only solution, I can think of is to provide two platforms & two main-32.roc & main-64.roc entrypoints pointing to the respective platforms, and swap out a USIZE type.

module [
    USIZE,
    GDExtensionObjectPtr,
]

## Pointer-sized unsigned integer for the current target.
## Swap this file (or regenerate it) for wasm32 vs native.
USIZE : U64
USIZE : U32

## Godot object handle / pointer-sized value across the Roc ↔ host boundary.
GDExtensionObjectPtr : USIZE

Or somehow bind this host side, so we don't need to pass is across the boundry (ideal, & as per gdscript), so whenever roc app makes a call... the GDExtensionObjectPtr is attached as meta data for the function call, without being exposed roc side.

But for now, i'll just have two platforms... just to get a prototype web platform working and fix the design issues later.

Not to mention, that it's quite unsafe & insecure... because roc side, we could pass in any random number as a memory address, and start corrupting memory etc. If godot or the zig host doesn't validate it.

view this post on Zulip Scott Campbell (Sep 23 2026 at 11:16):

Yay!, wasm32 web (emscripten) target is now working.

Screenshot_20260923_191049.png

Ignore the error labels, that's expected... just me being lazy and binding the print function to the print_error function.

And CI is using a blank project, so there is no scene, a game dev needs to add a scene and some game objects to it etc.

Maybe I'll make a quick demo, and upload it somewhere...

Screenshot_20260923_194128.png

view this post on Zulip Scott Campbell (Sep 23 2026 at 11:20):

Anton said:

PR#11474

Resolved two issues in the platform, wasm32-emscripten (web) works with the PR branch.

Thanks again for the help.

view this post on Zulip Scott Campbell (Sep 23 2026 at 12:10):

Functioning live web demo:

https://scottc.github.io/godot-roc/ godot 4.7.2

https://scottc.github.io/redot-roc/ redot 26.2

Keybinds:
WASD / arrow keys = move
Space / enter = jump

view this post on Zulip Anton (Sep 23 2026 at 13:51):

Awesome @Scott Campbell, looks like it required determination :muscle:

view this post on Zulip Luke Boswell (Sep 23 2026 at 23:07):

Love to see all the experimentation here :grinning_face_with_smiling_eyes:

view this post on Zulip Scott Campbell (Sep 24 2026 at 12:48):

I just discovered the web editor
https://editor.godotengine.org/

:smiling_devil: :see_no_evil:


Last updated: Sep 24 2026 at 15:59 UTC