godot-csharp

star 4

Comprehensive Godot 4 C# game development skill with TDD using GdUnit4/xUnit/NUnit, BDD with Reqnroll/Gherkin feature files, SOLID principles, design patterns (State Machine, Component, Observer, Command), and Godot C# API best practices. Use this skill when building Godot games with C#.

curtbushko By curtbushko schedule Updated 1/29/2026

name: godot-csharp description: Comprehensive Godot 4 C# game development skill with TDD using GdUnit4/xUnit/NUnit, BDD with Reqnroll/Gherkin feature files, SOLID principles, design patterns (State Machine, Component, Observer, Command), and Godot C# API best practices. Use this skill when building Godot games with C#.

Godot C# Game Development Skill

You are an expert Godot C# game developer who follows Test-Driven Development (TDD) and Behavior-Driven Development (BDD) principles using modern .NET practices.

Critical Requirements

Build Quality (NON-NEGOTIABLE)

  • Project MUST build and run without errors
  • Before completing any task, verify:
    • dotnet build succeeds with no errors
    • Project runs in Godot editor without errors
    • All unit tests pass (GdUnit4 or xUnit/NUnit)
    • All BDD scenarios pass (if using Reqnroll)
  • If any of these fail, fix the issues before marking the task complete
  • NEVER leave code in a broken state

Output and Documentation Standards

  • NEVER use emojis in code, comments, documentation, or any output
  • Keep all communication professional and text-based
  • Use Nerd Fonts icons for CLI output if visual indicators are needed

Test-Driven Development (TDD)

ALWAYS follow the TDD cycle when implementing new functionality:

  1. RED: Write a failing test first

    • Write the test that describes the desired behavior
    • Run tests and confirm it fails for the right reason
    • This validates that the test can actually detect failures
  2. GREEN: Write minimal code to make the test pass

    • Implement just enough code to make the test pass
    • Don't add extra features or over-engineer
    • Run tests and confirm it passes
  3. REFACTOR: Improve the code while keeping tests green

    • Clean up the implementation
    • Remove duplication
    • Improve naming and structure
    • Run tests after each refactoring to ensure they still pass

Project Structure

Recommended Directory Layout

project/
├── addons/                    # Godot addons (GdUnit4, etc.)
├── assets/                    # Raw assets
│   ├── sprites/
│   ├── audio/
│   │   ├── music/
│   │   └── sfx/
│   ├── fonts/
│   └── shaders/
├── resources/                 # .tres resource files
│   ├── themes/
│   ├── materials/
│   └── data/
├── scenes/                    # .tscn scene files
│   ├── actors/
│   ├── levels/
│   ├── ui/
│   └── components/
├── scripts/                   # C# source files
│   ├── Autoloads/            # Singleton scripts
│   ├── Components/           # Reusable components
│   ├── Entities/             # Game entities (Player, Enemy, etc.)
│   ├── Resources/            # Custom Resource classes
│   ├── States/               # State machine states
│   ├── Systems/              # Game systems (Input, Audio, etc.)
│   └── Utils/                # Utility classes
├── tests/                     # Test projects
│   ├── Unit/                 # Unit tests (xUnit/NUnit/GdUnit4)
│   ├── Integration/          # Integration tests
│   ├── Features/             # BDD feature files (.feature)
│   └── StepDefinitions/      # Reqnroll step definitions
├── project.godot
├── MyGame.csproj
├── MyGame.Tests.csproj       # Test project
└── MyGame.sln

File Naming Conventions

  • Scenes: PascalCase.tscn (e.g., PlayerCharacter.tscn)
  • Scripts: PascalCase.cs (e.g., PlayerController.cs)
  • Resources: PascalCase.tres (e.g., PlayerStats.tres)
  • Tests: <ClassName>Tests.cs (e.g., PlayerControllerTests.cs)
  • Feature files: <Feature>.feature (e.g., PlayerMovement.feature)
  • Step definitions: <Feature>Steps.cs (e.g., PlayerMovementSteps.cs)

Testing Frameworks

Option 1: GdUnit4 (Recommended for Godot-Specific Testing)

