* Godot Game Dev expansion pack for BMAD * Workflow changes * Workflow changes * Fixing config.yaml, editing README.md to indicate correct workflow * Fixing references to config.yaml, adding missing QA review to game-dev agent * More game story creation fixes * More game story creation fixes * Adding built web agent file * - Adding ability for QA agent to have preloaded context files similar to Dev agent. - Fixing stray Unity references in game-architecture-tmpl.yaml --------- Co-authored-by: Brian <bmadcode@gmail.com>
22 KiB
Game Development Guidelines (Godot, GDScript & C#)
Overview
This document establishes coding standards, architectural patterns, and development practices for game development using Godot Engine with GDScript and C#. These guidelines ensure consistency, performance (60+ FPS target), maintainability, and enforce Test-Driven Development (TDD) across all game development stories.
Performance Philosophy
Following John Carmack's principles:
- "Measure, don't guess" - Profile everything with Godot's built-in profiler
- "Focus on what matters: framerate and responsiveness" - 60+ FPS is the minimum, not the target
- "The best code is no code" - Simplicity beats cleverness
- "Think about cache misses, not instruction counts" - Memory access patterns matter most
GDScript Standards
Naming Conventions
Classes and Scripts:
- PascalCase for class names:
PlayerController,GameData,InventorySystem - Snake_case for file names:
player_controller.gd,game_data.gd - Descriptive names that indicate purpose:
GameStateManagernotGSM
Functions and Methods:
- Snake_case for functions:
calculate_damage(),process_input() - Descriptive verb phrases:
activate_shield()notshield() - Private methods prefix with underscore:
_update_health()
Variables and Properties:
- Snake_case for variables:
player_health,movement_speed - Constants in UPPER_SNAKE_CASE:
MAX_HEALTH,GRAVITY_FORCE - Export variables with clear names:
@export var jump_height: float = 5.0 - Boolean variables with is/has/can prefix:
is_alive,has_key,can_jump - Signal names in snake_case:
health_changed,level_completed
Static Typing (MANDATORY for Performance)
Always use static typing for 10-20% performance gain:
# GOOD - Static typing
extends CharacterBody2D
@export var max_health: int = 100
@export var movement_speed: float = 300.0
var current_health: int
var velocity_multiplier: float = 1.0
func take_damage(amount: int) -> void:
current_health -= amount
if current_health <= 0:
_die()
func _die() -> void:
queue_free()
# BAD - Dynamic typing (avoid)
var health = 100 # No type specified
func take_damage(amount): # No parameter or return type
health -= amount
C# Standards (for Performance-Critical Systems)
When to Use C# vs GDScript
Use C# for:
- Complex algorithms (pathfinding, procedural generation)
- Heavy mathematical computations
- Performance-critical systems identified by profiler
- External .NET library integration
- Large-scale data processing
Use GDScript for:
- Rapid prototyping and iteration
- UI and menu systems
- Simple game logic
- Editor tools and scene management
- Quick gameplay tweaks
C# Naming Conventions
using Godot;
public partial class PlayerController : CharacterBody2D
{
// Public fields (use sparingly, prefer properties)
[Export] public float MoveSpeed = 300.0f;
// Private fields with underscore prefix
private int _currentHealth;
private float _jumpVelocity;
// Properties with PascalCase
public int MaxHealth { get; set; } = 100;
// Methods with PascalCase
public void TakeDamage(int amount)
{
_currentHealth -= amount;
if (_currentHealth <= 0)
{
Die();
}
}
private void Die()
{
QueueFree();
}
}
Godot Architecture Patterns
Node-Based Architecture
Scene Composition Over Inheritance:
# Player.tscn structure:
# Player (CharacterBody2D)
# ├── Sprite2D
# ├── CollisionShape2D
# ├── PlayerHealth (Node)
# ├── PlayerMovement (Node)
# └── PlayerInput (Node)
# PlayerHealth.gd - Single responsibility component
extends Node
class_name PlayerHealth
signal health_changed(new_health: int)
signal died
@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 = max(0, current_health - amount)
health_changed.emit(current_health)
if current_health == 0:
died.emit()
Signal-Based Communication
Decouple Systems with Signals:
# GameManager.gd - Singleton/Autoload
extends Node
signal game_started
signal game_over
signal level_completed
var score: int = 0
var current_level: int = 1
func start_game() -> void:
score = 0
current_level = 1
game_started.emit()
get_tree().change_scene_to_file("res://scenes/levels/level_1.tscn")
# Player.gd - Connects to signals
extends CharacterBody2D
func _ready() -> void:
GameManager.game_over.connect(_on_game_over)
func _on_game_over() -> void:
set_physics_process(false) # Stop player movement
$AnimationPlayer.play("death")
Resource-Based Data Management
Use Custom Resources for Game Data:
# WeaponData.gd - Custom Resource
extends Resource
class_name WeaponData
@export var weapon_name: String = "Sword"
@export var damage: int = 10
@export var attack_speed: float = 1.0
@export var sprite: Texture2D
# Weapon.gd - Uses the resource
extends Node2D
class_name Weapon
@export var weapon_data: WeaponData
func _ready() -> void:
if weapon_data:
$Sprite2D.texture = weapon_data.sprite
func attack() -> int:
return weapon_data.damage if weapon_data else 0
Performance Optimization
Object Pooling (MANDATORY for Spawned Objects)
# ObjectPool.gd - Generic pooling system
extends Node
class_name ObjectPool
@export var pool_scene: PackedScene
@export var initial_size: int = 20
var _pool: Array[Node] = []
func _ready() -> void:
for i in initial_size:
var instance := pool_scene.instantiate()
instance.set_process(false)
instance.set_physics_process(false)
instance.visible = false
add_child(instance)
_pool.append(instance)
func get_object() -> Node:
for obj in _pool:
if not obj.visible:
obj.visible = true
obj.set_process(true)
obj.set_physics_process(true)
return obj
# Expand pool if needed
var new_obj := pool_scene.instantiate()
add_child(new_obj)
_pool.append(new_obj)
return new_obj
func return_object(obj: Node) -> void:
obj.set_process(false)
obj.set_physics_process(false)
obj.visible = false
obj.position = Vector2.ZERO
Process Optimization
Use Appropriate Process Methods:
extends Node2D
# For physics calculations (fixed timestep)
func _physics_process(delta: float) -> void:
# Movement, collision detection
pass
# For visual updates and input
func _process(delta: float) -> void:
# Animations, UI updates
pass
# Use timers or signals instead of checking every frame
func _ready() -> void:
var timer := Timer.new()
timer.wait_time = 1.0
timer.timeout.connect(_check_condition)
add_child(timer)
timer.start()
func _check_condition() -> void:
# Check something once per second instead of 60 times
pass
Memory Management
Prevent Memory Leaks:
extends Node
var _connections: Array[Callable] = []
func _ready() -> void:
# Store connections for cleanup
var callable := GameManager.score_changed.connect(_on_score_changed)
_connections.append(callable)
func _exit_tree() -> void:
# Clean up connections
for connection in _connections:
if connection.is_valid():
connection.disconnect()
_connections.clear()
# Use queue_free() not free() for nodes
func remove_enemy(enemy: Node) -> void:
enemy.queue_free() # Safe deletion
Test-Driven Development (MANDATORY)
GUT (Godot Unit Test) for GDScript
Write Tests FIRST:
# test/unit/test_player_health.gd
extends GutTest
var player_health: PlayerHealth
func before_each() -> void:
player_health = PlayerHealth.new()
player_health.max_health = 100
func test_take_damage_reduces_health() -> void:
# Arrange
player_health.current_health = 100
# Act
player_health.take_damage(30)
# Assert
assert_eq(player_health.current_health, 70, "Health should be reduced by damage amount")
func test_health_cannot_go_negative() -> void:
# Arrange
player_health.current_health = 10
# Act
player_health.take_damage(20)
# Assert
assert_eq(player_health.current_health, 0, "Health should not go below 0")
func test_died_signal_emitted_at_zero_health() -> void:
# Arrange
player_health.current_health = 10
watch_signals(player_health)
# Act
player_health.take_damage(10)
# Assert
assert_signal_emitted(player_health, "died")
GoDotTest for C#
using Godot;
using GoDotTest;
[TestClass]
public class PlayerControllerTests : TestClass
{
private PlayerController _player;
[TestInitialize]
public void Setup()
{
_player = new PlayerController();
_player.MaxHealth = 100;
}
[Test]
public void TakeDamage_ReducesHealth()
{
// Arrange
_player.CurrentHealth = 100;
// Act
_player.TakeDamage(30);
// Assert
AssertThat(_player.CurrentHealth).IsEqualTo(70);
}
[Test]
public void TakeDamage_EmitsDiedSignal_WhenHealthReachesZero()
{
// Arrange
_player.CurrentHealth = 10;
var signalEmitted = false;
_player.Died += () => signalEmitted = true;
// Act
_player.TakeDamage(10);
// Assert
AssertThat(signalEmitted).IsTrue();
}
}
Input Handling
Godot Input System
Input Map Configuration:
# Configure in Project Settings -> Input Map
# Actions: "move_left", "move_right", "jump", "attack"
extends CharacterBody2D
@export var speed: float = 300.0
@export var jump_velocity: float = -400.0
func _physics_process(delta: float) -> void:
# Add gravity
if not is_on_floor():
velocity.y += ProjectSettings.get_setting("physics/2d/default_gravity") * delta
# Handle jump
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = jump_velocity
# Handle movement
var direction := Input.get_axis("move_left", "move_right")
velocity.x = direction * speed
move_and_slide()
# For responsive input (use _unhandled_input for UI priority)
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("attack"):
_perform_attack()
Scene Management
Scene Loading and Transitions
# SceneManager.gd - Autoload singleton
extends Node
var current_scene: Node = null
func _ready() -> void:
var root := get_tree().root
current_scene = root.get_child(root.get_child_count() - 1)
func change_scene(path: String) -> void:
call_deferred("_deferred_change_scene", path)
func _deferred_change_scene(path: String) -> void:
# Free current scene
current_scene.queue_free()
# Load new scene
var new_scene := ResourceLoader.load(path) as PackedScene
current_scene = new_scene.instantiate()
get_tree().root.add_child(current_scene)
get_tree().current_scene = current_scene
# With loading screen
func change_scene_with_loading(path: String) -> void:
# Show loading screen
var loading_screen := preload("res://scenes/ui/loading_screen.tscn").instantiate()
get_tree().root.add_child(loading_screen)
# Load in background
ResourceLoader.load_threaded_request(path)
# Wait for completion
while ResourceLoader.load_threaded_get_status(path) != ResourceLoader.THREAD_LOAD_LOADED:
await get_tree().process_frame
# Switch scenes
loading_screen.queue_free()
change_scene(path)
Project Structure
res://
├── scenes/
│ ├── main/
│ │ ├── main_menu.tscn
│ │ └── game.tscn
│ ├── levels/
│ │ ├── level_1.tscn
│ │ └── level_2.tscn
│ ├── player/
│ │ └── player.tscn
│ └── ui/
│ ├── hud.tscn
│ └── pause_menu.tscn
├── scripts/
│ ├── player/
│ │ ├── player_controller.gd
│ │ └── player_health.gd
│ ├── enemies/
│ │ └── enemy_base.gd
│ ├── systems/
│ │ ├── game_manager.gd
│ │ └── scene_manager.gd
│ └── ui/
│ └── hud_controller.gd
├── resources/
│ ├── weapons/
│ │ └── sword_data.tres
│ └── enemies/
│ └── slime_data.tres
├── assets/
│ ├── sprites/
│ ├── audio/
│ └── fonts/
├── tests/
│ ├── unit/
│ │ └── test_player_health.gd
│ └── integration/
│ └── test_level_loading.gd
└── project.godot
Development Workflow
TDD Story Implementation Process
-
Read Story Requirements:
- Understand acceptance criteria
- Identify performance requirements (60+ FPS)
- Determine GDScript vs C# needs
-
Write Tests FIRST (Red Phase):
- Write failing unit tests in GUT/GoDotTest
- Define expected behavior
- Run tests to confirm they fail
-
Implement Feature (Green Phase):
- Write minimal code to pass tests
- Follow Godot patterns and conventions
- Use static typing in GDScript
- Choose appropriate language (GDScript/C#)
-
Refactor (Refactor Phase):
- Optimize for performance
- Clean up code structure
- Ensure 60+ FPS maintained
- Run profiler to validate
-
Integration Testing:
- Test scene interactions
- Validate performance targets
- Test on all platforms
-
Update Documentation:
- Mark story checkboxes complete
- Document performance metrics
- Update File List
Performance Checklist
- Stable 60+ FPS achieved
- Static typing used in all GDScript
- Object pooling for spawned entities
- No memory leaks detected
- Draw calls optimized
- Appropriate process methods used
- Signals properly connected/disconnected
- Tests written FIRST (TDD)
- 80%+ test coverage
Performance Targets
Frame Rate Requirements
- Desktop: 60+ FPS minimum (144 FPS for high-refresh)
- Mobile: 60 FPS on mid-range devices
- Web: 60 FPS with appropriate export settings
- Frame Time: <16.67ms consistently
Memory Management
- Scene Memory: Keep under platform limits
- Texture Memory: Optimize imports, use compression
- Object Pooling: Required for bullets, particles, enemies
- Reference Cleanup: Prevent memory leaks
Optimization Priorities
- Profile First: Use Godot profiler to identify bottlenecks
- Optimize Algorithms: Better algorithms beat micro-optimizations
- Reduce Draw Calls: Batch rendering, use atlases
- Static Typing: 10-20% performance gain in GDScript
- Language Choice: Use C# for compute-heavy operations
General Optimization
Anti-Patterns
-
Security Holes
- Buffer overflows
- SQL injection vectors
- Unvalidated user input
- Timing attacks
- Memory disclosure
- Race conditions with security impact
-
Platform Sabotage
- Fighting Godot's scene system
- Reimplementing platform features
- Ignoring hardware capabilities
GDScript Optimization
Performance Destroyers
-
Type System Crimes
- Dynamic typing anywhere (10-20% performance loss)
- Variant usage in hot paths
- Dictionary/Array without typed variants
- Missing return type hints
- Untyped function parameters
-
Allocation Disasters
- Creating Arrays/Dictionaries in loops
- String concatenation with +
- Unnecessary Node instantiation
- Resource loading in game loop
- Signal connections without caching
-
Process Method Abuse
- _process() when _physics_process() suffices
- Frame-by-frame checks for rare events
- get_node() calls every frame
- Node path resolution in loops
- Unnecessary process enabling
GDScript Death Sentences
# CRIME: Dynamic typing
var health = 100 # Dies. var health: int = 100
# CRIME: String concatenation in loop
for i in range(1000):
text += str(i) # Dies. Use StringBuffer or Array.join()
# CRIME: get_node every frame
func _process(delta):
$UI/Score.text = str(score) # Dies. Cache the node reference
# CRIME: Creating objects in loop
for enemy in enemies:
var bullet = Bullet.new() # Dies. Object pool
# CRIME: Untyped arrays
var enemies = [] # Dies. var enemies: Array[Enemy] = []
# CRIME: Path finding every frame
func _process(delta):
find_node("Player") # Dies. Store reference in _ready()
# CRIME: Signal spam
for i in range(100):
emit_signal("updated", i) # Dies. Batch updates
# CRIME: Resource loading in game
func shoot():
var bullet_scene = load("res://bullet.tscn") # Dies. Preload
# CRIME: Checking rare conditions every frame
func _process(delta):
if player_died: # Dies. Use signals
game_over()
# CRIME: Node creation without pooling
func spawn_particle():
var p = Particle.new() # Dies. Pool everything spawned
add_child(p)
The Only Acceptable GDScript Patterns
# GOOD: Static typing everywhere
var health: int = 100
var speed: float = 300.0
var enemies: Array[Enemy] = []
# GOOD: Cached node references
@onready var score_label: Label = $UI/Score
@onready var health_bar: ProgressBar = $UI/HealthBar
# GOOD: Preloaded resources
const BULLET_SCENE: PackedScene = preload("res://bullet.tscn")
const EXPLOSION_SOUND: AudioStream = preload("res://explosion.ogg")
# GOOD: Object pooling
var bullet_pool: Array[Bullet] = []
func _ready() -> void:
for i in 50:
var bullet := BULLET_SCENE.instantiate() as Bullet
bullet.visible = false
bullet_pool.append(bullet)
# GOOD: Typed dictionaries
var player_stats: Dictionary = {
"health": 100,
"armor": 50,
"speed": 300.0
}
# GOOD: Efficient string building
func build_text(count: int) -> String:
var parts: PackedStringArray = []
for i in count:
parts.append(str(i))
return "".join(parts)
# GOOD: Timer-based checks
func _ready() -> void:
var timer := Timer.new()
timer.wait_time = 1.0
timer.timeout.connect(_check_rare_condition)
add_child(timer)
timer.start()
# GOOD: Batch operations
var updates_pending: Array[int] = []
func queue_update(value: int) -> void:
updates_pending.append(value)
if updates_pending.size() == 1:
call_deferred("_process_updates")
func _process_updates() -> void:
# Process all updates at once
for value in updates_pending:
# Do work
pass
updates_pending.clear()
# GOOD: Const for compile-time optimization
const MAX_ENEMIES: int = 100
const GRAVITY: float = 980.0
const DEBUG_MODE: bool = false
GDScript-Specific Optimization Rules
- ALWAYS use static typing - Non-negotiable 10-20% free performance
- NEVER use get_node() in loops - Cache everything in @onready
- NEVER load() in gameplay - preload() or ResourceLoader
- NEVER create nodes without pooling - Pool or die
- NEVER concatenate strings in loops - PackedStringArray.join()
- ALWAYS use const for constants - Compile-time optimization
- ALWAYS specify Array types - Array[Type] not Array
- NEVER check conditions every frame - Use signals and timers
- ALWAYS batch similar operations - One update, not many
- NEVER trust the profiler isn't watching - It always is
Godot C# Optimization
Anti-Patterns
-
Performance Destroyers
- ANY allocation in render/game loop
- String operations in hot paths
- LINQ anywhere (it allocates, period)
- Boxing/unboxing in performance code
- Virtual calls when direct calls possible
- Cache-hostile data layouts
- Synchronous I/O blocking computation
-
Algorithmic Incompetence
- O(n²) when O(n log n) exists
- O(n³) = fired
- Linear search in sorted data
- Recalculating invariants
- Branches in SIMD loops
- Random memory access patterns
-
Architectural Cancer
- Abstractions that don't eliminate code
- Single-implementation interfaces
- Factory factories
- 3+ levels of indirection
- Reflection in performance paths
- Manager classes (lazy design)
- Event systems for direct calls
- Not using SIMD where available
- Thread-unsafe code in parallel contexts
C#/GODOT SPECIFIC DEATH SENTENCES
Instant Rejection Patterns
// CRIME: LINQ in game code
units.Where(u => u.IsAlive).ToList() // Dies. Pre-filtered array.
// CRIME: String operations
$"Player {name} scored {score}" // Dies. StringBuilder or byte buffer.
// CRIME: Boxing
object value = 42; // Dies. Generic or specific type.
// CRIME: Foreach on List<T>
foreach(var item in list) // Dies. for(int i = 0; i < list.Count; i++)
// CRIME: Properties doing work
public int Count => CalculateCount(); // Dies. Cache or field.
// CRIME: Virtual by default
public virtual void Update() // Dies. Sealed unless NEEDED.
// CRIME: Events for direct calls
public event Action OnUpdate; // Dies. Direct method call.
// CRIME: Reflection
typeof(T).GetMethod("Update") // Dies. Direct call or delegates.
// CRIME: Async in game loop
await LoadDataAsync(); // Dies. Preload or synchronous.
// CRIME: GD.Print in production
GD.Print($"Debug: {value}"); // Dies. Conditional compilation.
Godot-Specific Crimes
// CRIME: GetNode every frame
GetNode<Label>("UI/Score") // Dies. Cache in _Ready().
// CRIME: Creating Nodes dynamically
var bullet = bulletScene.Instantiate(); // Dies. Object pool.
// CRIME: Signal connections in loops
unit.HealthChanged += OnHealthChanged; // Dies. Batch updates.
// CRIME: _Process without need
public override void _Process(double delta) // Dies. Use _PhysicsProcess or events.
// CRIME: Autoload abuse
GetNode<GameManager>("/root/GameManager") // Dies. Direct reference.
The Only Acceptable Patterns
// GOOD: Pre-allocated buffers
private readonly Unit[] _units = new Unit[MAX_UNITS];
private readonly int[] _indices = new int[MAX_UNITS];
// GOOD: Struct over class
public struct UnitData { public int Health; public Vector2I Position; }
// GOOD: Data-oriented design
public struct Units {
public int[] Health;
public Vector2I[] Positions;
public bool[] IsAlive;
}
// GOOD: Zero-allocation update
public void Update() {
int count = _activeCount;
for (int i = 0; i < count; i++) {
ref Unit unit = ref _units[i];
unit.Position += unit.Velocity;
}
}
// GOOD: Compile-time elimination
#if DEBUG
GD.Print("Debug info");
#endif
These guidelines ensure consistent, high-quality Godot game development that meets performance targets, maintains code quality, and follows TDD practices across all implementation stories.