Contributing Guide
Step-by-step guides for adding content, fixing bugs, and modifying systems.
Quick Start
- Open the project in Godot 4 (4.4+ recommended)
- Run the greybox: Press F6 while
mayo_breach_greybox.tscnis open - Run tests: Open the GUT panel (bottom dock) and click "Run All"
- Read the architecture guide: Architecture
- Follow coding standards: Coding Standards
How to Add a New Enemy
1Create the enemy script
# scripts/levels/greybox_my_enemy.gd
extends GreyboxEnemyBase
func _get_enemy_id() -> String:
return "greybox.my_enemy"
func _get_sprite_path() -> String:
return "res://assets/sprites/enemies/my_enemy.png"
func _get_death_color() -> Color:
return Color(0.5, 0.8, 1.0, 1) # Light blue particles
func _get_drop_chance() -> float:
return 0.4 # 40% health drop chance
2Create the scene
- Right-click in
scenes/levels/→ New Scene - Root node:
CharacterBody2D - Add child:
Sprite2D(name itSprite2D— the base class looks for this) - Add child:
CollisionShape2Dwith aCircleShape2D - Attach script:
scripts/levels/greybox_my_enemy.gd - Save as
scenes/levels/greybox_my_enemy.tscn
3Add to the spawner
Open scenes/levels/mayo_breach_greybox.tscn, find the Spawner node, and add your scene to the exported PackedScene slot.
4Test
- Open
mayo_breach_greybox.tscn - Press F6 (run current scene)
- Verify your enemy appears, chases the player, takes damage, and dies
How to Add a New Level
1Create the scene
- Right-click in
scenes/levels/→ New Scene - Root node:
Node2D - Add child:
TileMap - Add child:
GreyboxPlayer(instance fromscenes/player/greybox_player.tscn) - Add child:
Spawner(instance or inline withgreybox_spawner.gd) - Add child:
CanvasLayerfor UI
2Write the level script
# scripts/levels/my_level.gd
extends Node2D
const TILE_SIZE := 32
var _room := [
[1, 1, 1, 1, 1],
[1, 2, 0, 3, 1],
[1, 0, 0, 0, 1],
[1, 3, 0, 4, 1],
[1, 1, 1, 1, 1],
]
@onready var _tilemap: TileMap = $TileMap
@onready var _player: CharacterBody2D = $GreyboxPlayer
func _ready() -> void:
_build_tileset()
_paint_room()
_place_player()
func _build_tileset() -> void:
# See mayo_breach_greybox.gd for full example
pass
func _paint_room() -> void:
for y in _room.size():
for x in _room[y].size():
_tilemap.set_cell(0, Vector2i(x, y), _room[y][x], Vector2i(0, 0))
func _place_player() -> void:
for y in _room.size():
for x in _room[y].size():
if _room[y][x] == 2:
_player.position = Vector2(x * TILE_SIZE + TILE_SIZE / 2, y * TILE_SIZE + TILE_SIZE / 2)
return
3Wire up the title screen
In scripts/levels/title_screen.gd, change the scene path:
get_tree().change_scene_to_file("res://scenes/levels/my_level.tscn")
How to Add a New UI Panel
1Create the scene
- Right-click in
scenes/ui/→ New Scene - Root should be
CanvasLayer(for overlays) orControl(for widgets) - Pick appropriate layer number (see UI Stack)
- Add your UI elements (Label, Button, Panel, etc.)
2Write the script
# scripts/ui/my_panel.gd
extends CanvasLayer
@onready var _label: Label = $Label
func _ready() -> void:
# Listen to game events, NOT direct node references
Events.wave_started.connect(_on_wave_started)
visible = false
func _on_wave_started(wave_index: int, _total: int) -> void:
_label.text = "Wave %d started!" % wave_index
visible = true
3Add to the main scene
Open scenes/main.tscn and instance your UI panel as a child of the root.
How the Signal Bus Works
All gameplay events flow through the Events autoload. This is how scripts talk to each other without knowing each other exists.
Emitting an event
# Anywhere in the codebase:
Events.player_damaged.emit(10, "enemy.swarmer")
Listening to an event
func _ready() -> void:
Events.player_damaged.connect(_on_player_damaged)
func _on_player_damaged(amount: float, source_id: String) -> void:
print("Player took ", amount, " damage from ", source_id)
Adding a new signal
- Open
scripts/autoload/events.gd - Add your signal:
signal my_event(param: Type) - Document who emits it and who listens
Testing
Running Tests
- Open the GUT panel in the Godot editor (bottom dock)
- Click "Run All"
- Or run from terminal:
godot --headless -s addons/gut/gut_cmdln.gd
Writing a New Test
# tests/unit/test_my_feature.gd
extends GutTest
func test_player_can_take_damage():
var player = preload("res://scenes/player/greybox_player.tscn").instantiate()
add_child_autofree(player)
var initial_hp = player.current_hp
player._on_player_damaged(10, "test")
assert_eq(player.current_hp, initial_hp - 10, "HP should decrease by damage amount")
Test Naming Convention
- File:
test_{system}_{behavior}.gd - Function:
test_{what_is_being_tested}()
Code Review Checklist
Before submitting changes:
- Game compiles with zero errors (
godot --headless --check-only) - All GUT tests pass
- Greybox plays identically (no regressions)
- New code follows Coding Standards
- Comments explain WHY, not WHAT
- No
get_nodes_in_group()in_processor_physics_process - No
load()in hot paths - Type hints on all functions and variables