Skip to content

M5.1 — The StatBlock Resource

What you'll learn

  • Gather an actor's numbers into a custom StatBlock Resource instead of scattered variables.
  • Distinguish a Resource (shared data) from a Node (live state) — StatBlock vs the M3.2 Health.
  • Author and assign StatBlock .tres files per actor, and duplicate() 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 StatBlock is inspector-editable, can be saved as a .tres asset, 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

  1. Create res://scripts/stat_block.gd with the class_name StatBlock definition above.
  2. Author the player's stats: FileSystem dock → Create New → Resource, choose StatBlock, save as res://resources/stats/player.tres. In the Inspector set max_health (e.g., 30), damage_min/max (e.g., 5/8), crit_chance (e.g., 0.1), move_speed (e.g., 150).
  3. Author res://resources/stats/skeleton.tres with smaller numbers (e.g., max_health 8, damage 2/3, move_speed 90).
  4. Add @export var stats: StatBlock to player.gd and enemy.gd, and the _ready initialization that copies max_health and move_speed out of stats. In each scene's Inspector, assign the matching .tres to the stats slot.
  5. Press F5. Behavior is unchanged visually, but the player and skeleton now derive their numbers from their StatBlock assets. Open skeleton.tres, change move_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 valuesmax_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.