Files
BMAD-METHOD/expansion-packs/bmad-godot-game-dev/data/development-guidelines.md
sjennings f20d572216 Godot Game Dev expansion pack for BMAD (#532)
* 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>
2025-09-06 13:49:21 -05:00

894 lines
22 KiB
Markdown

# 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: `GameStateManager` not `GSM`
**Functions and Methods:**
- Snake_case for functions: `calculate_damage()`, `process_input()`
- Descriptive verb phrases: `activate_shield()` not `shield()`
- 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:**
```gdscript
# 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
```csharp
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:**
```gdscript
# 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:**
```gdscript
# 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:**
```gdscript
# 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)
```gdscript
# 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:**
```gdscript
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:**
```gdscript
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:**
```gdscript
# 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#
```csharp
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:**
```gdscript
# 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
```gdscript
# 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
1. **Read Story Requirements:**
- Understand acceptance criteria
- Identify performance requirements (60+ FPS)
- Determine GDScript vs C# needs
2. **Write Tests FIRST (Red Phase):**
- Write failing unit tests in GUT/GoDotTest
- Define expected behavior
- Run tests to confirm they fail
3. **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#)
4. **Refactor (Refactor Phase):**
- Optimize for performance
- Clean up code structure
- Ensure 60+ FPS maintained
- Run profiler to validate
5. **Integration Testing:**
- Test scene interactions
- Validate performance targets
- Test on all platforms
6. **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
1. **Profile First**: Use Godot profiler to identify bottlenecks
2. **Optimize Algorithms**: Better algorithms beat micro-optimizations
3. **Reduce Draw Calls**: Batch rendering, use atlases
4. **Static Typing**: 10-20% performance gain in GDScript
5. **Language Choice**: Use C# for compute-heavy operations
## General Optimization
### Anti-Patterns
1. **Security Holes**
- Buffer overflows
- SQL injection vectors
- Unvalidated user input
- Timing attacks
- Memory disclosure
- Race conditions with security impact
2. **Platform Sabotage**
- Fighting Godot's scene system
- Reimplementing platform features
- Ignoring hardware capabilities
## GDScript Optimization
### Performance Destroyers
1. **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
2. **Allocation Disasters**
- Creating Arrays/Dictionaries in loops
- String concatenation with +
- Unnecessary Node instantiation
- Resource loading in game loop
- Signal connections without caching
3. **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
```gdscript
# 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
```gdscript
# 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
1. **ALWAYS use static typing** - Non-negotiable 10-20% free performance
2. **NEVER use get_node() in loops** - Cache everything in @onready
3. **NEVER load() in gameplay** - preload() or ResourceLoader
4. **NEVER create nodes without pooling** - Pool or die
5. **NEVER concatenate strings in loops** - PackedStringArray.join()
6. **ALWAYS use const for constants** - Compile-time optimization
7. **ALWAYS specify Array types** - Array[Type] not Array
8. **NEVER check conditions every frame** - Use signals and timers
9. **ALWAYS batch similar operations** - One update, not many
10. **NEVER trust the profiler isn't watching** - It always is
## Godot C# Optimization
### Anti-Patterns
1. **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
2. **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
3. **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
```csharp
// 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
```csharp
// 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
```csharp
// 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.