Skip to content

Godot bridge

The bridge is the narrow, disciplined boundary between the ECS simulation and the rest of Godot. Everything in GodotECS.Bridge exists so that C# systems stay bulk and job-friendly while nodes, audio, navigation, and GDScript still work.

The sync-point rule

Never touch Godot objects from inside a job or a hot per-entity loop. All Godot interaction happens at explicit sync points on the main thread.

Concretely:

  • Systems write requests as ECS data during simulation: AudioEvent buffer elements, NavRequest components, death/hit entries queued on EcsSignals.
  • Bridge nodes (AudioBridge, NavBridge, EcsSignals) run on the main thread in _Process / _PhysicsProcess and drain those requests once per frame: playing sounds, resolving paths, emitting signals.
  • The reverse direction follows the same rule: a CharacterBody3D player is never moved by ECS; systems read its position once per tick (via a singleton such as PlayerBodyRef), and hero impulses go out through PhysicsServer3D only at a sync point.
  • Per-frame budgets cap the cost: at most AudioBridge.MaxVoices (32) sounds and NavBridge.PathsPerFrame (64) paths per frame. Overflow is dropped (audio, keep loudest) or deferred (nav, stays Pending).

Follow this rule and the simulation stays deterministic, parallelizable, and testable without a running Godot server (every external dependency is behind an injectable interface: IAudioSink, INavQuery).

Signals: ECS → GDScript events

EcsSignals batches entity deaths and player hits into real Godot signals, emitted once per frame at the sync point — never per entity inside a job.

csharp
// C# — in a system (main thread) or at a sync point:
signals.EnqueueDeath(deadEntity); // ignored if Entity.IsNull
signals.EnqueueHit(25);
int emitted = signals.EmitPending(); // drains both queues, returns total emitted
gdscript
# GDScript — subscribe without knowing anything about ECS:
func _ready():
    ecs_signals.EntityDied.connect(_on_entity_died)
    ecs_signals.HitPlayer.connect(_on_hit_player)

func _on_entity_died(index: int, version: int):
    print("Died: ", index, " v", version)

func _on_hit_player(damage: int):
    hp -= damage

_Process calls EmitPending() automatically when either queue is non-empty. EmittedDeaths / EmittedHits record everything emitted so tests can assert on them without a Godot server.

Audio: positional sounds with a voice budget

Systems never play sounds directly — they push AudioEvent { BankId, Pos, Volume } entries, and AudioBridge flushes them once per frame:

csharp
// C# — setup (once):
audio.RegisterBank(0, GD.Load<AudioStream>("res://sfx/shot.wav"));

// C# — per event (anywhere main-thread):
audio.PushBank(0, muzzlePos, 0.8f);

// C# — flush (automatic in _Process; call manually in tests):
int played = audio.Flush();

Behaviour: Flush() keeps at most MaxVoices (32) events — the loudest win (ClusterCap) — then routes each through Sink?.Play(...) plus a real AudioStreamPlayer3D from a 32-voice round-robin pool created in _Ready(). Volume is clamped to [0.0001, 1] and converted with Mathf.LinearToDb.

For tests, inject a mock IAudioSink and count Played — no Godot server needed:

csharp
audio.Sink = new NullAudioSink(); // counts plays, plays nothing

NavBridge resolves NavRequest { Target, RequestId, Pending } components into NavPath point buffers, at most Budget (default 64) per physics frame. Requests beyond budget stay Pending and are retried next frame.

csharp
// C# — request a path (simulation side):
em.AddComponent<NavRequest>(agent);
em.SetComponentData(agent, new NavRequest { Target = playerPos, RequestId = 7, Pending = true });

// C# — resolve (usually automatic in _PhysicsProcess against World.Default):
int done = NavBridge.ProcessBudget(World.Default, query, 64);

// Read the result:
DynamicBuffer<NavPath> path = em.GetBuffer<NavPath>(agent);
foreach (NavPath p in path.AsSpan())
    MoveToward(p.Point);

The query is behind INavQuery.TryGetPath(from, to, outPoints) — returns false when no path exists, in which case the request stays Pending. The default is StraightLineQuery (straight from → to, no server needed); in production inject a NavigationServer3D-backed query via the Query property. Start position is the entity's LocalTransform.Pos, or Vector3.Zero if it has none.

GDScript facade

GdScriptApi is a Node you drop in the scene that exposes the default world to GDScript. Read-only by design, plus spawn/kill helpers — mass writes stay in C#:

gdscript
extends Node

@onready var ecs = $GdScriptApi

func _ready():
    print("Entities: ", ecs.GetEntityCount())  # World.Default.Entities.LiveCount
    print("Tick: ", ecs.GetTick())              # World.Default.Tick

    var idx = ecs.SpawnScene(enemy_scene, Vector3(10, 0, 5))  # root Index, or -1
    var pos = ecs.GetEntityPos(idx, version)                  # LocalTransform.Pos or Vector3.ZERO
    if not ecs.KillEntity(idx, version):                      # false if already dead
        print("already dead")

SpawnScene appends the scene to World.Default at the given position (empty/null scene → -1). GetEntityPos returns Vector3.ZERO for dead entities or entities without LocalTransform.

Full API

Every type and member with exact C# signatures: Bridge API.

GodotECS docs — version 0.1.0. All rights reserved until a license is added.