GdUnit4 is an embedded unit testing framework for Godot 4 supporting C#.

Installation:

<!-- In your .csproj -->
<ItemGroup>
  <PackageReference Include="gdUnit4.api" Version="4.*" />
</ItemGroup>

Test Structure:

using GdUnit4;
using static GdUnit4.Assertions;

namespace MyGame.Tests;

[TestSuite]
public class HealthComponentTests
{
    private HealthComponent _healthComponent = null!;

    [Before]
    public void Setup()
    {
        _healthComponent = new HealthComponent();
    }

    [After]
    public void Teardown()
    {
        _healthComponent?.Free();
    }

    [TestCase]
    public void InitialHealth_ShouldEqualMaxHealth()
    {
        // Arrange
        _healthComponent.MaxHealth = 100;

        // Act
        _healthComponent._Ready();

        // Assert
        AssertThat(_healthComponent.Health).IsEqual(100);
    }

    [TestCase]
    public void TakeDamage_ShouldReduceHealth()
    {
        // Arrange
        _healthComponent.MaxHealth = 100;
        _healthComponent._Ready();

        // Act
        _healthComponent.TakeDamage(25);

        // Assert
        AssertThat(_healthComponent.Health).IsEqual(75);
    }

    [TestCase]
    public void TakeDamage_ShouldEmitHealthChangedSignal()
    {
        // Arrange
        _healthComponent.MaxHealth = 100;
        _healthComponent._Ready();
        var monitor = _healthComponent.MonitorSignal("HealthChanged");

        // Act
        _healthComponent.TakeDamage(10);

        // Assert
        AssertThat(monitor).IsEmitted();
    }
}

Option 2: xUnit/NUnit (For Logic-Only Testing)

Use standard .NET testing frameworks for non-Godot-specific logic.

Installation:

<!-- MyGame.Tests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="xunit" Version="2.*" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
    <PackageReference Include="FluentAssertions" Version="6.*" />
    <PackageReference Include="NSubstitute" Version="5.*" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\MyGame.csproj" />
  </ItemGroup>
</Project>

Test Structure:

using FluentAssertions;
using NSubstitute;
using Xunit;

namespace MyGame.Tests;

public class DamageCalculatorTests
{
    [Fact]
    public void CalculateDamage_WithNoArmor_ReturnsFullDamage()
    {
        // Arrange
        var calculator = new DamageCalculator();
        var baseDamage = 100;
        var armor = 0;

        // Act
        var result = calculator.Calculate(baseDamage, armor);

        // Assert
        result.Should().Be(100);
    }

    [Theory]
    [InlineData(100, 0, 100)]
    [InlineData(100, 50, 50)]
    [InlineData(100, 100, 1)]  // Minimum 1 damage
    public void CalculateDamage_WithArmor_ReducesDamageCorrectly(
        int baseDamage, int armor, int expected)
    {
        // Arrange
        var calculator = new DamageCalculator();

        // Act
        var result = calculator.Calculate(baseDamage, armor);

        // Assert
        result.Should().Be(expected);
    }
}

Option 3: Chickensoft GoDotTest (Integrated Testing)

For tests that need to run within Godot's runtime.

Installation:

<ItemGroup>
  <PackageReference Include="Chickensoft.GoDotTest" Version="2.*" />
</ItemGroup>

Test Structure:

using Chickensoft.GoDotTest;
using Godot;
using Shouldly;

namespace MyGame.Tests;

public class PlayerTests : TestClass
{
    private Player _player = null!;

    public PlayerTests(Node testScene) : base(testScene) { }

    [Setup]
    public void Setup()
    {
        _player = new Player();
        TestScene.AddChild(_player);
    }

    [Cleanup]
    public void Cleanup()
    {
        _player.QueueFree();
    }

    [Test]
    public void Player_ShouldMoveRight_WhenInputPressed()
    {
        // Arrange
        var initialPosition = _player.Position;
        Input.ActionPress("move_right");

        // Act
        _player._PhysicsProcess(0.016); // Simulate one frame

        // Assert
        _player.Position.X.ShouldBeGreaterThan(initialPosition.X);

        // Cleanup
        Input.ActionRelease("move_right");
    }
}

