M5.1 — The StatBlock Resource¶
What you'll learn
- Gather an actor's numbers into a custom
StatBlockResourceinstead of scattered variables. - Distinguish a Resource (shared data) from a Node (live state) —
StatBlockvs the M3.2Health. - Author and assign StatBlock
.tresfiles per actor, andduplicate()to diverge per instance.
How it applies
- Numbers are the game once combat works. From M5 on, "this enemy is dangerous" means a number, not a feeling. Putting those numbers in one typed structure makes them the thing designers tune, testers probe, and loot (M6) modifies — a single, named surface instead of a scatter of magic values.
- A typed Resource is a testable, serializable seam. A
StatBlockis inspector-editable, can be saved as a.tresasset, round-trips through the save system (M8), and is type-checked at use. The same move as typed signal parameters: a typed seam is a seam you can test and a value you can't fat-finger. - Boundary value analysis lives here. Stats have ranges and caps (armor can't exceed a sensible
max; crit chance is
[0,1]). Centralizing them is what lets a tester ask "what does the game do at the maximum armor value?" against one definition instead of hunting per-actor. - Shared base stats, per-instance live state. The player's base damage is design data shared by every player session; the player's current HP is live, per-run state. Splitting these into a Resource and a Node keeps each where it belongs and prevents accidentally serializing live state as if it were design data.
Concepts¶
Why a Resource, not loose variables¶
By M4, the enemy script could hold var speed, the Health holds max_health, an attack hitbox holds
damage. Spread like this, the actor's "character sheet" is smeared across several nodes, with no single
place to see or tune it, and no way to treat "a set of stats" as one value you can pass around, save, or
modify with loot.
A custom Resource fixes this. A Resource is a data object that can live as a file (.tres), be
edited in the Inspector, be shared between instances, and be serialized. A StatBlock Resource gathers
the actor's numbers into one named, typed structure: the character sheet as data.
Defining StatBlock¶
Example
A first StatBlock. @export makes each field inspector-editable; types and defaults document the
intended range:
# res://scripts/stat_block.gd
class_name StatBlock
extends Resource
@export var max_health: int = 10
@export var damage_min: int = 2
@export var damage_max: int = 4
@export var armor: int = 0
@export_range(0.0, 1.0) var crit_chance: float = 0.05
@export var crit_mult: float = 1.5
@export var move_speed: float = 150.0
class_name StatBlock registers the type so you can create .tres files of it and type variables as
StatBlock. @export_range(0.0, 1.0) constrains crit_chance in the Inspector to a valid
probability — the editor itself enforces the boundary, so a designer can't enter 1.5 and silently
break the crit roll. Damage is a range (min/max), not a single number, because ARPG hits roll
within a band (M5.2 uses it).
Resource vs Node, revisited¶
M3.2 made Health a Node and promised a contrast. Here it is, side by side:
StatBlock (Resource) |
Health (Node) |
|
|---|---|---|
| Nature | shared, inert data | live, per-instance state |
| Lives as | a .tres file or in memory |
a node in the scene tree |
| Edited | in the Inspector, authored once | changes constantly at runtime |
| Emits signals | no | yes (health_changed, died) |
| Saved (M8) | as design data / item rolls | its current value is run state |
The division: StatBlock says "this actor's max health is 30, base damage 5–8"; Health tracks "this
actor's current health is 17 right now." Health reads max_health from the StatBlock on setup; the
StatBlock never changes during play (loot in M6 produces new StatBlock values, it doesn't mutate the
base asset).
Authoring .tres files¶
Because StatBlock has a class_name, you can create instances as files: in the FileSystem dock,
Create New → Resource → StatBlock, save as res://resources/stats/player.tres, and edit its fields in
the Inspector. Assign that .tres to the actor via an exported StatBlock property. This makes a
skeleton's stats a file a designer opens and tweaks — no code, no scene surgery — and makes "an elite
variant" a copied .tres with bigger numbers.
Example
The actor holds a StatBlock and uses it to initialize. On the enemy (and similarly the player):
@export var stats: StatBlock
func _ready() -> void:
health.max_health = stats.max_health
speed = stats.move_speed
# the attack hitbox's damage is rolled from stats.damage_min..damage_max in M5.2
@export var stats: StatBlock shows a slot in the Inspector; drop skeleton.tres into it. The actor
reads its numbers from the assigned block, so two enemies differ entirely by which .tres they carry.
A note on duplication¶
A Resource assigned to many instances is shared by reference by default — all skeletons point at the
same skeleton.tres. That is correct for base stats (you want one definition). But when an instance
needs its own modifiable copy — a specific dropped item with rolled affixes (M6), or per-enemy
buffs — you call stats.duplicate() to get an independent copy. Sharing the base and duplicating when an
instance must diverge is the pattern; M6 leans on it heavily for item rolls.
Walkthrough¶
- Create
res://scripts/stat_block.gdwith theclass_name StatBlockdefinition above. - Author the player's stats: FileSystem dock →
Create New → Resource, chooseStatBlock, save asres://resources/stats/player.tres. In the Inspector setmax_health(e.g., 30),damage_min/max(e.g., 5/8),crit_chance(e.g., 0.1),move_speed(e.g., 150). - Author
res://resources/stats/skeleton.treswith smaller numbers (e.g.,max_health8, damage 2/3,move_speed90). - Add
@export var stats: StatBlocktoplayer.gdandenemy.gd, and the_readyinitialization that copiesmax_healthandmove_speedout ofstats. In each scene's Inspector, assign the matching.tresto thestatsslot. - Press
F5. Behavior is unchanged visually, but the player and skeleton now derive their numbers from their StatBlock assets. Openskeleton.tres, changemove_speed, relaunch — the skeleton chases faster, with no code edit.
Optional sanity check
Assign the same skeleton.tres to two skeletons, then at runtime (temporarily) do
stats.move_speed = 999 on one of them in code: both skeletons speed up, because they share the one
resource by reference. Now change it to stats = stats.duplicate(); stats.move_speed = 999 and
confirm only that one speeds up. This demonstrates the shared-by-reference rule and why duplicate()
exists — the exact behavior M6's per-item rolls depend on. Revert the experiment.
Self-check quiz¶
Q1 — Why gather an actor's numbers into a StatBlock Resource instead of leaving them as fields on the actor and its components?
A. Resources are faster to read than fields. B. It makes the character sheet one named, typed, inspector-editable, serializable value that designers tune, testers probe, and loot modifies — instead of magic numbers smeared across nodes. C. Godot forbids numeric fields on nodes. D. It reduces the node count.
Reveal answer
B. Centralizing stats gives a single surface for tuning, testing, saving, and modification. A is not a meaningful difference. C is false. D is irrelevant (a Resource isn't a node anyway).
Q2 — StatBlock is a Resource and Health is a Node. What principle assigns each its type?
A. Anything with numbers is a Resource; anything visual is a Node. B. Shared, inert, serializable design data → Resource; live, per-instance, signal-emitting state → Node. StatBlock is the character sheet; Health is the current HP. C. Resources can't be edited in the Inspector, so dynamic things must be Nodes. D. It is arbitrary.
Reveal answer
B. The split is by the nature of the data: base/design data that's shared and saved is a Resource; live, changing, event-emitting state is a Node. A is wrong (Health holds numbers too). C is backwards (Resources are very much Inspector-editable). D is wrong — it's a deliberate rule.
Q3 — Two skeletons are assigned the same skeleton.tres. You want one to take a temporary speed buff without affecting the other. What do you do?
A. Edit skeleton.tres on disk at runtime.
B. Call stats = stats.duplicate() on the buffed instance first, then modify its copy; shared
Resources are by-reference, so a duplicate is needed for per-instance divergence.
C. Nothing — instances already have independent Resources.
D. Make StatBlock a Node instead.
Reveal answer
B. Assigned Resources are shared by reference, so modifying the shared block changes every
instance; duplicate() yields an independent copy to modify safely. A would change the asset for
everyone (and isn't how runtime works). C is false — they share by default. D abandons the
Resource benefits to solve a problem duplicate() already solves.
Integration question¶
Q4 — open
Health (M3.2) reads max_health from a StatBlock, yet StatBlock never changes during play and
Health.current changes constantly. Explain how these two cooperate to represent an actor — what each
is the authority for — and why M6's loot will produce new StatBlock values via duplicate() rather
than mutating an actor's base .tres.
Reveal expected answer
They divide authority by permanence. StatBlock is the authority for the actor's design-time
maximums and base values — max_health, base damage range, armor, crit, move speed — shared data
that is the same every run and is authored once as a .tres. Health is the authority for live,
moment-to-moment state — the actor's current HP right now — which it initializes from the
StatBlock's max_health and then mutates through its single setter as the actor takes damage and
heals. The StatBlock answers "how tough is this kind of actor?"; the Health answers "how hurt is
this specific actor at this instant?" M6's loot must produce new StatBlock values via
duplicate() rather than editing a base .tres because the base asset is shared by reference
across every instance and across every run: mutating it would change every skeleton (or every
player session) at once and would corrupt the design data that should stay constant. A dropped
item's rolled stats are instance-specific divergence, which is exactly what duplicate() is
for — a private copy to modify — preserving the base as the immutable definition while letting each
rolled item carry its own numbers.