Part 04 — When data has gone too far
You will build: the fences — the closed set of kinds (an enum, not a script), the version on the data (a save that knows its own shape), and the test that decides when "make it data" has stopped being a design tool and started being an architecture. You'll learn: where data-driven stops · the version field as a migration you do now · the save seam (ids, never paths) · and the honest answer to "should I make a plugin system?"
Why this exists#
Parts 01–03 made "add a thing" a file operation, and that's a real power — the power is exactly why this part exists. A tool that makes content easy to add makes it easy to add the wrong kind of content, and the failure mode isn't a bug, it's an architecture: a game whose balance is a JSON no one validates, whose "flexible" upgrades run scripts, whose save breaks the day a crop is renamed, and whose catalogue has forty kinds no design asked for. The fences are the part of data-driven that keeps it a design tool — and they're the part most likely to save you from your own stage 05 momentum.
Build it#
A — The fence: kinds are closed, instances are open#
The survivor's WeaponUpgrade has @export_enum("damage", "attack_speed") var upgrade_type
(stage 07). The enum is the fence, and the fence's job is to make the extensible point
visible:
- Instances are open. A new
damageupgrade (a different value, a different name) is a.tres— part 02's acceptance test, no code. The set of upgrade instances grows without limit. - Kinds are closed. A new kind of upgrade — one the enum doesn't list (
"knockback", stage 07's stretch) — is a new enum value and a newmatchbranch inapply_upgrade. That's code, by design, and it's the correct cost: a new kind is a new behaviour, and a behaviour is a code path that gets reviewed, tested, and given a failure mode. The fence makes that cost visible at the point of extension (you can't add"knockback"to a.treswithout the enum knowing it) instead of hidden at the point of runtime (a.treswith an upgrade_type thematchdoesn't handle, falling through to thepush_warning— the survivor's stage 07_branch, the fence's tripwire).
The tripwire is the design: the match's _ case warns on an unknown kind, so a .tres
that outgrew the fence (added by a designer who had the enum's old list) is named at the
moment it's applied, not silently ignored. A fence that fails open (the unknown kind does
nothing) is a fence that's been removed; a fence that fails loud (the warning) is a fence.
The moment the fence is reached for as a plugin system — "let's make upgrade_type a
Script reference so designers can add behaviours" — is the moment data-driven stops being a
design tool. A script-on-a-resource is a system: it has a loading order, a failure mode
(a script that errors at apply time), a version (a script from an old build), and a security
surface (arbitrary code in a content file). The event bus shelf
and the state machine shelf are where behaviours
live, with the review and the failure modes that behaviours need. The upgrade's values are
data; its kinds are code; a "upgrade that runs a script" is a behaviour wearing a
.tres's clothes, and it deserves the system's treatment, not the catalogue's.
B — The version: a migration you do now#
The 3D farm's stage 07 save holds {cell: {crop id, stage, days_grown}} with "a version
field from day one." The version is data-driven's relationship to time, and it's the fence
parts 01–03 don't have: a catalogue is consistent within a build, but a save is written by
build N and read by build N+k, and the k builds between them changed the data's shape.
The failure, concretely: you add a days_per_stage to CropData (build N+1), and a save
from build N (which predates the field) loads. Without a version, the save's crops have no
days_per_stage, and the game either crashes (the field is unset) or silently grows every
crop at the default tempo (the field is zero, and days_grown / 0 is the division-by-zero
part 03's stretch was warning about). With a version, the load checks it:
const SAVE_VERSION := 2
func load_save(data: Dictionary) -> void:
if data.get("version", 0) < SAVE_VERSION:
_migrate(data) # the k builds' shape changes, applied in order
# ...load the (now current-shape) data...
_migrate is code — the version is data, the migration is code, and the line between them
is the same line as part A's: the shape of the data is a design fact (versioned, known),
the transform between shapes is a behaviour (reviewed, tested). The 3D farm's stage 07
states the test: "what breaks when you add a crop to the catalogue after a save exists?
The version field is the answer; make the break first." Make the break first is the whole
method — a version you add after the first incompatible save is a migration you do at 2 a.m.
with a player's save in your hands; a version from day one is a migration you do in a commit.
C — The save seam: ids, never paths#
The 3D farm's stage 07 save holds the crop's id, never its .tres path, and the reason
is the same as part 02's id/display split, one tense later. A path is a location; an id is
a contract. Rename turnip.tres to turnip_v2.tres (or move it, or reorganize the
data/crops/ folder) and a save holding the path is orphaned — the save points at a file
that isn't there, and the load fails on a game whose content is fine. A save holding the
id survives the rename, because the id is the contract and the catalogue (part 03) resolves
it to whatever file currently carries it.
(Godot 4's UIDs make the file survive a rename — the .uid is stable, the path is not —
which is why a loaded resource is safe across a rename. But a save that serializes a path
or a UID is still coupling the save to the file system, and the id is the one thing that's
coupled only to the catalogue. The save's job is to record what the player had, and "what
the player had" is a set of ids and counts, not a set of file locations.)
The seam, stated: the save holds the data layer's shape (ids, counts, the version), and the
data layer resolves it (the catalogue, part 03) at load. A save that holds a resolved
reference (a path, a UID, a serialized Resource) has crossed from "the player's state" to
"the file system's state," and the file system is not a save's business.
D — The test: can a non-programmer break this?#
The overview called part 04 "the part most likely to save you from yourself," and the test is the saving. Take your catalogue, your definitions, your save — the data-driven surface a designer (or future-you, three months from now, not remembering the invariants) would touch — and ask: can they change it without breaking the game, and if they can break it, does the break announce itself?
The answers sort the surface into three:
- Safe, and the break is impossible. The
@export_rangefence (part 01), the typed array (a non-CropDatacan't be added to the roster), the enum (a non-listed kind can't be picked in the inspector). The type system is the fence; the designer cannot express the break. This is the goal state for the values. - Safe, and the break announces itself. The bad-id warning (part 03), the duplicate-id
warning, the
match's_tripwire (part A), the version check (part B). The designer can express the break (a typo'd id, a duplicate, an unknown kind, an old save), and the game names it at the moment it happens. This is the goal state for the kinds and the time. - Unsafe, and the break is silent. The wall-less
@export(a value with no range), the behaviour-on-idmatch(part 02's leak), the save holding a path (part C), the script-reference "upgrade" (part A's plugin system). The designer can express the break, and the game doesn't know. This is the state to find and fence — each one is a part 01–03 check you skipped, and the test is how you find the skips.
Run it on your game and write down the third column. An empty third column is the data-driven surface done; a non-empty one is a list of fences to build, in the order the designer is most likely to touch them.
E — The honest "should I make a tool?"#
The stretch that follows part 03 is a tool — a generator that turns a spreadsheet into
.tres files, a validator that runs part 03's stretch checks on demand, an exporter that
writes the save's version. The honest answer, the one the almanac's architecture doc gives
for its own interactive demos ("harvested, never authored"): build the tool when the
hand-process has hurt three times. A spreadsheet-to-.tres generator is a project with
its own failure modes (a stale spreadsheet, a column reorder, a UID that the generator
doesn't preserve) — and it earns those failure modes only when adding crops by hand has
actually been the bottleneck. Until then, the hand-process (a .tres, a catalogue entry,
part 02's acceptance test) is fast enough, and the tool is a second source of truth the
catalogue didn't need. The fence (part D's test) is the thing to build now; the tool is the
thing to build when the fence keeps catching the same mistake three times.
F — Commit#
git add . && git commit -m "data 04: the fences — closed kinds, versioned data, the save seam"
Checkpoint — definition of done#
- Your extensible point is fenced: the instances are open (a file), the kinds are
closed (an enum or equivalent), and the tripwire (the
_warning, the unknown-kind name) fires when you force a kind past the fence — met on purpose, like part 03's duplicate - The data has a version, the load checks it, and you've made the break first (an old-shape save against the new code) and watched the migration handle it — the 3D farm's stage 07 test, run
- The save holds ids and counts, never paths or UIDs — and you can say what a rename would have done to a path-holding save (orphaned) and why the id survives (the catalogue resolves it)
- The part-D test ran on your whole data-driven surface, and the third column (unsafe and silent) is either empty or a written list of fences to build, in touch-order
- You can answer "should I make a plugin system?" with the part-A distinction (values are
data, kinds are code, a script-on-a-resource is a behaviour wearing a
.tres's clothes) — and "should I make a tool?" with the part-E answer (three hurts, not zero) - Zero warnings; committed
If you get stuck#
- The "flexible" data breaks silently (part D's third column, and you can't find which
entry) → the break is a wall-less
@exportor a behaviour-on-idmatch. Grep for thematchon the id (part 02's leak) and for the@export varwithout a_range/_enum/ type fence; the silent break is the one without a tripwire, and the tripwire is the fix (apush_warningwhere the break lands). - The version migration "works" until a second shape change →
_migrateis written for one jump (N to N+1) and the save is two jumps old (N to N+2). Migrations apply in order (N→N+1, then N+1→N+2), keyed on the save's version, not the code's — a migration that assumes "the save is one version old" is a migration that breaks on the second-old save. - A renamed
.tresorphaned a loaded resource (not just a save) → the scene referenced it by path in a way the UID didn't cover (apreload("res://data/crops/turnip.tres")— the 3D farm'sCROP_SCENEis a scene preload, stable; a resource path in apreloadis the fragile one). The fix is the UID reference (Godot 4 resolves by UID across a rename) or the catalogue (part 03) — a resource resolved by id at load, not preloaded by path. - The fence caught the same mistake three times (part E's trigger) → that's the tool's green light. The mistake is now a process failure (the hand-step is error-prone), not a data failure, and the tool (the validator, the generator) is the fence moved up a level — built because the three hurts were real, not because the idea was elegant.