BDD with Reqnroll

Installation

<!-- MyGame.Tests.csproj -->
<ItemGroup>
  <PackageReference Include="Reqnroll" Version="2.*" />
  <PackageReference Include="Reqnroll.xUnit" Version="2.*" />
</ItemGroup>

Feature File Structure

# tests/Features/PlayerHealth.feature
Feature: Player Health System
    As a player
    I want to have a health system
    So that I can take damage and die

    Background:
        Given a player with 100 max health

    Scenario: Player takes damage
        When the player takes 25 damage
        Then the player health should be 75

    Scenario: Player cannot have negative health
        When the player takes 9999 damage
        Then the player health should be 0
        And the player should be dead

    Scenario: Player heals after taking damage
        Given the player has taken 50 damage
        When the player heals 30 health
        Then the player health should be 80

    Scenario Outline: Damage calculation with armor
        Given the player has <armor> armor
        When the player takes <damage> raw damage
        Then the player should receive <actual> damage

        Examples:
            | armor | damage | actual |
            | 0     | 100    | 100    |
            | 50    | 100    | 50     |
            | 100   | 100    | 1      |

Step Definitions

// tests/StepDefinitions/PlayerHealthSteps.cs
using Reqnroll;
using FluentAssertions;

namespace MyGame.Tests.StepDefinitions;

[Binding]
public class PlayerHealthSteps
{
    private HealthComponent _healthComponent = null!;
    private int _lastDamageReceived;

    [Given(@"a player with (\d+) max health")]
    public void GivenAPlayerWithMaxHealth(int maxHealth)
    {
        _healthComponent = new HealthComponent
        {
            MaxHealth = maxHealth
        };
        _healthComponent._Ready();
    }

    [Given(@"the player has (\d+) armor")]
    public void GivenThePlayerHasArmor(int armor)
    {
        _healthComponent.Armor = armor;
    }

    [Given(@"the player has taken (\d+) damage")]
    public void GivenThePlayerHasTakenDamage(int damage)
    {
        _healthComponent.TakeDamage(damage);
    }

    [When(@"the player takes (\d+) damage")]
    public void WhenThePlayerTakesDamage(int damage)
    {
        _healthComponent.TakeDamage(damage);
    }

    [When(@"the player takes (\d+) raw damage")]
    public void WhenThePlayerTakesRawDamage(int damage)
    {
        _lastDamageReceived = _healthComponent.CalculateDamage(damage);
        _healthComponent.TakeDamage(_lastDamageReceived);
    }

    [When(@"the player heals (\d+) health")]
    public void WhenThePlayerHealsHealth(int amount)
    {
        _healthComponent.Heal(amount);
    }

    [Then(@"the player health should be (\d+)")]
    public void ThenThePlayerHealthShouldBe(int expected)
    {
        _healthComponent.Health.Should().Be(expected);
    }

    [Then(@"the player should be dead")]
    public void ThenThePlayerShouldBeDead()
    {
        _healthComponent.IsDead.Should().BeTrue();
    }

    [Then(@"the player should receive (\d+) damage")]
    public void ThenThePlayerShouldReceiveDamage(int expected)
    {
        _lastDamageReceived.Should().Be(expected);
    }
}

Hooks for Test Lifecycle

// tests/StepDefinitions/Hooks.cs
using Reqnroll;

namespace MyGame.Tests.StepDefinitions;

[Binding]
public class Hooks
{
    [BeforeScenario]
    public void BeforeScenario(ScenarioContext context)
    {
        // Setup before each scenario
    }

    [AfterScenario]
    public void AfterScenario(ScenarioContext context)
    {
        // Cleanup after each scenario
    }

    [BeforeFeature]
    public static void BeforeFeature(FeatureContext context)
    {
        // Setup before each feature
    }

    [AfterFeature]
    public static void AfterFeature(FeatureContext context)
    {
        // Cleanup after each feature
    }
}

C# Godot Best Practices

Node References

public partial class Player : CharacterBody2D
{
    // Use [Export] for editor-configurable references
    [Export] public HealthComponent? HealthComponent { get; set; }
    [Export] public Sprite2D? Sprite { get; set; }

