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:
AudioEventbuffer elements,NavRequestcomponents, death/hit entries queued onEcsSignals. - Bridge nodes (
AudioBridge,NavBridge,EcsSignals) run on the main thread in_Process/_PhysicsProcessand drain those requests once per frame: playing sounds, resolving paths, emitting signals. - The reverse direction follows the same rule: a
CharacterBody3Dplayer is never moved by ECS; systems read its position once per tick (via a singleton such asPlayerBodyRef), and hero impulses go out throughPhysicsServer3Donly at a sync point. - Per-frame budgets cap the cost: at most
AudioBridge.MaxVoices(32) sounds andNavBridge.PathsPerFrame(64) paths per frame. Overflow is dropped (audio, keep loudest) or deferred (nav, staysPending).
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.
// 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 — 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:
// 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:
audio.Sink = new NullAudioSink(); // counts plays, plays nothingNavigation: budgeted pathfinding
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.
// 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#:
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.