From godot-prompter
Provides Godot 4.3+ dependency injection patterns: autoloads, @export injection, service locators, scene injection for loose coupling and testability.
npx claudepluginhub jame581/godotprompter --plugin godot-prompterThis skill uses the workspace's default tool permissions.
Patterns for wiring dependencies between systems so nodes stay loosely coupled, swappable, and testable. All examples target Godot 4.3+ with no deprecated APIs.
Provides Godot 4 GDScript patterns for architecture, signals, scenes, state machines, and optimization. Useful for building games, game systems, and best practices.
Reviews GDScript and C# code in Godot 4.3+ projects using checklists for node architecture, style, best practices, anti-patterns, and pitfalls.
Provides C# patterns for Godot with clear interop boundaries, node ownership, and engine lifecycle awareness. Use when C# interop with scenes/resources grows or architecture needs review.
Share bugs, ideas, or general feedback.
Patterns for wiring dependencies between systems so nodes stay loosely coupled, swappable, and testable. All examples target Godot 4.3+ with no deprecated APIs.
Related skills: godot-testing for test-friendly architecture, event-bus for signal-based decoupling, godot-project-setup for autoload registration.
Tight coupling makes code hard to test, extend, and swap. The most common form in Godot is reaching directly into a global autoload from everywhere in the codebase.
# BAD — tight coupling via direct autoload access scattered everywhere
# player.gd
func take_damage(amount: int) -> void:
health -= amount
AudioManager.play_sfx("hurt") # hard dependency on AudioManager
UIManager.update_health_bar(health) # hard dependency on UIManager
if health <= 0:
GameState.record_death() # hard dependency on GameState
# enemy.gd
func attack() -> void:
AudioManager.play_sfx("attack") # same AudioManager dependency again
// BAD — tight coupling via direct autoload / global access scattered everywhere
// Player.cs
public partial class Player : CharacterBody3D
{
private int _health = 100;
public void TakeDamage(int amount)
{
_health -= amount;
GetNode<AudioManager>("/root/AudioManager").PlaySfx("hurt"); // hard dependency
GetNode<UIManager>("/root/UIManager").UpdateHealthBar(_health); // hard dependency
if (_health <= 0)
GetNode<GameState>("/root/GameState").RecordDeath(); // hard dependency
}
}
// Enemy.cs
public partial class Enemy : CharacterBody3D
{
public void Attack()
{
GetNode<AudioManager>("/root/AudioManager").PlaySfx("attack"); // same dependency again
}
}
Problems with this approach:
AudioManager directly is coupled to its concrete implementation.AudioManager for a different implementation requires changing every caller.Player in isolation is impossible — AudioManager, UIManager, and GameState must all exist and be valid.| Pattern | Complexity | Testability | Best For |
|---|---|---|---|
| Autoloads | Low | Low | Truly global singletons: audio, settings, platform services |
| @export Injection | Low | High | Most nodes — wire deps in the editor, no runtime lookup needed |
| Service Locator | Medium | Medium | Plugins, optional systems, swappable implementations at runtime |
| Scene Injection | Low | High | Parent-to-child wiring: Level sets up Enemy, HUD sets up sub-panels |
Autoloads are appropriate for services that are genuinely global and have no meaningful test double (e.g., audio playback, OS settings, platform APIs). Avoid using them as a dumping ground for any shared state.
When appropriate:
Dangers:
autoloads/audio_manager.gd)extends Node
## Plays a one-shot sound effect by key.
func play_sfx(key: String) -> void:
var stream: AudioStream = _sfx_library.get(key)
if stream == null:
push_warning("AudioManager: unknown sfx key '%s'" % key)
return
var player := AudioStreamPlayer.new()
player.stream = stream
player.finished.connect(player.queue_free)
add_child(player)
player.play()
## Plays background music, crossfading from the current track.
func play_music(key: String, crossfade_time: float = 0.5) -> void:
pass # implementation omitted for brevity
var _sfx_library: Dictionary = {}
func _ready() -> void:
_load_sfx_library()
func _load_sfx_library() -> void:
# Populate _sfx_library from a Resource or folder scan
pass
Autoloads/AudioManager.cs)using Godot;
using System.Collections.Generic;
/// <summary>
/// Global audio service. Register as autoload named "AudioManager".
/// </summary>
public partial class AudioManager : Node
{
private readonly Dictionary<string, AudioStream> _sfxLibrary = new();
public override void _Ready()
{
LoadSfxLibrary();
}
/// <summary>Plays a one-shot sound effect by key.</summary>
public void PlaySfx(string key)
{
if (!_sfxLibrary.TryGetValue(key, out AudioStream stream))
{
GD.PushWarning($"AudioManager: unknown sfx key '{key}'");
return;
}
var player = new AudioStreamPlayer { Stream = stream };
player.Finished += player.QueueFree;
AddChild(player);
player.Play();
}
private void LoadSfxLibrary()
{
// Populate _sfxLibrary from a Resource or folder scan
}
}
The most Godot-idiomatic DI pattern. Declare a dependency as @export and wire it in the editor Inspector — or assign it from a parent in code. The node never needs to know where its dependency lives in the tree.
This is preferred over get_node("../../SomeNode") hard-coded paths, which break silently when the scene tree is reorganised.
# health_component.gd — receives its audio dependency via @export
class_name HealthComponent
extends Node
@export var audio: AudioManager ## Set in the Inspector or by parent
@export var max_health: int = 100
var current_health: int
func _ready() -> void:
current_health = max_health
func take_damage(amount: int) -> void:
current_health = clampi(current_health - amount, 0, max_health)
# Uses the injected reference — no direct autoload access
if audio != null:
audio.play_sfx("hurt")
health_changed.emit(current_health, max_health)
if current_health == 0:
died.emit()
signal health_changed(current: int, maximum: int)
signal died
# player.gd — wires the component in the editor or in _ready()
extends CharacterBody2D
@export var health: HealthComponent ## Drag HealthComponent node here in Inspector
using Godot;
/// <summary>
/// Reusable health component. Wire dependencies via the Inspector.
/// </summary>
public partial class HealthComponent : Node
{
[Export] public AudioManager Audio { get; set; } // Set in Inspector or by parent
[Export] public int MaxHealth { get; set; } = 100;
[Signal] public delegate void HealthChangedEventHandler(int current, int maximum);
[Signal] public delegate void DiedEventHandler();
private int _currentHealth;
public override void _Ready()
{
_currentHealth = MaxHealth;
}
public void TakeDamage(int amount)
{
_currentHealth = Mathf.Clamp(_currentHealth - amount, 0, MaxHealth);
Audio?.PlaySfx("hurt");
EmitSignal(SignalName.HealthChanged, _currentHealth, MaxHealth);
if (_currentHealth == 0)
EmitSignal(SignalName.Died);
}
}
using Godot;
public partial class Player : CharacterBody2D
{
// Drag HealthComponent node here in the Inspector
[Export] public HealthComponent Health { get; set; }
}
A Service Locator is an autoload that acts as a runtime registry. Systems register themselves by name; consumers retrieve them by name. Unlike direct autoload access, the locator is the only hard dependency — everything else is swappable.
Useful for:
autoloads/service_locator.gd)extends Node
var _services: Dictionary = {}
## Registers a service under [param service_name].
## Call from the service's own _ready().
func register(service_name: String, instance: Object) -> void:
if _services.has(service_name):
push_warning("ServiceLocator: overwriting existing service '%s'" % service_name)
_services[service_name] = instance
## Removes a service registration. Call from _exit_tree() of the service.
func unregister(service_name: String) -> void:
_services.erase(service_name)
## Returns the service registered under [param service_name], or null.
## Cast the result to the expected type at the call site.
func get_service(service_name: String) -> Object:
if not _services.has(service_name):
push_warning("ServiceLocator: no service registered for '%s'" % service_name)
return null
return _services[service_name]
## Typed convenience helper — returns null and warns if the cast fails.
func get_typed(service_name: String, expected_type: Variant) -> Variant:
var svc: Object = get_service(service_name)
if svc == null:
return null
if not is_instance_of(svc, expected_type):
push_warning(
"ServiceLocator: '%s' is not an instance of %s" % [service_name, expected_type]
)
return null
return svc
# audio_service.gd — self-registers on ready
extends Node
class_name AudioService
func _ready() -> void:
ServiceLocator.register("audio", self)
func _exit_tree() -> void:
ServiceLocator.unregister("audio")
func play_sfx(key: String) -> void:
pass # implementation
# consumer.gd — retrieves the service at runtime
func _ready() -> void:
var audio := ServiceLocator.get_typed("audio", AudioService) as AudioService
if audio != null:
audio.play_sfx("pickup")
Autoloads/ServiceLocator.cs)using Godot;
using System.Collections.Generic;
/// <summary>
/// Runtime service registry. Register as autoload named "ServiceLocator".
/// </summary>
public partial class ServiceLocator : Node
{
private readonly Dictionary<string, GodotObject> _services = new();
/// <summary>Registers a service under <paramref name="serviceName"/>.</summary>
public void Register(string serviceName, GodotObject instance)
{
if (_services.ContainsKey(serviceName))
GD.PushWarning($"ServiceLocator: overwriting existing service '{serviceName}'");
_services[serviceName] = instance;
}
/// <summary>Removes a service registration.</summary>
public void Unregister(string serviceName) => _services.Remove(serviceName);
/// <summary>Returns the service or null, with a warning if missing.</summary>
public GodotObject GetService(string serviceName)
{
if (!_services.TryGetValue(serviceName, out var service))
{
GD.PushWarning($"ServiceLocator: no service registered for '{serviceName}'");
return null;
}
return service;
}
/// <summary>Typed retrieval — returns null and warns on type mismatch.</summary>
public T GetService<T>(string serviceName) where T : GodotObject
{
var service = GetService(serviceName);
if (service is T typed)
return typed;
if (service != null)
GD.PushWarning($"ServiceLocator: '{serviceName}' is not of type {typeof(T).Name}");
return null;
}
}
using Godot;
// AudioService.cs — self-registers
public partial class AudioService : Node
{
public override void _Ready()
{
GetNode<ServiceLocator>("/root/ServiceLocator").Register("audio", this);
}
public override void _ExitTree()
{
GetNode<ServiceLocator>("/root/ServiceLocator").Unregister("audio");
}
public void PlaySfx(string key) { /* implementation */ }
}
// Consumer.cs — typed retrieval
public partial class Pickup : Area2D
{
public override void _Ready()
{
var audio = GetNode<ServiceLocator>("/root/ServiceLocator")
.GetService<AudioService>("audio");
audio?.PlaySfx("pickup");
}
}
A parent scene constructs or loads its children and sets their dependencies in _ready(). Children declare what they need via @export properties or setter methods; the parent fills them in. Children stay free of hard-coded paths.
Example: Level injects the player reference into all Enemy children.
# enemy.gd — declares its dependency; does not go looking for the player
class_name Enemy
extends CharacterBody2D
## Set by the Level scene in _ready(). Enemy will not search for the player itself.
var player: CharacterBody2D = null
func _physics_process(delta: float) -> void:
if player == null:
return
# Move toward the injected player reference
var direction := (player.global_position - global_position).normalized()
velocity = direction * 120.0
move_and_slide()
# level.gd — owns both Player and Enemies; injects the reference
extends Node2D
@onready var player: CharacterBody2D = $Player
func _ready() -> void:
# Inject the player reference into every enemy in the Enemies group
for enemy in get_tree().get_nodes_in_group("enemies"):
if enemy is Enemy:
enemy.player = player
using Godot;
// Enemy.cs — receives its dependency; does not search the tree
public partial class Enemy : CharacterBody2D
{
/// <summary>Set by the Level scene in _Ready(). Do not call GetNode here.</summary>
public CharacterBody2D Player { get; set; }
public override void _PhysicsProcess(double delta)
{
if (Player == null) return;
Vector2 direction = (Player.GlobalPosition - GlobalPosition).Normalized();
Velocity = direction * 120f;
MoveAndSlide();
}
}
using Godot;
// Level.cs — owns Player and Enemies; wires the dependency
public partial class Level : Node2D
{
private CharacterBody2D _player;
public override void _Ready()
{
_player = GetNode<CharacterBody2D>("Player");
foreach (Node node in GetTree().GetNodesInGroup("enemies"))
{
if (node is Enemy enemy)
enemy.Player = _player;
}
}
}
Injectable dependencies make tests trivial: swap the real service for a stub or mock. Because the node declares what it needs (via @export or a property), tests can provide a controlled stand-in without touching autoloads.
# stub_audio.gd — a lightweight stand-in for AudioManager
class_name StubAudio
extends Node
var last_sfx: String = ""
var play_count: int = 0
func play_sfx(key: String) -> void:
last_sfx = key
play_count += 1
# test_health_component.gd
extends GutTest
var health: HealthComponent
var audio_stub: StubAudio
func before_each() -> void:
audio_stub = StubAudio.new()
add_child_autofree(audio_stub)
health = preload("res://components/health_component.tscn").instantiate()
health.audio = audio_stub # inject the stub — no real AudioManager needed
health.max_health = 100
add_child_autofree(health)
func test_take_damage_plays_hurt_sfx() -> void:
health.take_damage(10)
assert_eq(audio_stub.last_sfx, "hurt")
assert_eq(audio_stub.play_count, 1)
func test_no_sfx_when_audio_is_null() -> void:
health.audio = null # also valid — null is handled gracefully
health.take_damage(10)
pass # should not crash
func test_died_signal_emitted_at_zero_health() -> void:
watch_signals(health)
health.take_damage(100)
assert_signal_emitted(health, "died")
| Situation | Recommended Pattern |
|---|---|
| Service used by nearly every node in every scene | Autoload singleton |
| Node needs 1–3 deps, scene is editor-authored | @export injection |
| System is optional or swappable at runtime | Service Locator |
| Parent scene constructs children and knows their needs | Scene injection |
| Writing tests for a node with external dependencies | @export or property injection + stubs |
| Plugin that must work in any project | Service Locator (self-registers, no assumptions) |
| Two sibling nodes need the same dep | Let their parent hold it and inject downward |
Quick decision guide:
Does every scene in the project need it?
YES → Autoload singleton
NO ↓
Is the dependency known at edit-time and wired in the Inspector?
YES → @export injection
NO ↓
Does the dependency need to be swapped at runtime (plugins, A/B testing)?
YES → Service Locator
NO ↓
Does a parent scene own both the consumer and the dependency?
YES → Scene injection
NO → Reconsider — either promote to autoload or restructure ownership
# BAD — GameManager, EnemySpawner, InventorySystem, DialogueSystem all as autoloads.
# Every node in the game is coupled to every other system at module level.
# Test one component → must initialise all autoloads.
# GOOD — Only AudioManager, Settings, and SceneTransition are autoloads.
# EnemySpawner is a node in the Level scene, injected into enemies that need it.
# BAD — Player needs HealthComponent, which needs AudioManager,
# which needs SoundBank, which needs FileSystem...
# A change deep in the chain breaks everything above it.
# GOOD — flatten: HealthComponent takes only AudioManager (or a narrow interface).
# Each node declares only immediate dependencies.
# BAD
# PlayerController._ready() calls ServiceLocator.get_service("inventory")
# InventorySystem._ready() calls ServiceLocator.get_service("player")
# Neither can fully initialise because the other isn't ready yet.
# GOOD — break the cycle with a signal.
# InventorySystem emits item_used; PlayerController connects to it.
# PlayerController never holds a reference to InventorySystem at all.
# BAD — everything is registered: enemies, UI panels, individual nodes.
# ServiceLocator becomes a second, untyped scene tree.
# GOOD — only register stable, long-lived services (audio, analytics, save system).
# Short-lived nodes are wired by their parent via scene injection.
# BAD — crashes if the @export was never set in the editor
func take_damage(amount: int) -> void:
audio.play_sfx("hurt") # NullReferenceError if audio was not wired
# GOOD — guard or assert clearly
func take_damage(amount: int) -> void:
assert(audio != null, "HealthComponent: audio dependency was not injected")
audio.play_sfx("hurt")
# OR — treat it as optional
func take_damage(amount: int) -> void:
if audio != null:
audio.play_sfx("hurt")
@export or a public property) rather than calling get_node on distant relatives@export fields are validated (assert or null check) before useunregister in _exit_tree() / _ExitTree()_ready(), after children are fully initialised@export ([Export]) dependencies are disconnected / cleared in _ExitTree() if they hold event subscriptions