    // Cache node references in _Ready
    private AnimationPlayer _animationPlayer = null!;
    private StateMachine _stateMachine = null!;

    public override void _Ready()
    {
        _animationPlayer = GetNode<AnimationPlayer>("AnimationPlayer");
        _stateMachine = GetNode<StateMachine>("StateMachine");

        // Null check exports
        if (HealthComponent is null)
            GD.PushError("HealthComponent not assigned!");
    }
}

Signals (C# Style)

public partial class HealthComponent : Node
{
    // Signal definitions using delegates
    [Signal]
    public delegate void HealthChangedEventHandler(int newHealth, int maxHealth);

    [Signal]
    public delegate void DamagedEventHandler(int amount, Node? source);

    [Signal]
    public delegate void DiedEventHandler();

    private int _health;
    public int Health
    {
        get => _health;
        private set
        {
            var oldHealth = _health;
            _health = Math.Clamp(value, 0, MaxHealth);

            if (_health != oldHealth)
            {
                EmitSignal(SignalName.HealthChanged, _health, MaxHealth);
            }

            if (_health <= 0 && oldHealth > 0)
            {
                EmitSignal(SignalName.Died);
            }
        }
    }

    [Export]
    public int MaxHealth { get; set; } = 100;

    public bool IsDead => Health <= 0;

    public override void _Ready()
    {
        Health = MaxHealth;
    }

    public void TakeDamage(int amount, Node? source = null)
    {
        if (IsDead) return;

        Health -= amount;
        EmitSignal(SignalName.Damaged, amount, source);
    }

    public void Heal(int amount)
    {
        if (IsDead) return;
        Health += amount;
    }
}

Signal Connections

public override void _Ready()
{
    // Connect to signals using C# events
    HealthComponent.HealthChanged += OnHealthChanged;
    HealthComponent.Died += OnDied;

    // Or using Godot's Connect method
    HealthComponent.Connect(
        HealthComponent.SignalName.HealthChanged,
        Callable.From<int, int>(OnHealthChanged)
    );
}

public override void _ExitTree()
{
    // Disconnect signals
    HealthComponent.HealthChanged -= OnHealthChanged;
    HealthComponent.Died -= OnDied;
}

private void OnHealthChanged(int newHealth, int maxHealth)
{
    GD.Print($"Health: {newHealth}/{maxHealth}");
}

private void OnDied()
{
    GD.Print("Player died!");
}

Async/Await with Godot

public async Task PlayAttackAnimation()
{
    _animationPlayer.Play("attack");

    // Wait for animation to finish
    await ToSignal(_animationPlayer, AnimationPlayer.SignalName.AnimationFinished);

    _animationPlayer.Play("idle");
}

public async Task WaitForSeconds(double seconds)
{
    await ToSignal(
        GetTree().CreateTimer(seconds),
        SceneTreeTimer.SignalName.Timeout
    );
}

public async Task FadeOut(float duration)
{
    var tween = CreateTween();
    tween.TweenProperty(this, "modulate:a", 0f, duration);
    await ToSignal(tween, Tween.SignalName.Finished);
}

Properties vs Fields

public partial class Enemy : CharacterBody2D
{
    // Use [Export] for editor-visible properties
    [Export]
    public float MoveSpeed { get; set; } = 100f;

    [Export]
    public int Damage { get; set; } = 10;

    // Private fields with underscore prefix
    private Vector2 _targetPosition;
    private bool _isChasing;

    // Read-only computed properties
    public bool IsMoving => Velocity.LengthSquared() > 0.01f;
    public float DistanceToTarget => Position.DistanceTo(_targetPosition);
}

Design Patterns

State Machine Pattern

// States/IState.cs
public interface IState
{
    void Enter();
    void Exit();
    void Update(double delta);
    void PhysicsUpdate(double delta);
    void HandleInput(InputEvent @event);
}

// States/StateMachine.cs
public partial class StateMachine : Node
{
    [Signal]
    public delegate void StateChangedEventHandler(IState? oldState, IState newState);

    [Export]
    public NodePath? InitialStatePath { get; set; }

    public IState? CurrentState { get; private set; }

    private Dictionary<string, IState> _states = new();

    public override void _Ready()
    {
        foreach (var child in GetChildren())
        {
            if (child is IState state)
            {
                _states[child.Name.ToString().ToLower()] = state;
            }
        }

        if (InitialStatePath is not null)
        {
            var initialState = GetNode<Node>(InitialStatePath);
            if (initialState is IState state)
            {
                TransitionTo(initialState.Name);
            }
        }
    }

    public override void _Process(double delta)
    {
        CurrentState?.Update(delta);
    }

    public override void _PhysicsProcess(double delta)
    {
        CurrentState?.PhysicsUpdate(delta);
    }

    public override void _UnhandledInput(InputEvent @event)
    {
        CurrentState?.HandleInput(@event);
    }

    public void TransitionTo(string stateName)
    {
        var key = stateName.ToLower();
        if (!_states.TryGetValue(key, out var newState))
        {
            GD.PushError($"State '{stateName}' not found");
            return;
        }

        var oldState = CurrentState;
        CurrentState?.Exit();
        CurrentState = newState;
        CurrentState.Enter();

        EmitSignal(SignalName.StateChanged, oldState, newState);
    }
}

// States/PlayerIdleState.cs
public partial class PlayerIdleState : Node, IState
{
    [Export]
    public CharacterBody2D? Actor { get; set; }

    [Export]
    public AnimatedSprite2D? AnimatedSprite { get; set; }

    private StateMachine _stateMachine = null!;

    public override void _Ready()
    {
        _stateMachine = GetParent<StateMachine>();
    }

    public void Enter()
    {
        AnimatedSprite?.Play("idle");
    }

    public void Exit() { }

    public void Update(double delta) { }

    public void PhysicsUpdate(double delta)
    {
        var direction = Input.GetAxis("move_left", "move_right");
        if (Math.Abs(direction) > 0.1f)
        {
            _stateMachine.TransitionTo("Run");
        }

        if (Input.IsActionJustPressed("jump"))
        {
            _stateMachine.TransitionTo("Jump");
        }
    }

    public void HandleInput(InputEvent @event) { }
}

Component Pattern

// Components/HealthComponent.cs
public partial class HealthComponent : Node
{
    [Signal]
    public delegate void HealthChangedEventHandler(int newHealth, int maxHealth);

    [Signal]
    public delegate void DiedEventHandler();

    [Export]
    public int MaxHealth { get; set; } = 100;

    [Export]
    public float InvincibilityDuration { get; set; } = 0f;

    public int Health { get; private set; }
    public bool IsInvincible { get; private set; }
    public bool IsDead => Health <= 0;

    public override void _Ready()
    {
        Health = MaxHealth;
    }

    public void TakeDamage(int amount, Node? source = null)
    {
        if (IsInvincible || IsDead) return;

        Health = Math.Max(0, Health - amount);
        EmitSignal(SignalName.HealthChanged, Health, MaxHealth);

        if (IsDead)
        {
            EmitSignal(SignalName.Died);
        }
        else if (InvincibilityDuration > 0)
        {
            StartInvincibility();
        }
    }

    public void Heal(int amount)
    {
        if (IsDead) return;

        Health = Math.Min(MaxHealth, Health + amount);
        EmitSignal(SignalName.HealthChanged, Health, MaxHealth);
    }

    private async void StartInvincibility()
    {
        IsInvincible = true;
        await ToSignal(
            GetTree().CreateTimer(InvincibilityDuration),
            SceneTreeTimer.SignalName.Timeout
        );
        IsInvincible = false;
    }
}

// Components/HitboxComponent.cs
public partial class HitboxComponent : Area2D
{
    [Signal]
    public delegate void HitEventHandler(HurtboxComponent hurtbox);

    [Export]
    public int Damage { get; set; } = 10;

    [Export]
    public float KnockbackForce { get; set; } = 200f;

    public override void _Ready()
    {
        AreaEntered += OnAreaEntered;
    }

    private void OnAreaEntered(Area2D area)
    {
        if (area is HurtboxComponent hurtbox)
        {
            hurtbox.ReceiveHit(this);
            EmitSignal(SignalName.Hit, hurtbox);
        }
    }
}

// Components/HurtboxComponent.cs
public partial class HurtboxComponent : Area2D
{
    [Signal]
    public delegate void HurtEventHandler(HitboxComponent hitbox);

    [Export]
    public HealthComponent? HealthComponent { get; set; }

    public void ReceiveHit(HitboxComponent hitbox)
    {
        HealthComponent?.TakeDamage(hitbox.Damage, hitbox.Owner);
        EmitSignal(SignalName.Hurt, hitbox);
    }
}

Observer Pattern (Event Bus)

// Autoloads/Events.cs
public partial class Events : Node
{
    // Singleton instance
    public static Events Instance { get; private set; } = null!;

    // Player events
    [Signal]
    public delegate void PlayerSpawnedEventHandler(Node player);

    [Signal]
    public delegate void PlayerDiedEventHandler(Node player);

    [Signal]
    public delegate void PlayerHealthChangedEventHandler(int health, int maxHealth);

    // Game events
    [Signal]
    public delegate void LevelStartedEventHandler(string levelName);

    [Signal]
    public delegate void LevelCompletedEventHandler(string levelName);

    [Signal]
    public delegate void GamePausedEventHandler();

    [Signal]
    public delegate void GameResumedEventHandler();

    // Economy events
    [Signal]
    public delegate void CoinsChangedEventHandler(int newAmount);

    public override void _Ready()
    {
        Instance = this;
    }
}

// Usage in Player.cs
public override void _Ready()
{
    Events.Instance.EmitSignal(Events.SignalName.PlayerSpawned, this);
}

// Usage in UI
public override void _Ready()
{
    Events.Instance.PlayerHealthChanged += OnPlayerHealthChanged;
    Events.Instance.PlayerDied += OnPlayerDied;
}

public override void _ExitTree()
{
    Events.Instance.PlayerHealthChanged -= OnPlayerHealthChanged;
    Events.Instance.PlayerDied -= OnPlayerDied;
}

Command Pattern

// Commands/ICommand.cs
public interface ICommand
{
    void Execute();
    void Undo();
}

// Commands/MoveCommand.cs
public class MoveCommand : ICommand
{
    private readonly Node2D _actor;
    private readonly Vector2 _direction;
    private readonly float _distance;

    public MoveCommand(Node2D actor, Vector2 direction, float distance)
    {
        _actor = actor;
        _direction = direction;
        _distance = distance;
    }

    public void Execute()
    {
        _actor.Position += _direction * _distance;
    }

    public void Undo()
    {
        _actor.Position -= _direction * _distance;
    }
}

// Commands/CommandHistory.cs
public class CommandHistory
{
    private readonly List<ICommand> _history = new();
    private int _currentIndex = -1;

    public void Execute(ICommand command)
    {
        // Remove any commands after current index
        if (_currentIndex < _history.Count - 1)
        {
            _history.RemoveRange(_currentIndex + 1, _history.Count - _currentIndex - 1);
        }

        command.Execute();
        _history.Add(command);
        _currentIndex++;
    }

    public bool Undo()
    {
        if (_currentIndex < 0) return false;

        _history[_currentIndex].Undo();
        _currentIndex--;
        return true;
    }

    public bool Redo()
    {
        if (_currentIndex >= _history.Count - 1) return false;

        _currentIndex++;
        _history[_currentIndex].Execute();
        return true;
    }
}

Object Pool Pattern

// Systems/ObjectPool.cs
public partial class ObjectPool<T> : Node where T : Node
{
    private readonly PackedScene _scene;
    private readonly Queue<T> _available = new();
    private readonly HashSet<T> _inUse = new();
    private readonly int _initialSize;
    private readonly bool _canGrow;

    public ObjectPool(PackedScene scene, int initialSize = 20, bool canGrow = true)
    {
        _scene = scene;
        _initialSize = initialSize;
        _canGrow = canGrow;
    }

    public override void _Ready()
    {
        for (int i = 0; i < _initialSize; i++)
        {
            CreateInstance();
        }
    }

    private T CreateInstance()
    {
        var instance = _scene.Instantiate<T>();
        instance.SetProcess(false);
        instance.SetPhysicsProcess(false);

        if (instance is Node2D node2D)
            node2D.Visible = false;
        else if (instance is Node3D node3D)
            node3D.Visible = false;

        AddChild(instance);
        _available.Enqueue(instance);
        return instance;
    }

    public T? Acquire()
    {
        T instance;

        if (_available.Count == 0)
        {
            if (_canGrow)
            {
                instance = CreateInstance();
                _available.Dequeue(); // Remove from available since we just added it
            }
            else
            {
                GD.PushWarning("Object pool exhausted");
                return null;
            }
        }
        else
        {
            instance = _available.Dequeue();
        }

        _inUse.Add(instance);
        instance.SetProcess(true);
        instance.SetPhysicsProcess(true);

        if (instance is Node2D node2D)
            node2D.Visible = true;
        else if (instance is Node3D node3D)
            node3D.Visible = true;

        if (instance is IPoolable poolable)
            poolable.OnAcquire();

        return instance;
    }

    public void Release(T instance)
    {
        if (!_inUse.Contains(instance))
        {
            GD.PushWarning("Trying to release instance not from this pool");
            return;
        }

        if (instance is IPoolable poolable)
            poolable.OnRelease();

        instance.SetProcess(false);
        instance.SetPhysicsProcess(false);

        if (instance is Node2D node2D)
            node2D.Visible = false;
        else if (instance is Node3D node3D)
            node3D.Visible = false;

        _inUse.Remove(instance);
        _available.Enqueue(instance);
    }
}

public interface IPoolable
{
    void OnAcquire();
    void OnRelease();
}

Running Tests

GdUnit4

# Run all tests from command line
godot --headless -s addons/gdUnit4/bin/GdUnitCmdTool.gd --run-all

# Run specific test suite
godot --headless -s addons/gdUnit4/bin/GdUnitCmdTool.gd --run=res://tests/Unit/HealthComponentTests.cs

xUnit/NUnit

# Run all tests
dotnet test

# Run with verbosity
dotnet test --verbosity normal

# Run specific test class
dotnet test --filter "FullyQualifiedName~HealthComponentTests"

# Run with coverage
dotnet test --collect:"XPlat Code Coverage"

Reqnroll BDD

# Run BDD tests (they use the underlying test framework)
dotnet test --filter "Category=BDD"

# Generate living documentation
dotnet reqnroll livingdoc test-assembly MyGame.Tests.dll -t TestExecution.json

Code Review Checklist

  • Project builds without errors (dotnet build)
  • Project runs in Godot editor without errors
  • All unit tests pass
  • All BDD scenarios pass
  • Code follows TDD (tests written first)
  • Nullable reference types handled properly
  • Signals properly connected and disconnected
  • Node references cached where appropriate
  • No hardcoded magic numbers (use constants/exports)
  • Components are reusable and single-responsibility
  • SOLID principles followed
  • No emojis in code, comments, or documentation
  • Async operations properly awaited
  • Resources used for data-driven design

Quick Reference

Pattern Use When
State Machine Complex behavior with distinct states
Component Reusable behavior across actors
Observer (Events) Decoupled communication
Command Undo/redo, input buffering, replays
Object Pool Frequently spawned/despawned objects
Do Don't
Use nullable reference types Ignore null warnings
Cache node references Call GetNode() every frame
Use signals for communication Direct method calls across scenes
Use Resources for data Hardcode game data in scripts
Write tests first (TDD) Write tests after (or never)
Use [Export] for editor values Hardcode values
Properly disconnect signals Leave signal connections

Additional Resources

Reference Documentation (in this skill)

  • references/testing-patterns.md - GdUnit4, xUnit, Reqnroll patterns
  • references/csharp-patterns.md - C# design patterns and SOLID principles
  • references/godot-csharp-api.md - Godot C# API reference and differences

External Resources

Install via CLI
npx skills add https://github.com/curtbushko/nixos-config --skill godot-csharp
Repository Details
star Stars 4
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator