Skip to content

05 — Engine Integration & Client API

Part of the Lattice networking suite design set. See also: README · 00 Overview · 01 High-Level Design · 02 Netcode LLD · 03 Auth Service · 04 Social Library · 06 Implementation Roadmap


1. Purpose & Scope

This document specifies how game code talks to Lattice: the stable C ABI that lattice-core exposes, and the idiomatic per-engine wrappers (lattice-unity, lattice-unreal, lattice-godot, lattice-web) layered on top of it.

The guiding constraint is ergonomics. Lattice must feel as light as Photon Fusion: a developer should start a session in one or two calls, declare a replicated property in one line, spawn a networked object in one call, and author an RPC with a single attribute/macro. Everything heavy — wire format, prediction/rollback, interest management, congestion control — lives behind the C ABI (see 02 Netcode LLD) and never leaks into gameplay code.

In scope:

  • The C ABI contract: handles, marshalling, memory ownership, callback dispatch.
  • How [Networked]-style declarations compile down to core serialization (see 02 §Serialization).
  • Idiomatic APIs and complete code samples for Unity (C#), Unreal (C++), Godot 4 (GDScript + C#), and the web (WASM) stretch target.
  • Prefab/asset registration and the network-object catalog.
  • Per-engine build/packaging, threading, and main-thread callback rules.

Out of scope: the netcode internals themselves (see 02), the backend services (03, 04), and fleet placement (01).


2. Binding Strategy

2.1 The Layer Stack

lattice-core is compiled to a single native shared library per platform (.dll / .so / .dylib, or .a/.lib for static link into Unreal/dedicated servers, or .wasm for web). Its only public surface is a flat extern "C" C ABI in lattice-core/abi/. Every engine wrapper is a thin translation layer over that surface; game code never sees the C ABI directly.

flowchart TD
    subgraph CORE["lattice-core (C++20)"]
        IMPL["transport · replication · prediction · simulation · serialization"]
        ABI(["abi/ : extern \"C\" stable C ABI (opaque handles, POD structs, callbacks)"])
        IMPL --> ABI
    end

    subgraph WRAP["Per-engine idiomatic wrappers"]
        UW["lattice-unity (C#, P/Invoke + SafeHandle)"]
        UEW["lattice-unreal (UE module, UObject wrappers)"]
        GW["lattice-godot (GDExtension, Node wrappers)"]
        WW["lattice-web (Emscripten cwrap + TS API)"]
    end

    subgraph GAME["Game code (what the developer writes)"]
        GC1["NetworkRunner.StartGame(Host)"]
        GC2["[Networked] float Health"]
        GC3["Runner.Spawn(playerPrefab)"]
        GC4["[Rpc] void Fire()"]
    end

    ABI -->|"P/Invoke"| UW
    ABI -->|"static/dynamic link"| UEW
    ABI -->|"GDExtension C iface"| GW
    ABI -->|"WASM exports"| WW

    UW --> GAME
    UEW --> GAME
    GW --> GAME
    WW --> GAME

Why this shape. A C++ class ABI is unstable across compilers and toolchains; a flat extern "C" surface is the universal contract callable from C# (P/Invoke), Unreal (direct C++ call), Godot (GDExtension's C interface), and WASM (Emscripten exports), and stays binary-stable across compiler versions. The wrapper's whole job is to make that flat surface feel native — MonoBehaviours in Unity, UObjects in Unreal, Nodes in Godot.

2.2 The C ABI Surface (representative)

The ABI deals exclusively in opaque handles, POD structs, and C function pointers. No C++ types, no STL, no exceptions cross the line. All functions return an lt_result status code; outputs are written through pointers.

/* lattice-core/abi/lattice.h  — representative excerpt */
#ifdef __cplusplus
extern "C" {
#endif

typedef enum { LT_OK = 0, LT_ERR_INVALID_ARG = 1, LT_ERR_NOT_AUTHORITY = 2,
               LT_ERR_NO_SESSION = 3, LT_ERR_UNKNOWN_PREFAB = 4 } lt_result;

typedef enum { LT_MODE_SERVER = 0, LT_MODE_HOST = 1,
               LT_MODE_CLIENT = 2, LT_MODE_SHARED_HOST = 3 } lt_mode;

/* Opaque handles — never dereferenced by the wrapper. */
typedef struct lt_runner_t*  lt_runner;       /* one per session            */
typedef uint32_t             lt_netid;        /* stable NetworkObject id     */
typedef uint16_t             lt_prefab_id;    /* index into the catalog      */
typedef uint16_t             lt_field_id;     /* [Networked] field slot      */

/* --- Lifecycle --- */
lt_result lt_runner_create(const lt_runner_config* cfg, lt_runner* out);
lt_result lt_runner_start(lt_runner r, lt_mode mode, const lt_start_args* args);
lt_result lt_runner_tick(lt_runner r, double dt);   /* pumps sim; engine drives */
lt_result lt_runner_shutdown(lt_runner r);
void      lt_runner_destroy(lt_runner r);

/* --- Catalog (spawnable types matched by id across peers) --- */
lt_result lt_register_prefab(lt_runner r, const char* stable_key,
                             const lt_object_schema* schema, lt_prefab_id* out_id);

/* --- Replication --- */
lt_result lt_spawn(lt_runner r, lt_prefab_id prefab, lt_peer owner,
                   const lt_transform* xform, lt_netid* out_id);
lt_result lt_despawn(lt_runner r, lt_netid id);
lt_result lt_field_write(lt_runner r, lt_netid id, lt_field_id f,
                         const void* src, uint32_t size);   /* authority only */
lt_result lt_field_read (lt_runner r, lt_netid id, lt_field_id f,
                         void* dst, uint32_t size);

/* --- RPCs --- */
lt_result lt_rpc_invoke(lt_runner r, lt_netid id, lt_field_id rpc_slot,
                        lt_rpc_target target, const void* payload, uint32_t size);

/* --- Ownership / authority --- */
lt_result lt_request_authority(lt_runner r, lt_netid id);
lt_bool   lt_has_authority(lt_runner r, lt_netid id);

/* --- Callbacks INTO the engine (called only from lt_runner_tick) --- */
typedef struct {
    void* user;                                            /* wrapper context */
    void (*on_spawned)   (void* user, lt_netid, lt_prefab_id, lt_peer owner);
    void (*on_despawned) (void* user, lt_netid);
    void (*on_field_changed)(void* user, lt_netid, lt_field_id);
    void (*on_rpc)       (void* user, lt_netid, lt_field_id slot,
                          const void* payload, uint32_t size);
    void (*on_input_poll)(void* user, lt_peer, void* out_input, uint32_t size);
    void (*on_sim_tick)  (void* user, uint32_t tick, double dt);
    void (*on_authority_changed)(void* user, lt_netid, lt_peer new_owner);
} lt_callbacks;

lt_result lt_runner_set_callbacks(lt_runner r, const lt_callbacks* cb);

#ifdef __cplusplus
}
#endif

2.3 Marshalling

Concern Rule at the boundary
Primitives Pass by value (int32, uint32, float, double, bool as uint8).
Structs Only POD structs with explicit, fixed-layout fields (e.g. lt_transform { float pos[3]; float rot[4]; }). No padding ambiguity: the ABI header pins layout; wrappers mirror it with [StructLayout(LayoutKind.Sequential)] (C#), matching struct (UE/Godot), or a typed-array view (WASM).
Strings UTF-8 const char* in; out-strings are written into a caller-provided buffer with a length, never returned as core-allocated pointers the caller must free.
Blobs ([Networked] field values, RPC payloads) Raw void* + size. The wrapper packs the engine value into a stack/pooled buffer and hands the core a pointer. The core owns the wire encoding (quantization, delta) — the blob crossing the ABI is the unencoded engine value; encoding happens inside core.
Errors Status codes only. No exceptions cross the ABI; wrappers translate non-LT_OK into engine-native exceptions/log errors.
Callbacks C function pointers + a void* user context (the wrapper's runner object). Fired only synchronously from inside lt_runner_tick, so they always land on the thread that called tick (the engine main/game thread).

2.4 Object Handles & Lifetime

A NetworkObject exists in two places: the core record (authoritative id, owner, replicated fields, in the core's arena) and the engine view (the GameObject/AActor/Node the player sees). The wrapper maintains a bidirectional map lt_netid <-> engine object.

flowchart TD
    A["Authority calls Spawn(prefab, owner, xform)"] --> B["wrapper: lt_spawn() over ABI"]
    B --> C["core: allocate netid, add to replication set"]
    C --> D["core: queue spawn to interested peers (see 02 AoI)"]
    C --> E["on_spawned fired in next lt_runner_tick (main thread)"]
    E --> F["wrapper: look up prefab_id in catalog -> instantiate engine object"]
    F --> G["wrapper: register map netid <-> engine object; bind [Networked] fields"]
    G --> H["game OnSpawned() callback runs"]

    subgraph LIFE["Lifetime & ownership rules"]
        R1["core owns the netid + field storage (its arena)"]
        R2["engine owns the visual object (GC / UObject / Node refcount)"]
        R3["Despawn -> on_despawned -> wrapper destroys engine object + unmaps"]
        R4["wrapper holds core via SafeHandle/RAII; destroy is idempotent"]
    end

Memory ownership contract:

  • Core owns all replication storage (netids, field blobs, snapshot buffers) inside its own arena. The wrapper never frees core memory; it calls lt_despawn / lt_runner_destroy.
  • Engine owns the visual object lifetime via its own GC/refcount. The wrapper destroys the engine object in response to on_despawned, and conversely a wrapper-initiated Despawn() calls into core first, then tears down the engine object on the resulting on_despawned.
  • No pointers are shared long-term. Game code holds an lt_netid (a 32-bit value) and an engine-object reference — never a raw core pointer. Handles are validated each call; a stale lt_netid returns LT_ERR_INVALID_ARG.
  • C# / WASM wrap the runner handle in a SafeHandle (C#) or a disposable JS object (web) so finalization is deterministic and double-free is impossible.

2.5 [Networked] → Core Serialization

A [Networked] property (or its UPROPERTY/exported equivalent) is not an ad-hoc field. At type-registration time the wrapper builds an lt_object_schema describing each replicated field: its slot id (lt_field_id, stable ordinal), its type/size, and replication hints (quantization, interpolation, predicted-vs-replicated). That schema is what lt_register_prefab hands the core.

flowchart LR
    DEV["[Networked] float Health\n[Networked, Interpolated] Vector3 Position"] --> REFL["wrapper reflects/parses fields at type init"]
    REFL --> SCHEMA["build lt_object_schema: {slot, type, size, hints}"]
    SCHEMA --> REG["lt_register_prefab(stable_key, schema)"]
    REG --> CORE["core: assigns prefab_id, lays out field table"]
    CORE --> WIRE["core serialization: quantize + delta vs last acked snapshot (see 02)"]

    SET["game sets Health = 90"] --> WR["wrapper: lt_field_write(id, HEALTH_SLOT, &v, 4)"]
    WR --> DIRTY["core marks slot dirty; included in next snapshot at 20-30 Hz"]
    RECV["remote receives snapshot"] --> CB["on_field_changed(id, HEALTH_SLOT)"]
    CB --> GET["wrapper updates C# property backing store; raises OnChanged"]
  • Field slots are stable ordinals, assigned in declaration order and frozen by the schema, so server and client agree on slot ids without sending field names on the wire. The catalog (§7) guarantees both ends register the same schema under the same stable key.
  • Hints map to core features documented in 02: Interpolated → snapshot interp buffer (~100 ms); Predicted → the field participates in prediction/rollback; quantization attributes → bit-packing/quantize on the wire. The wrapper only forwards hints; the core implements them.
  • Writes are authority-gated. lt_field_write from a non-authority returns LT_ERR_NOT_AUTHORITY; the wrapper surfaces that as a no-op + warning, matching Fusion's "only state authority may write" semantics.

3. Design for Ergonomics

The headline goal: make the common path trivial. Below is the "boilerplate budget" Lattice commits to, and what the core does behind each line.

Developer goal What the developer writes What the core does
Start a session runner.StartGame(GameMode.Host, "room-1") (1 call) Creates runner, opens UDP socket, auths via token (03), connects to relay/director if needed, begins the 60 Hz tick loop.
Declare replicated state [Networked] public float Health { get; set; } (1 line) Reflects the field into an lt_object_schema slot, allocates field storage, wires delta/quantize, drives on_field_changed.
Spawn a networked object runner.Spawn(playerPrefab, owner: player) (1 call) Allocates a netid, replicates the spawn to interested peers (AoI), fires on_spawned everywhere, instantiates the prefab from the catalog.
Author an RPC [Rpc] void Fire(Vector3 dir) { ... } (1 attribute) Generates a slot id, marshals args, routes over the right reliability channel, dispatches to on_rpc on the target peer(s).
Read input on the authority implement OnInput(ref MyInput) Polls input each tick on the input source, ships it to the authority, applies it inside the authoritative OnSimTick.

Side-by-side: "host a game and spawn a player"

What the developer writes (Unity, ~12 lines):

async void Start() {
    _runner = gameObject.AddComponent<NetworkRunner>();
    await _runner.StartGame(GameMode.Host, sessionName: "room-1");   // (1)
    // Host also acts as a player:
    _runner.Spawn(playerPrefab, owner: _runner.LocalPlayer);          // (2)
}

What the core does behind those two lines: opens and binds the UDP socket; performs the X25519 handshake and validates the session token with lattice-auth; registers the listen-server with the director so clients can find it; starts the 60 Hz fixed-tick loop and the 20–30 Hz snapshot scheduler; for the spawn — assigns a netid, lays the player's [Networked] fields into the snapshot stream, runs interest management to decide which peers see it, and fires on_spawned on every interested peer (and locally) which instantiates the prefab from the catalog and binds its replicated fields. All of that complexity stays below the ABI.

This is the same ergonomic envelope as Photon Fusion's runner.StartGame(...) + runner.Spawn(...), by design.


4. Unity Binding (lattice-unity)

4.1 API Shape

  • NetworkRunner — a MonoBehaviour wrapping the lt_runner handle. StartGame(GameMode, sessionName) returns a Task. Drives lt_runner_tick from FixedUpdate.
  • NetworkBehaviour — base class for replicated components; exposes [Networked] properties, Spawn/Despawn, [Rpc] methods, and the FixedUpdateNetwork() tick callback.
  • [Networked] — a property attribute; a Roslyn source generator rewrites the auto-property into lt_field_read/write calls (zero reflection at runtime) and emits the lt_object_schema.
  • [Rpc] — marks a method as a remote call; the generator emits the marshalling + lt_rpc_invoke and the dispatch stub.
  • INetworkInput + OnInput(NetworkRunner, NetworkInput) — the input struct contract.
  • Ownership/authorityObject.HasStateAuthority, Object.HasInputAuthority, Object.RequestStateAuthority().

4.2 Complete sample — host a game and spawn a player

using Lattice;            // lattice-unity
using UnityEngine;
using System.Threading.Tasks;

public struct PlayerInput : INetworkInput {
    public Vector3 Move;     // wsad direction
    public bool    Fire;
}

// Bootstrap: starts a host session and spawns the local player.
public class GameLauncher : MonoBehaviour, INetworkInputProvider {
    [SerializeField] private NetworkObject playerPrefab;   // catalog entry
    private NetworkRunner _runner;

    async void Start() {
        _runner = gameObject.AddComponent<NetworkRunner>();
        _runner.AddInputProvider(this);

        // (1) one call to host. Mode = Host => listen-server + local player.
        await _runner.StartGame(GameMode.Host, sessionName: "room-1");

        // (2) one call to spawn the host's own player.
        _runner.Spawn(playerPrefab, position: Vector3.zero,
                      owner: _runner.LocalPlayer);
    }

    // Called by clients joining the host; spawn their avatar.
    public void OnPlayerJoined(NetworkRunner runner, PlayerRef player) {
        if (runner.IsServer)                               // authority spawns
            runner.Spawn(playerPrefab, owner: player);
    }

    // Input is polled once per tick on the input source and shipped to authority.
    public void OnInput(NetworkRunner runner, NetworkInput input) {
        input.Set(new PlayerInput {
            Move = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical")),
            Fire = Input.GetButton("Fire1")
        });
    }
}

4.3 Complete sample — predicted movement + RPC

using Lattice;
using UnityEngine;

public class Player : NetworkBehaviour {

    // One line each. Source generator turns these into lt_field_read/write
    // and registers schema slots. Interpolated => ~100 ms snapshot buffer.
    [Networked] public float Health { get; set; } = 100f;
    [Networked, Interpolated] public Vector3 Position { get; set; }

    [SerializeField] private float speed = 6f;

    // Lattice's fixed tick callback — runs at 60 Hz on the sim thread schedule.
    // On the authority it consumes confirmed input; on the predicting client it
    // re-runs during rollback with buffered input. Same code, both paths.
    public override void FixedUpdateNetwork() {
        if (GetInput(out PlayerInput input)) {
            // Predicted on the input owner, authoritative on the server.
            Position += input.Move.normalized * speed * Runner.DeltaTime;
            transform.position = Position;

            if (input.Fire)
                Fire(input.Move.normalized);   // RPC, fired from authority/owner
        }
    }

    // One attribute authors the RPC. Source = input authority, target = state
    // authority (server), routed Reliable-Unordered by default.
    [Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
    private void Fire(Vector3 dir) {
        // Runs on the server: spawn a projectile, do hit-scan w/ lag comp (see 02).
        Runner.Spawn(Resolve("Projectile"), position: Position, owner: Object.InputAuthority);
    }

    // Fired everywhere when Health changes (on_field_changed -> generated stub).
    [OnChanged(nameof(Health))]
    private void OnHealthChanged() {
        if (Health <= 0 && Object.HasStateAuthority)
            Runner.Despawn(Object);
    }
}

4.4 Unity Package Layout

com.lattice.netcode/                 (UPM package)
├── package.json
├── Runtime/
│   ├── Lattice.Runtime.asmdef        # references the native plugin
│   ├── NetworkRunner.cs
│   ├── NetworkBehaviour.cs
│   ├── Attributes.cs                 # [Networked], [Rpc], [OnChanged]
│   └── Interop/LatticeNative.cs      # [DllImport("lattice_core")] P/Invoke
├── Editor/
│   ├── Lattice.Editor.asmdef
│   └── PrefabCatalogBuilder.cs       # scans NetworkObject prefabs -> catalog
├── SourceGen/                        # Roslyn analyzer/generator (no runtime reflection)
│   └── Lattice.SourceGen.dll
└── Plugins/                          # native lib per platform (import settings set per target)
    ├── Windows/x86_64/lattice_core.dll
    ├── Linux/x86_64/liblattice_core.so
    ├── macOS/lattice_core.bundle      # universal (arm64 + x86_64)
    ├── Android/arm64-v8a/liblattice_core.so
    └── iOS/liblattice_core.a          # static, IL2CPP
  • Native plugin per platform with Unity import settings restricting each binary to its target. iOS uses the static .a for IL2CPP; Windows/Linux/macOS desktop and Android use dynamic libs.
  • asmdef isolates the runtime assembly and references only the interop; the source generator runs at compile time so there is no reflection on the hot path.
  • Prefab registration: any NetworkObject prefab is scanned by PrefabCatalogBuilder (editor) into a deterministic, ordered catalog baked into the build (see §7).

5. Unreal Binding (lattice-unreal)

lattice-unreal is a UE module that statically links lattice-core and presents it through UObjects. Note: Lattice does not use Unreal's native replication; it replaces it. ULatticeNetComponent is the per-actor replicated component, and ALatticeNetActor is a convenience actor that already has one.

5.1 API Shape

  • ULatticeRunnerSubsystem — a UGameInstanceSubsystem owning the lt_runner; StartGame(ELatticeMode, FString Session); ticks core from the game thread.
  • ALatticeNetActor / ULatticeNetComponent — replicated actor/component; replicated properties registered via macros; RPC macros; input provider interface ILatticeInputSource.
  • SimulatedTick(float DeltaTime) — the per-tick callback (Unreal's analog of FixedUpdateNetwork).

5.2 Replicated property & RPC macros

// LatticePlayer.h
#include "LatticeNetActor.h"
#include "LatticePlayer.generated.h"

USTRUCT()
struct FPlayerInput {
    GENERATED_BODY()
    UPROPERTY() FVector Move = FVector::ZeroVector;
    UPROPERTY() bool    bFire = false;
};

UCLASS()
class ALatticePlayer : public ALatticeNetActor, public ILatticeInputSource {
    GENERATED_BODY()
public:
    // Macro maps to a schema slot; analogous to [Networked]. Quantize/interp via flags.
    LATTICE_NETWORKED(float, Health, /*default*/100.f);
    LATTICE_NETWORKED_FLAGS(FVector, Position, ELatticeRep::Interpolated);

    // Lattice fixed-tick callback (NOT UE Tick): runs on authority + during rollback.
    virtual void SimulatedTick(float DeltaTime) override;

    // One macro authors an RPC: source = input authority, target = state authority.
    UFUNCTION()
    LATTICE_RPC(InputAuthority, StateAuthority)
    void ServerFire(FVector Dir);

    // Input provider: polled each tick, marshalled to the authority.
    virtual void GatherInput(FLatticeInputWriter& Out) override;
};
// LatticePlayer.cpp
void ALatticePlayer::SimulatedTick(float DeltaTime) {
    FPlayerInput In;
    if (GetInput(In)) {                                  // confirmed or buffered (rollback)
        Position = Position + In.Move.GetSafeNormal() * Speed * DeltaTime;  // predicted
        SetActorLocation(Position);
        if (In.bFire) ServerFire(In.Move.GetSafeNormal());
    }
}

void ALatticePlayer::ServerFire_Implementation(FVector Dir) {   // runs on state authority
    GetRunner()->Spawn(ProjectileClass, Position, GetInputAuthority());
}

void ALatticePlayer::GatherInput(FLatticeInputWriter& Out) {
    FPlayerInput In;
    In.Move  = ConsumeMovementVector();
    In.bFire = WasFirePressed();
    Out.Write(In);
}

5.3 Spawn sample (Game Instance Subsystem)

void UMyGameFlow::HostGame() {
    auto* Runner = GetGameInstance()->GetSubsystem<ULatticeRunnerSubsystem>();

    Runner->OnStarted.AddLambda([this, Runner]() {            // (1) host
        Runner->Spawn(ALatticePlayer::StaticClass(),         // (2) spawn host player
                      FVector::ZeroVector, Runner->LocalPlayer());
    });
    Runner->OnPlayerJoined.AddLambda([Runner](FLatticePlayerRef Player) {
        if (Runner->IsServer())
            Runner->Spawn(ALatticePlayer::StaticClass(), FVector::ZeroVector, Player);
    });

    Runner->StartGame(ELatticeMode::Host, TEXT("room-1"));
}

5.4 Module / UBT Integration

  • Ships as a UE plugin Lattice/ with Lattice.uplugin and a Lattice.Build.cs that adds lattice-core headers and links the static lattice_core library per platform from Source/ThirdParty/LatticeCore/.
  • LATTICE_NETWORKED / LATTICE_RPC are macros expanded by UHT-friendly code-gen; replicated slots and RPC stubs are produced at build time (no runtime reflection beyond UE's own).
  • Supported: Win64, Linux (incl. dedicated-server target), Mac, with the same module compiled into the UE dedicated server target. The shared game-sim library (see §8) is linked into both the client and the UE/standalone dedicated server.

6. Godot 4 Binding (lattice-godot)

lattice-godot is a GDExtension (Godot 4's native C interface) exposing two nodes.

6.1 API Shape

  • LatticeRunner (Node) — owns the lt_runner; start_game(mode, session); ticks core from _physics_process (Godot's fixed step). Emits signals player_joined, spawned, etc.
  • LatticeSync (Node, child of a spawnable scene) — the replicated component. Exported properties tagged for replication are the [Networked] equivalent; methods decorated with an RPC annotation become RPCs; _simulated_tick(delta) is the fixed-tick callback.
  • Ownership/authority: sync.has_authority(), sync.request_authority().

6.2 GDScript sample — host + spawn + predicted movement

# game.gd — attached to a Node that has a LatticeRunner child.
extends Node

@export var player_scene: PackedScene   # registered in the catalog
@onready var runner: LatticeRunner = $LatticeRunner

func _ready() -> void:
    runner.player_joined.connect(_on_player_joined)
    await runner.start_game(LatticeRunner.MODE_HOST, "room-1")   # (1) host
    runner.spawn(player_scene, runner.local_player)              # (2) spawn host player

func _on_player_joined(player: int) -> void:
    if runner.is_server():
        runner.spawn(player_scene, player)
# player.gd — attached to the root of player_scene; has a LatticeSync child.
extends CharacterBody3D

@onready var sync: LatticeSync = $LatticeSync

# [Networked]-equivalent: exported + tagged for replication via the sync node.
@export var health: float = 100.0          # sync.replicate("health")
@export var net_position: Vector3          # sync.replicate("net_position", LatticeSync.INTERPOLATED)

const SPEED := 6.0

func _ready() -> void:
    sync.replicate("health")
    sync.replicate("net_position", LatticeSync.INTERPOLATED)
    sync.health_changed.connect(_on_health_changed)

# Lattice fixed-tick callback (driven by LatticeSync), not _physics_process.
func _simulated_tick(delta: float) -> void:
    var input := sync.get_input()                 # confirmed or buffered (rollback)
    if input:
        net_position += input.move.normalized() * SPEED * delta   # predicted
        global_position = net_position
        if input.fire:
            fire.rpc_id(LatticeSync.STATE_AUTHORITY, input.move.normalized())

# RPC: source = input authority, target = state authority.
@lattice_rpc(LatticeSync.INPUT_AUTHORITY, LatticeSync.STATE_AUTHORITY)
func fire(dir: Vector3) -> void:
    runner.spawn(load("res://projectile.tscn"), sync.input_authority)

func _on_health_changed() -> void:
    if health <= 0.0 and sync.has_authority():
        runner.despawn(self)

6.3 Notes

  • C# in Godot is a first-class alternative: with the Mono/.NET build, the same LatticeRunner/LatticeSync nodes are scriptable in C#, and [Networked]/[Rpc] attributes (shared with the Unity source generator's attribute definitions where practical) provide the same one-line ergonomics.
  • The GDExtension binary (liblattice_godot.{so,dll,dylib}) bundles or dynamically loads lattice-core; registration is via the standard .gdextension descriptor.
  • Spawnable scenes are registered into the catalog (§7) by a stable resource path/key so server and client resolve the same prefab_id.

7. Prefab / Asset Registration & the Network-Object Catalog

Spawning across the wire sends a small lt_prefab_id, never an engine asset reference. Both ends must map that id to the same spawnable type. The catalog is the contract that guarantees this.

flowchart TD
    subgraph BUILD["Build time (per engine)"]
        SCAN["scan spawnable types (NetworkObject prefabs / Lattice actors / scenes)"]
        SCAN --> KEYS["assign STABLE KEY per type (asset GUID / class path / res path)"]
        KEYS --> SORT["sort keys deterministically -> ordinal = prefab_id"]
        SORT --> SCHEMA["emit lt_object_schema per type ([Networked] slots)"]
        SCHEMA --> BAKE["bake catalog manifest + content hash into the build"]
    end

    subgraph RUNTIME["Runtime"]
        REG["each peer: lt_register_prefab(stable_key, schema) in catalog order"]
        REG --> HASH["core compares catalog content hash at connect"]
        HASH -->|"match"| OK["spawns resolve by prefab_id on all peers"]
        HASH -->|"mismatch"| REJECT["reject join: incompatible catalog (version error)"]
    end

    BAKE --> REG
  • Stable key → ordinal. Each spawnable type gets a stable key (Unity asset GUID, Unreal class path, Godot resource path). Keys are sorted deterministically so the ordinal prefab_id is identical on every peer that shares the build.
  • Content hash gate. The catalog (keys + schema slots + hints) is hashed; peers exchange the hash at connect and a mismatch is rejected, preventing desync from divergent catalogs (the analog of Fusion's prefab/config hash check).
  • Schema travels with the catalog, not the wire. Field slot ids and types are agreed via the catalog, so snapshots carry only slot data — never field names.

8. Build, Packaging & the Shared Sim Library

8.1 Where the native lib lives

Engine Native form Location
Unity dynamic (.dll/.so/.bundle), static .a on iOS Plugins/<platform>/ in the UPM package
Unreal static .lib/.a linked by Lattice.Build.cs Source/ThirdParty/LatticeCore/
Godot dynamic, loaded via .gdextension plugin bin/ dir
Web .wasm + JS glue served alongside the app bundle

8.2 Shared game-sim library (client ↔ dedicated server)

The same gameplay simulation must run on the client (for prediction and P2P hosting) and on the dedicated server. As established in 01 and detailed in 02 §Shared Simulation, the game-sim library is authored once in C++ and compiled into:

  • the dedicated server (lattice-gameserver, links lattice-core + game-sim), and
  • the client, exposed through the engine binding so the client's FixedUpdateNetwork/SimulatedTick/_simulated_tick runs byte-for-byte identical logic — which is what lets prediction/rollback and P2P-host topologies stay consistent.

Engines that author gameplay in their own language (C# in Unity, GDScript in Godot) call into game-sim through the binding for the deterministic portions (movement integration, hit resolution) and/or mirror the same fixed-step contract; the determinism requirements (fixed DeltaTime, no wall-clock, fixed-point or carefully-bounded float per 02) are imposed by the tick callback contract. The dedicated server target for Unreal/Unity (headless builds) and the standalone lattice-gameserver both link the identical game-sim.

8.3 Platforms

Core targets Win64, Linux (x86_64 + arm64), macOS (arm64 + x86_64), Android (arm64-v8a), iOS (arm64), and WASM. Each binding ships only the platform binaries its engine supports; the dedicated-server path is always Linux/Win64 headless.


9. Web Binding (lattice-web, stretch)

The web target compiles lattice-core to WASM via Emscripten and exposes a thin TypeScript API. Browsers cannot open raw UDP sockets, so the transport layer swaps its socket for WebTransport (HTTP/3 / QUIC datagrams) — this is the optional QUIC/WebTransport backend called out in 00 §3.3 and 02. The replication/prediction/serialization layers above the transport are unchanged.

// lattice-web — thin TS API over the WASM exports (Emscripten cwrap)
import { NetworkRunner, GameMode } from "@lattice/web";

const runner = new NetworkRunner({ catalog: builtCatalog });
await runner.startGame(GameMode.Client, "room-1");   // connects via WebTransport datagrams

runner.onSimTick = (tick, dt) => { /* predicted movement, same contract as native */ };
runner.onSpawned = (id, prefabId, owner) => instantiate(prefabId, id);
runner.onFieldChanged = (id, slot) => applyField(id, slot);

Limitations & mapping:

  • No raw UDP / no custom congestion at the wire — the browser owns QUIC congestion control; Lattice's channel semantics (Unreliable / Reliable) map onto QUIC datagrams (unreliable) and reliable streams (reliable channels), as specified in the transport section of 02.
  • No P2P hole punching in-browser; web clients connect to a dedicated server or via a WebTransport-capable relay (lattice-relay).
  • Single-threaded by default; the WASM tick runs on the main thread (or a Web Worker with SharedArrayBuffer where COOP/COEP headers permit), see §10.
  • Callbacks are dispatched from the WASM tick into JS synchronously, mirroring the native callback rule.

10. Threading Model & Main-Thread Callback Rules

The core's I/O (socket recv, decrypt, snapshot decode) may run on an internal worker thread, but all callbacks into engine code are deferred and dispatched synchronously from lt_runner_tick, which the binding always calls on the engine's main/game thread. Game code therefore never deals with cross-thread races against the engine's object model.

flowchart TD
    NETT["core I/O thread: recv/decrypt UDP, decode snapshots, queue events"] --> Q["lock-free event queue (spawn/despawn/field/rpc)"]
    Q --> TICK["lt_runner_tick called on engine main thread"]
    TICK --> DRAIN["drain queue -> fire callbacks on main thread"]
    DRAIN --> CB["on_spawned / on_field_changed / on_rpc / on_sim_tick"]
    CB --> ENGINE["safe to touch GameObjects / UObjects / Nodes"]
Engine Drives lt_runner_tick from Callback thread Rule
Unity FixedUpdate (and a fixed accumulator to hit 60 Hz) main thread All [Networked]/[Rpc]/FixedUpdateNetwork callbacks are main-thread; touching Transform/GameObject is safe.
Unreal game thread tick (subsystem) game thread SimulatedTick and RPC handlers run on the game thread; safe to mutate AActors.
Godot _physics_process main thread _simulated_tick, signals, and RPC handlers fire on the main thread.
Web RAF/worker loop calling the WASM tick main thread (or worker) Callbacks dispatched synchronously from the tick; DOM/engine objects touched on the owning thread only.

The internal worker thread is an optimization, not part of the API contract; bindings may also run core fully single-threaded on platforms (iOS/web) where that is simpler.


11. Engine Summary Table

Engine Binding tech Language(s) Min version Packaging
Unity Native plugin + P/Invoke; Roslyn source generator C# Unity 2022.3 LTS (Unity 6 recommended) UPM package com.lattice.netcode (per-platform Plugins/, asmdef, source gen)
Unreal UE module, static-linked lattice-core C++ (Blueprint-exposed) UE 5.3+ UE plugin Lattice/ (.uplugin, Build.cs, ThirdParty libs)
Godot 4 GDExtension (native C interface) GDScript (and C# via .NET build) Godot 4.2+ Addon with .gdextension + per-platform liblattice_godot.*
Web (stretch) WASM (Emscripten) + WebTransport TypeScript / JS Evergreen browsers w/ WebTransport npm @lattice/web (.wasm + JS glue + .d.ts)

12. Custom Types & the Networked Type System (per engine)

Beyond [Networked] primitives, developers must be able to network their own value structs and reference types, and — when they want full control — to write the (de)serialization by hand. Lattice offers both a declarative path (attributes/macros, codegen) and an explicit function path (a typed bit buffer you write to yourself). Both compile down to the same core schema/bitstream described in 02 §20 Serialization codegen and are registered via lattice_register_type.

The interface and bit-method names below mirror the canonical "Networked Type System & Custom Types" section (02 §21): the managed bit buffer (BitBuffer) and the native BitWriter/BitReader faces, the custom value-struct marker INetworkStruct, the manual-serialization interface INetworkSerializable (managed void Serialize(ref BitBuffer buf); native void Serialize(lattice::BitWriter& w) const + void Deserialize(lattice::BitReader& r)), and custom reference types as NetworkBehaviour subclasses with [Networked] members. C++ and GDScript expose the same concepts under their native idioms.

12.1 Supported [Networked] primitive types

The explicitly requested primitive set, plus the always-supported extras, and how each maps to the per-engine native type and the core wire type (02 §14.6 Quantization & bit-packing):

Lattice type Unity (C#) Unreal (C++) Godot (GDScript / C#) Core wire (default)
bool bool bool bool 1 bit
int (32-bit) int int32 int / int 32 bits (or ranged)
long (64-bit) long int64 int / long 64 bits (or ranged)
float float float float / float 32 bits (or compressed)
double double double float (64-bit) / double 64 bits
Vector2 Vector2 FVector2D Vector2 / Vector2 2× float / quantized
Vector3 Vector3 FVector Vector3 / Vector3 3× float / quantized
Vector4 Vector4 FVector4 Vector4 / Vector4 4× float / quantized
Quaternion Quaternion FQuat Quaternion / Quaternion smallest-three (10 b/comp)
string string FString String / string length-prefixed UTF-8
byte[] byte[] TArray<uint8> PackedByteArray / byte[] length-prefixed blob
enum any enum enum class (UENUM) int const / enum ranged int (ceil(log2(range)) bits)

The requested core set — bool, int, long, float, double, Vector2/3/4 (and Quaternion, string) — is the declarable baseline; byte[] and enum round out the primitives. The vector/quaternion mapping is the one shown above; the wrapper marshals the engine type into the POD layout the core expects (§12.6).

12.2 Declarative custom value struct (INetworkStruct)

A custom struct of primitives implements INetworkStruct (a pure marker / value-semantics contract — no per-instance heap, blittable). Its fields are reflected into schema slots by the same codegen that handles [Networked] properties, so it can be used directly as a [Networked] property on a NetworkBehaviour. Per-field quantization hints are optional.

Unity (C#):

using Lattice;
using UnityEngine;

// Custom networked value struct — blittable, no managed references.
public struct PlayerState : INetworkStruct {
    public bool    IsAlive;
    public int     Score;
    public long    Xp;

    // Quantization hints map to core quantizers (see 02 §14.6):
    [Quantize(0f, 100f, precision: 0.1f)] public float   Stamina;
    [Quantize(-4096f, 4096f, precision: 0.001f)] public Vector3 Position;  // 1 mm
    [Quantize(-256f, 256f, precision: 0.01f)]    public Vector3 Velocity;
}

Unreal (C++): a USTRUCT declared as a netstruct. The binding-friendly per-member macro LATTICE_NETWORKED_MEMBER (the per-member analog of §5's LATTICE_NETWORKED) expands to a LATTICE_NETSTRUCT(...) declaration in core (02 §21) — i.e. these member macros are sugar over core's LATTICE_NETSTRUCT, and opting a struct into per-member delta maps to core's LATTICE_NETSTRUCT_DELTA. All forms expand to the same lattice_type_desc registration:

USTRUCT()
struct FPlayerState {
    GENERATED_BODY()
    LATTICE_NETWORKED_MEMBER(bool,    IsAlive,  false)
    LATTICE_NETWORKED_MEMBER(int32,   Score,    0)
    LATTICE_NETWORKED_MEMBER(int64,   Xp,       0)
    LATTICE_NETWORKED_MEMBER_Q(float,  Stamina,  0.f,  LATTICE_RANGE(0.f, 100.f, 0.1f))
    LATTICE_NETWORKED_MEMBER_Q(FVector, Position, FVector::ZeroVector, LATTICE_BOUNDS(-4096, 4096, 0.001f))
    LATTICE_NETWORKED_MEMBER_Q(FVector, Velocity, FVector::ZeroVector, LATTICE_BOUNDS(-256, 256, 0.01f))
};

Godot (GDScript): GDScript has no user structs, so a custom value struct is a small Resource whose exported fields are registered with the owning LatticeSync; the wrapper packs it into the same POD layout. (In Godot C#, use the same INetworkStruct struct as Unity.)

# player_state.gd
class_name PlayerState
extends Resource

@export var is_alive: bool   = true
@export var score: int       = 0
@export var xp: int          = 0          # 64-bit
@export var stamina: float   = 0.0
@export var position: Vector3 = Vector3.ZERO
@export var velocity: Vector3 = Vector3.ZERO

# Field layout + quantization registered once with the sync node:
static func register(sync: LatticeSync) -> void:
    sync.define_struct("PlayerState", [
        ["is_alive", LatticeSync.T_BOOL],
        ["score",    LatticeSync.T_INT],
        ["xp",       LatticeSync.T_LONG],
        ["stamina",  LatticeSync.T_FLOAT,  {"range": [0.0, 100.0], "precision": 0.1}],
        ["position", LatticeSync.T_VECTOR3, {"bounds": [-4096, 4096], "precision": 0.001}],
        ["velocity", LatticeSync.T_VECTOR3, {"bounds": [-256, 256],  "precision": 0.01}],
    ])

12.3 The explicit function path (INetworkSerializable / manual Serialize)

When a developer wants to control the bits exactly (variable encodings, conditional fields, packing several values into one), they implement INetworkSerializable and write to the typed bit buffer themselves. In managed code the same method serves both directions via a single BitBuffer (read in read-mode, write in write-mode), so layout cannot drift; in C++ the directions are explicit (Serialize(BitWriter&) + Deserialize(BitReader&)). This is the canonical pattern from 02 §21.

The typed methods mirror 02 exactly — BitWriter/BitReader (C++) and the managed BitBuffer expose the same named methods: WriteBool/ReadBool, WriteEnum/ReadEnum, WriteByte/ReadByte, WriteInt/ReadInt, WriteRangedInt/ReadRangedInt, WriteLong/ReadLong, WriteRangedLong/ReadRangedLong, WriteFloat/ReadFloat, WriteCompressedFloat/ReadCompressedFloat, WriteDouble/ReadDouble, WriteVector2/3/4 + ReadVector2/3/4, WriteQuaternion/ReadQuaternion, WriteString/ReadString, WriteBytes/ReadBytes, and the WriteBits/ReadBits raw escape hatch. The compressed-float and compressed-vector helpers take a FloatQuant { min, max, precision } quantization descriptor (WriteCompressedFloat(v, min, max, precision); WriteVector3(v, FloatQuant)), as defined in 02 §21.

Unity (C#) — same PlayerState, serialized by hand:

using Lattice;
using UnityEngine;

public struct PlayerState : INetworkStruct, INetworkSerializable {
    public bool    IsAlive;
    public int     Score;
    public long    Xp;
    public float   Stamina;
    public Vector3 Position;
    public Vector3 Velocity;

    // One method, both directions: BitBuffer reads in read-mode, writes in write-mode.
    public void Serialize(ref BitBuffer buf) {
        IsAlive  = buf.WriteBool(IsAlive);
        Score    = buf.WriteRangedInt(Score, 0, 1_000_000);                 // bounded -> fewer bits
        Xp       = buf.WriteLong(Xp);                                        // full 64-bit
        Stamina  = buf.WriteCompressedFloat(Stamina, 0f, 100f, 0.1f);        // quantized float
        // Compressed vectors take a FloatQuant { min, max, precision } descriptor:
        Position = buf.WriteVector3(Position, new FloatQuant(-4096f, 4096f, 0.001f)); // 1 mm
        Velocity = buf.WriteVector3(Velocity, new FloatQuant(-256f, 256f, 0.01f));
    }
}

The single BitBuffer method round-trips: in write-mode each Write* emits and returns the value; in read-mode it reads and returns it (so the assignments above populate fields when reading). The explicit ReadBool/ReadInt/… forms exist for code that prefers separate read/write paths.

Unreal (C++) — equivalent Serialize(BitWriter&) / Deserialize(BitReader&):

struct FPlayerState : public lattice::INetworkSerializable {
    bool    IsAlive  = true;
    int32   Score    = 0;
    int64   Xp       = 0;
    float   Stamina  = 0.f;
    FVector Position = FVector::ZeroVector;
    FVector Velocity = FVector::ZeroVector;

    // Native BitWriter/BitReader mirror the managed BitBuffer methods 1:1.
    void Serialize(lattice::BitWriter& w) const override {
        w.WriteBool(IsAlive);
        w.WriteRangedInt(Score, 0, 1'000'000);
        w.WriteLong(Xp);
        w.WriteCompressedFloat(Stamina, 0.f, 100.f, 0.1f);
        w.WriteVector3(Position, { .min = -4096, .max = 4096, .precision = 0.001f }); // FloatQuant
        w.WriteVector3(Velocity, { .min = -256,  .max = 256,  .precision = 0.01f  });
    }
    void Deserialize(lattice::BitReader& r) override {
        IsAlive  = r.ReadBool();
        Score    = r.ReadRangedInt(0, 1'000'000);
        Xp       = r.ReadLong();
        Stamina  = r.ReadCompressedFloat(0.f, 100.f, 0.1f);
        Position = r.ReadVector3({ .min = -4096, .max = 4096, .precision = 0.001f });
        Velocity = r.ReadVector3({ .min = -256,  .max = 256,  .precision = 0.01f  });
    }
};

In Godot, the explicit path is the same BitWriter/BitReader exposed on the GDExtension: func _serialize(w: BitWriter) / func _deserialize(r: BitReader) with w.write_compressed_float(...), w.write_vector3(...), etc. (snake_case per Godot convention, identical semantics).

Declarative vs. explicit — pick per type: the declarative path is the default and covers the vast majority of cases with one line per field; the explicit path is an opt-in for hand-tuned encodings. A type may even be declarative for most members and implement INetworkSerializable for full control — the codegen defers to a user-provided Serialize when present.

12.4 Custom networked class (reference type) using a custom struct

A custom networked reference type is simply a NetworkBehaviour subclass with [Networked] members — including a [Networked] custom struct property. It is spawned and replicated exactly like any other networked object (§4, §7).

using Lattice;
using UnityEngine;

public class PlayerAvatar : NetworkBehaviour {
    // Primitive [Networked] members (the requested set):
    [Networked] public bool    IsAlive { get; set; } = true;
    [Networked] public int     Score   { get; set; }
    [Networked] public long    Xp      { get; set; }
    [Networked] public float   Stamina { get; set; } = 100f;
    [Networked] public double  ServerTimeJoined { get; set; }

    // A [Networked] CUSTOM STRUCT property — replicated as one delta-tracked unit.
    [Networked] public PlayerState State { get; set; }

    public override void FixedUpdateNetwork() {
        if (!Object.HasStateAuthority) return;        // authority writes; clients read+predict
        var s = State;                                 // value struct: copy, mutate, assign back
        s.Stamina = Mathf.Min(100f, s.Stamina + 5f * Runner.DeltaTime);
        State = s;                                      // assignment marks the slot dirty
    }

    [Rpc(RpcSources.StateAuthority, RpcTargets.All)]
    public void AwardScore(int delta) { Score += delta; }
}
// Spawning the custom class — identical to any networked object:
runner.Spawn(playerAvatarPrefab, owner: player);   // catalog-resolved prefab_id (see §7)

The custom struct PlayerState is itself registered as a type (§12.5) and embedded in PlayerAvatar's schema as a nested type; on the wire it is delta-tracked as a unit (a dirty bit for the State slot), with its inner fields quantized per their hints.

12.5 Registering custom types in the catalog / type registry

Custom value structs and custom networked classes are registered into the same catalog and content-hash gate described in §7 — they are just additional entries in the type registry. The flow extends §7's catalog build:

flowchart TD
    subgraph BUILD["Build time"]
        T1["discover custom types: INetworkStruct structs + NetworkBehaviour subclasses"]
        T1 --> T2["codegen lt_object_schema / lattice_type_desc per type (slots, sizes, quantize hints)"]
        T2 --> T3["assign stable key per type (asset GUID / class path / res path)"]
        T3 --> T4["sort keys deterministically -> stable type_id (and prefab_id for spawnables)"]
        T4 --> T5["fold every type schema into the catalog content hash"]
    end

    subgraph RUNTIME["Runtime (every peer, in catalog order)"]
        R1["lattice_register_type(world, type_desc) per type"]
        R1 --> R2["exchange catalog/schema hash at connect (see 02 §20.2)"]
        R2 -->|"match"| R3["custom structs + classes resolve identically on all peers"]
        R2 -->|"mismatch"| R4["reject join: incompatible schema (hard, early)"]
    end

    T5 --> R1
  • Value structs get a type_id; spawnable classes get both a type_id and a prefab_id. Nested custom structs are referenced by type_id inside their owner's schema.
  • One hash covers everything. Adding/removing/reordering a custom struct field, changing a quantization range, or renaming a type changes the catalog content hash, so divergent builds are rejected at connect rather than silently corrupting state — consistent with §7 and 02 §20.2 Schema/version negotiation.
  • Same registration on server and client. Because the codegen runs from the same source and the keys sort deterministically, type_id/prefab_id are identical across the dedicated server, the P2P host, and every client.

12.6 Marshalling note: how typed values & custom structs cross the C ABI

All of the above stays consistent with the ABI rules in §2.3, §2.5, and 02 §19's ABI rules:

  • No managed references cross the boundary. A custom struct crosses the ABI as a blittable POD value (the same blittable constraint INetworkStruct enforces in C#) via lattice_set_prop / lattice_get_prop (const void* + size). The wrapper copies the engine-side struct into a pooled POD buffer; the core copies what it retains (no shared lifetime).
  • Little-endian POD layout. Primitive fields use the fixed little-endian layout the core defines (02 §20.3); the wrapper mirrors it with [StructLayout(LayoutKind.Sequential)] (C#), a matching struct (Unreal/Godot), or a typed-array view (WASM). Vectors/quaternions cross as fixed float arrays (float[2/3/4]), not engine objects.
  • Encoding happens inside core, not at the boundary. The blob crossing the ABI is the unencoded value; quantization/delta/bit-packing run inside core per the registered schema. The explicit INetworkSerializable.Serialize(ref BitBuffer) path is the one exception: there, the wrapper drives the core's BitWriter/BitReader through ABI bit-method shims, so the developer's hand-written bit layout is the wire layout (still little-endian, still POD in/out).
  • Strings / byte[] never cross as managed objects: they marshal as const char*/uint8_t* + length into caller-owned buffers, per §2.3.

13. Cross-Document References

All product and component names ("Lattice", lattice-core, lattice-unity, etc.) are working codenames and may be renamed before release without changing any architecture, interface, or parameter described here.