Engine interop

Usage guide for Godot, Unity, Unreal and native developers: call any kit service, receive kit events and hooks, and serve a capability from your engine in place of a kit (your own terrain answers world.ground, the third-person kit stands on it), with the contract of the services each kit consumes.

Source: module-kits:docs/usage/engine-interop/en.mdView Markdown source

Also read offline — CLI: grip-forge docs engine-interopMCP tool: gripforge_docs { "topic": "engine-interop" }

Kits and your engine, both ways #

GripForge kits are Rust bricks that talk to each other only through capabilities: a kit that needs the ground asks the capability world.ground, it does not know which brick answers. Your engine is a brick like the others. Everything works in both directions, on every engine:

You want to…Godot (GDScript)Unity (C#)Unreal (C++ / Blueprint)Bevy (Rust)C (gfkits.h)
call a kitcall_serviceCallServiceCallServicecall_with, kit_callgfk_call
listen to a kit's eventswatch + game_eventWatch + gameEventWatch + OnGameEventwatch + MessageReader<KitEvent>gfk_events_watch, gfk_events_take
change a kit's decision (hook)tap_hookTapHookTapHook / TapHookEventtap_kit_hookgfk_hook_tap
serve a capability yourselfprovide_serviceProvideProvideServiceprovide_ground, provide_kit_resource, provide_kit_servicegfk_provide

The last line is what lets the choice you made in your engine — your own terrain, your own camera, your own inventory screen — keep working: the kits use your brick exactly as they would use a kit.

Plug in your own brick (serve a capability) #

What happens #

  1. You give the host an object for a capability (world.ground).
  2. When a kit calls world.ground.groundY(x, z), the host calls the method groundY of your object with x, z and gives its return value back to the kit.
  3. has_service("world.ground") answers true for every kit: the kits that only use the capability when it exists switch it on by themselves.

Give it before starting the game when a kit needs it at mount time (the third-person kit asks world.ground.defaultSpawn when it places the player). Given later, it is used from the next call. It stays across a reload of the game and a stop / start of the host, until you remove it.

In place of a kit or beside it. By default your brick replaces a kit that serves the same capability (your choice wins; the kit keeps running for its other capabilities). Pass replace = false to only fill a gap: a kit that serves the capability answers, yours answers when none does.

Rules for the provider. It runs on the game thread, inside the step of the host: answer quickly and do not call the host back from it (the call is refused). Return a plain value: a number, a string, a boolean, an array or a dictionary. Calls whose arguments are all numbers (groundY(x, z), made often) reach your engine without any JSON.

Godot: your own terrain under the third-person kit #

A complete example: a Godot terrain (any mesh with collisions) and the third-person kit, without world.terrain and without a ray of your own. This is the scene of the test wrappers/demo/godot/tests/provide_service_test.gd.

# my_terrain.gd — on the node of your terrain
extends Node3D

@export var heightmap: Image          # or read your terrain resource, or cast into your own physics world

func height_at(x: float, z: float) -> float:
	return 3.0                          # replace with your terrain's height at (x, z)

# world.ground, the methods the kits call (camelCase as in the contract; ground_y also works)
func groundY(x: float, z: float) -> float:
	return height_at(x, z)

func defaultSpawn() -> Dictionary:
	return {"position": [0.0, height_at(0.0, 0.0), 0.0], "yaw": 0.0}

func bounds() -> Dictionary:
	return {"min": [-500.0, 0.0, -500.0], "max": [500.0, 50.0, 500.0]}
# main.gd
@onready var host: GfGameHost = $GfGameHost
@onready var terrain = $MyTerrain

func _ready() -> void:
	host.provide_service("world.ground", terrain)   # before start(): the spawn already asks it
	host.load_game("res://game/my.game.json")
	host.start()

The capsule of the player moves through the physics of Godot (physics: "engine", the default), which already sees the collisions of your terrain; world.ground tells the kit where the ground is when it places the player (spawn, scene entry, respawn). In the lockfile, the capability is resolved to the host: "capabilities": { "world.ground": { "provider": "host" } } — no kit has to be installed for it.

Other forms:

host.provide_service("world.ground", terrain, false)       # only when no kit serves world.ground
host.provide_service("camera.look", func(method, args): return my_camera.call(method, args))  # a Callable
host.unprovide_service("world.ground")                     # the kit answers again, if any
print(host.provided_services())                            # [{ "capability": "world.ground", "replace": true }]

Unity #

using GripForge.Kits;

public class MyTerrain : MonoBehaviour, IGfService, IGfNumberService
{
    public GfGameHost host;
    float HeightAt(double x, double z) => Terrain.activeTerrain.SampleHeight(new Vector3((float)x, 0, (float)z));

    void Awake() => host.Provide("world.ground", this);          // before StartGame

    // Calls whose arguments are all numbers: no JSON.
    public bool ServeNumbers(string method, double[] args, out double result)
    {
        result = 0;
        if (method != "groundY") return false;
        result = HeightAt(args[0], args[1]);
        return true;
    }

    // Any other call: the arguments and the answer as JSON text.
    public string Serve(string method, string argsJson) => method switch
    {
        "defaultSpawn" => $"{{\"position\":[0,{HeightAt(0, 0)},0],\"yaw\":0}}",
        _ => throw new GfServiceRefused($"no method {method}"),
    };
}

A delegate works too: host.Provide("world.ground", (method, args) => …) answers any value of the GfJson model. host.Unprovide("world.ground") removes it, host.Provided() lists what Unity serves.

Unreal (C++ and Blueprint) #

Implement the interface IGfServiceProvider on any object — a C++ class or a Blueprint (Class Settings → Interfaces → GfServiceProvider) — and give it to the host or the component:

// MyTerrainActor.h
UCLASS()
class AMyTerrainActor : public AActor, public IGfServiceProvider
{
    GENERATED_BODY()
public:
    virtual FString Serve_Implementation(const FString& Method, const FString& ArgsJson) override;
    virtual bool ServeNumbers_Implementation(const FString& Method, const TArray<double>& Args, double& Result) override;
};

// MyTerrainActor.cpp
bool AMyTerrainActor::ServeNumbers_Implementation(const FString& Method, const TArray<double>& Args, double& Result)
{
    if (Method != TEXT("groundY") || Args.Num() < 2) return false;
    Result = HeightAt(Args[0], Args[1]);     // your landscape's height (a line trace, a heightmap…)
    return true;
}

FString AMyTerrainActor::Serve_Implementation(const FString& Method, const FString& ArgsJson)
{
    if (Method == TEXT("defaultSpawn"))
        return FString::Printf(TEXT("{\"position\":[0,%f,0],\"yaw\":0}"), HeightAt(0, 0));
    return FString();                        // no value: null
}

// Before StartGame:
GameComponent->ProvideService(TEXT("world.ground"), TerrainActor);

In a Blueprint, implement the events Serve (and optionally ServeNumbers) of the interface, then call Provide Service on the GfGameComponent with your actor. UnprovideService removes it, ProvidedServices lists them.

Bevy (Rust) #

A Bevy game links the kits as a Rust crate, gripforge-kithost-bevy (kits/game_engine/bevy): no C library, no text between your game and the kits. Arguments go as typed values (Arg::Num, Arg::Str, Arg::Vec3), answers come back typed (Reply, Ret) and are read in place through accessors (Val, KitPayload); a to_json() exists on the accessors for a tool or a debug print, never on the way. groundY(x, z) goes from the kit to your resource and back as two numbers and one number.

use bevy::prelude::*;
use gfkits_bevy::*;

#[derive(Resource)]
struct MyTerrain { /* your heightfield */ }

impl GroundService for MyTerrain {
    fn ground_y(&self, x: f64, z: f64) -> Option<f64> { Some(self.height_at(x, z)) }
    fn default_spawn(&self) -> Option<GroundSpawn> {
        Some(GroundSpawn { position: [0.0, self.height_at(0.0, 0.0), 0.0], yaw: 0.0 })
    }
}

App::new()
    .add_plugins((MinimalPlugins, KitsPlugin::new(my_game).watch("player.*")))
    .provide_ground::<MyTerrain>(Provide::Replace)      // world.ground, read in place in your resource
    .add_systems(Startup, spawn_my_terrain)              // the kits are mounted after Startup (KitsSet::Mount)
    .run();
  • provide_ground::<T>() serves world.ground from a resource implementing GroundService; provide_kit_resource::<T>(capability, mode) serves any capability from a resource implementing KitService (fn serve(&self, call: &ServiceCall) -> Result<Reply, String>); provide_kit_service(capability, mode, closure) serves it from a closure. ServiceCall reads the arguments (num(i), text(i), vec3(i)) and the game's resources (resource::<R>()) in place.
  • Provide::Replace (default) wins over a kit that serves the same capability; Provide::Beside only fills a gap. Given before the mount, the kits see it from their mount; given later (KitGame::provide), from the next call. It stays across a reload; KitGame::unprovide removes it, KitGame::provided lists them.
  • The game is mounted in PostStartup and steps in Update with Bevy's Time (KitsSet::Mount, KitsSet::Step). KitGame is a non-send resource (NonSendMut<KitGame>); in an exclusive system, world.kit_call(...) and world.with_kits(...) lend the world to the providers that read a resource.
  • Your Bevy terrain still needs a collision for the kits' physics (the kits run their own Rapier world: give it a static shape with game.host_mut().physics.add_static_box(...)).
  • Test: kits/game_engine/bevy/tests/own_terrain.rs (a terrain resource serves world.ground, the third-person player spawns on it, walks and stays grounded; events and a hook tap in the same app).

C and other native hosts #

static int32_t my_ground(void* user, const char* method, size_t method_len, const char* args, size_t args_len,
                         const double* nums, size_t nums_len, GfkReply* reply) {
    if (method_len == 7 && memcmp(method, "groundY", 7) == 0 && nums_len == 2) {
        gfk_reply_number(reply, my_height(nums[0], nums[1]));
        return 0;
    }
    if (method_len == 12 && memcmp(method, "defaultSpawn", 12) == 0) {
        const char* a = "{\"position\":[0,3,0],\"yaw\":0}";
        gfk_reply_json(reply, a, strlen(a));
        return 0;
    }
    const char* why = "\"no such method\"";
    gfk_reply_json(reply, why, strlen(why));
    return 1;                                   /* refused: the kit gets the reason */
}

gfk_provide(NULL, "world.ground", 12, 1 /* replace a kit */, my_ground, my_state); /* before gfk_game_create */
gfk_game_create(json, len, &game);

When every argument is a number, args is null and the numbers come in nums; otherwise args is the JSON array of the arguments and nums is null. gfk_unprovide removes a service, gfk_provided lists them.

Call a kit #

Every service of every installed kit is callable from the engine, with the method names and arguments of the kit's API reference (grip-forge docs <kit>, MCP gripforge_kit_api):

var hp = host.call_service("combat.health", "get", ["player"])
host.call_service("inventory.items", "give", ["herb", 1])
var pos = host.CallService("character.control", "position", new List<object>());
FString Pos = Host->CallService(TEXT("character.control"), TEXT("position"), TEXT("[]"));
// Bevy: typed arguments, the answer read in place.
let pos = game.call_vec3("character.control", "position", &[])?;
let hp = game.call_number("combat.health", "get", &[Arg::Str("player")])?;

A call made from the engine reaches your own provided services too: call_service("world.ground", "groundY", [0, 0]) answers what your terrain answers.

Listen to a kit #

Events. Watch a pattern, then receive the matching events after each step:

host.watch("combat.*")
host.game_event.connect(func(topic, payload, event): print(topic, payload))

Unity: host.Watch("combat.*") and the C# event gameEvent; Unreal: Watch and the Blueprint event OnGameEvent. Bevy: KitsPlugin::new(game).watch("combat.*"), then a system with MessageReader<KitEvent> after KitsSet::Step; the payload is read in place (event.payload.num("amount")), moved out of the kit host, not copied.

Hooks. A hook is a decision of a kit that the game may change before it applies (the damage of a hit, the speed of a jump). Tap it and answer the fields to change:

host.tap_hook("combat.damage", func(payload, hook): return {"set": {"multiply": 2.0}})

Unity: TapHook(hook, (payload, hook) => …); Unreal: TapHook (C++) or TapHookEvent (Blueprint); Bevy: app.tap_kit_hook("character.jump", 0.0, |call| HookReply::set("speed", Ret::Num(call.num("speed").unwrap_or(5.0) * 1.5))): the tap reads the payload in place and answers HookReply::Pass, HookReply::set(field, value) or HookReply::cancel(reason). The hooks of a kit and their fields are listed in its API reference (hooks), the same ones a mod uses (contract of game.engine).

Which services does a kit consume? #

Every kit declares the services it consumes in services.json next to its manifest: for each capability, whether it is required (listed in the kit's requires) or optional (the kit checks has_service and works without it), and the methods it calls. The same list is in the API reference of the kit (section consumes), which the CLI and the MCP show. Read it to know what your brick must answer. For the third-person kit:

CapabilityRequiredMethods called
world.groundyesdefaultSpawn() → { position: [x, y, z], yaw }, groundY(x, z) → height
input.actionsyesaxis, pressed, justPressed
combat.ranged, combat.melee, combat.healthnoused when installed
engine.hooksnorun (game.engine)

A kit that calls a capability or a method it did not declare fails the checks of the kit repository, so the list is always complete.

When it does not do what you expect #

  • The kit does not use my service. Check the capability name and the method names (case matters: groundY; Godot also accepts ground_y). Give the service before start when the kit reads it at mount time.
  • A kit still answers instead of mine. You passed replace = false; a kit serving the capability wins then.
  • "the provider failed (did it call the host back?)". The provider called the host during the step: compute the answer from your own data instead.
  • The player falls through my terrain. world.ground places the player; the collisions come from the physics. Keep physics: "engine" so the capsule moves in your engine's world, and give your terrain a collision shape.

Limits #

  • One provider per capability. Providing it again replaces the previous one.
  • The provider answers synchronously, on the game thread.
  • On the web, the page serves a capability with hostServices when it creates the game: a function fills a gap, { serve: fn, replace: true } replaces a kit.

Versions #

gfk_provide, gfk_unprovide, gfk_provided and gfk_reply_number: gfkits ABI 1.11 (library 0.2.22). provide_service (Godot), Provide (Unity) and ProvideService (Unreal) ship with the same library. Bevy: gripforge-kithost-bevy 0.1, typed providers over the kit host's provide_typed.