Lecture W6T1: Components and Data-Driven Design
▶ PRESS START
Lucas P. Cordova, Ph.D.
Willamette University | Tuesday, September 29, 2026
Lab 3 is due Thursday, October 1, 9:59 PM. The proposal is due Thursday, October 8, 9:59 PM. Thursday's class: git for teams, lightning pitches, and Lab 4 goes out.
2 minutes with a neighbor:
Where big scripts go to die
player.gd, week 12Every new feature edits the same file, every bug hides in the same file, and your teammate is editing it right now too. Game Programming Patterns calls this class the god object.
First instinct: make a class tree. One axis works fine, until an entity needs two.
THE TREE THAT COULD NOTEntity ├─ MovingEntity │ ├─ Player │ └─ Rival ├─ ShootingEntity │ └─ Turret └─ FlyingShootingMoving...Entity ?!
A tree forces every entity onto one axis. A flying shooter needs two branches at once, and each new combination demands a new class. The hierarchy explodes.
GPP: Component, the pattern engines are built from
A component isolates one domain (movement, health, audio, collision) in its own object. The entity stops being a giant class and becomes a container that composes parts.
Each part can be written alone, tested alone, and reused by any entity that needs that domain.
This is GPP: Component, this week's reading. It is also, quietly, the architecture of every engine you have heard of.
RIVAL-BRAINS, SEEN AGAINRival (CharacterBody2D) the brain ├─ Sprite2D the looks └─ CollisionShape2D the body
Every child node is a part. Drawing lives in Sprite2D, the physical outline lives in CollisionShape2D, and your script only ever wrote the brain.
In Godot, the scene tree is the component system. Adding a capability is adding a child node. The pattern is one sentence: a part is a child node.
health_component.gdextends Node class_name HealthComponent signal died signal changed(current: int) @export var max_health: int = 3 var current: int func _ready() -> void: current = max_health func take_hit(amount: int = 1) -> void: current = clampi(current - amount, 0, max_health) changed.emit(current) if current == 0: died.emit()
class_name registers the type engine-wide, so any scene can add one.died, changed); it does no killing and no drawing.PLAYER.TSCNPlayer (CharacterBody2D) ├─ Sprite2D ├─ CollisionShape2D └─ HealthComponent max_health = 3
RIVAL.TSCNRival (CharacterBody2D) ├─ Sprite2D ├─ CollisionShape2D └─ HealthComponent max_health = 1
Same part, added as a child in two scenes, tuned differently in the Inspector. The part never asks who owns it; it counts hits for anyone.
Reuse without inheritance: the flying shooter is just a container holding a Fly part, a Shoot part, and a HealthComponent.
# player.gd @onready var health: HealthComponent = $HealthComponent func _ready() -> void: health.died.connect(_on_died) func _on_spike_touched() -> void: health.take_hit() func _on_died() -> void: respawn()
health.take_hit().died fires, whoever cares connects.Same shape as the rival's frighten() doorbell: public entrances, announced exits.
Lab 4, out Thursday, hands you a kit of parts to build and combine. This slide is its grading rubric in disguise.
The Inspector is the designer's control panel
# rival.gd @export var patrol_speed: float = 140.0 @export var chase_speed: float = 220.0 @export var sight_range: float = 260.0 @export var give_up_range: float = 360.0
@export moves a decision from code to the Inspector.Data-driven design: behavior stays in code, values live in data that a person can change without touching the code. The @export was step one.
| Rival | Role | chase | sight | give up |
|---|---|---|---|---|
| Linfield Wildcat | CHASER | 240 | 320 | 420 |
| Puget Sound Logger | AMBUSHER | 200 | 220 | 300 |
| George Fox Bruin | FLANKER | 220 | 280 | 360 |
| Pacific Boxer | WILDCARD | 180 | 380 | 380 |
Four copies of rival.gd with different numbers? The copies drift: you fix a bug in three of them. Hand-tune four node instances across scenes? The numbers hide in four places. Both roads rot.
ResourceA Resource is a data object saved as its own file (.tres). Give it a script with @exports and you have a custom data type the Inspector can create, edit, and swap.
# rival_stats.gd extends Resource class_name RivalStats @export var patrol_speed: float = 140.0 @export var chase_speed: float = 220.0 @export var sight_range: float = 260.0 @export var give_up_range: float = 360.0 @export var flee_speed: float = 260.0
.tres files you can name, copy, and git-diff.# rival.gd @export var stats: RivalStats State.CHASE: velocity = to_player.normalized() * stats.chase_speed if to_player.length() > stats.give_up_range: _change_state(State.PATROL)
FOUR FILES, ZERO NEW CODErivals/ ├─ chaser.tres chase 240, sight 320 ├─ ambusher.tres chase 200, sight 220 ├─ flanker.tres chase 220, sight 280 └─ wildcard.tres chase 180, sight 380
stats.chase_speed instead of its own copy..tres dropped into its stats slot.The beta gate (November 19) requires exactly this: playtest-informed balance living in data. Build it now and the requirement is free.
Your game, in parts and numbers
6 minutes, with your teammate, in your proposal doc or on paper:
What you know now
Lab 3: due Thursday, October 1, 9:59 PM, pushed to main, Your Submission complete. Proposal: due Thursday, October 8, 9:59 PM. Thursday: git for teams, 60-second lightning pitches, and Lab 4 goes out. Reading: GPP Component.
git checkout demo5 runs today's refactor: RivalStats plus four .tres temperaments to swap in the Inspector.