Game data: sources and measurements
The research behind the game-data skill, the data runner (ci/godot/lib/Data.ps1) and the culture-parse
architecture rule, gathered on 2026-10-21. It draws on Godot's documentation and the 4.7.2 class reference, studio
devlogs and talks. Tests ran on Linux x86_64 (4 cores), .NET SDK 8.0.131 (runtime 8.0.31) and Godot 4.7.2 mono and
standard, with C# [GlobalClass] Resources saved, edited by hand and reloaded, and a project exported with
--export-release Linux. Marks: tested means run here, doc means stated in documentation or a published talk,
and unconfirmed means not verified.
Sources
| Source | What it gave |
|---|---|
| Godot: resources | "Upon exporting a game, Godot serializes resource files as binary files (*.res)" |
| Godot: exporting projects | The include filter "allows non-resource files such as .txt, .json and .csv to be exported" |
| Godot: troubleshooting physics | "Since resources are shared by default..." |
GodotSharp.xml (4.7.2), Resource.Duplicate |
A sub-resource "will only be duplicated if it's local" |
| Orbitect: spreadsheets and JSON, Balancing with spreadsheets (search summaries) | Sheet exported to JSON by script; derived columns stay in the sheet |
| CastleDB (search summary) | A "structured static database": a model per sheet, references between sheets, one line-based JSON file that merges |
| Scott Bilas, A Data-Driven Game Object System (GDC 2002, Dungeon Siege) | Objects assembled from data, ">7300 unique object types" |
| Hades data tables (Nexus, sgg-mod-format) | Data as Lua tables that mods patch by merging |
| Factorio FFF #55, FFF #47 | A checksum of the state every tick; desyncs are "typically just a value of one variable" |
| Asset Library: Edit Resources as Table 2, CSV Data Importer | Editor tools for tables (not tested) |
| A Sony online consultant, "Crash Course in Online Features for Programmers" (GDC; notes) | Balance config from a server: no code in it, the game playable with any local version, no rebalancing mid-mission, its version in saves |
Custom Resources (tested)
| What | Result |
|---|---|
Rename an [Export] property (Damage to BaseDamage) and load an old .tres |
No error or warning, also with --verbose; the C# default (1, was 12). Re-saving drops Damage for good. A removed property behaves the same |
Rename the class and file, moving the .cs.uid too, .tres with a uid= |
Loads as the new class; stale path and script_class are tolerated |
The same with a .tres without a uid= |
Parse Error: [ext_resource] referenced non-existent resource; GD.Load returns null |
ResourceSaver.Save from a running game |
Writes the ext_resource without a uid |
| Values equal to the default, an empty array | Not written to the file |
[Export(PropertyHint.Range, "0,100")] int Hp set to 999 by hand |
Loaded as 999 |
An enum property set to 7 (no such member); Speed = "fast" on a float |
Loaded as 7, and as 0, with no error |
A missing ExtResource id |
The whole load fails |
Two scene instances whose [Export] Weapon is axe.tres; a.Weapon.BaseDamage += 5 |
One object: b and a later GD.Load see the change |
ResourceLocalToScene = true |
A copy per PackedScene.Instantiate(); two GD.Load calls still share |
Duplicate(true) |
An external .tres sub-resource stays shared; fields that are not exported are dropped (7 became 0, a List<int> came back empty) |
Duplicate(false) |
Shares the Array<string>: adding to the copy changed the original |
DuplicateDeep(Resource.DeepDuplicateMode.All) (4.5+) |
Copies the external sub-resource too |
| Cost, 1000 runs each | Duplicate(true) of a 1000-string array and a 20-deep chain: 1.47 ms; Duplicate() of 4 fields: about 45 µs; new: about 5 µs |
Edit axe.tres on disk, then GD.Load |
The cached, stale object |
ResourceLoader.Load(path, null, CacheMode.Replace) |
Reloads into the same object; every holder sees the new value. CacheMode.Ignore returns a new object |
So: definitions are read-only at run time and per-instance state lives in a plain C# object built from them; hints
guard only the inspector; renames need every .tres edited in the same commit and a test that compares each file's keys
with the class's stored properties (GetPropertyList() entries with PropertyUsageFlags.Storage).
Data files in an export (tested, default import settings)
| File | In the project | In the exported game |
|---|---|---|
Any .csv |
Imported as a translation, even a data table (items.csv produced items.name.translation) |
The file is absent: FileAccess.FileExists false |
.csv with importer="keep" (Import As > Keep File) |
Read with FileAccess.GetCsvLine(), quoted commas handled |
Exported and readable |
.json |
A JSON resource, also readable with FileAccess |
Exported and readable, no include filter needed |
.txt, .yaml |
Readable | Absent unless the preset's include filter names them |
.tres |
Text | Binary behind w.tres.remap; DirAccess lists only the .remap; ResourceLoader.ListDirectory("res://data") lists w.tres and loading by path works |
A loader that scans res://data with DirAccess for *.tres works in the editor and finds nothing in the export.
JSON numbers and culture (tested)
- Godot's
Json.ParseString("{\"hp\": 10}")["hp"]is a float (10.0);9007199254740993came back as9007199254740992;{"a": 1,}parsed;{"a":1,"a":2}kept 2. - With
LANG=de_DE.UTF-8, C#float.Parse("0.5")returned 5 with no exception and1.5f.ToString()gave1,5; withfr_FRit threwFormatException. The same inside Godot 4.7.2 C#; Godot's own"0.5".ToFloat()gave 0.5. A CI runner inC.UTF-8never sees it. Theculture-parsearchitecture rule flagsfloat.Parseand its kin without a culture. System.Text.Jsonon .NET 8 withUnmappedMemberHandling = JsonUnmappedMemberHandling.Disallowrejected{"Id":"a","Prise":3}for arecord Item(string Id, int Price): "The JSON property 'Prise' could not be mapped to any .NET member contained in type 'Item'."
Spreadsheets and published practice (doc)
- Orbitect, before exporting balance JSON by script: "maybe spending around five minutes copying, pasting, zipping up and sending to one another each tweak". Derived columns (damage per second, time to kill) stay in the sheet; only inputs are exported.
- One direction only: sheet, then exported CSV or JSON committed to the repository, then the game.
- Dungeon Siege assembled objects from data; "data-driven" meant no engineer required. Hades keeps its data in Lua tables that mods merge into.
The data runner
data.json in a game names each table's file (JSON or CSV), its id column and its fields; the runner is selected
whenever the file exists and needs no Godot. Field rules: type (string, int, number, bool), min, max, enum,
pattern (both case-sensitive: Rare is not rare), ref (another table whose ids the value must name),
list (a JSON array, or ; between values in a CSV
cell), optional and acyclic (on a ref to the field's own table: every loop among the rows is reported as
a -> b -> a is a cycle: none of them can come first, at most five, long ones shortened; prerequisite chains, quest
order, recipes). Two more checks look across rows. "totals" on a table adds a field up per group and fails a group
short of a number or of another table's field: { "sum": "xp", "by": "zone", "atLeast": "zones.xpToNext" } reports
zone 'steppes': xp adds up to 900, short of xpToNext 1000 (90%) (Mists of Pandaria shipped zones about 10% short of the
next level); a target row without the field needs nothing. "names" at the top level (table, field, files as globs
where **/ may match no folder, allow) fails on capitalised words of four letters or more one or two letters from a
canonical name, with the file and line (a comic and the game spelled one character Belrysa and Belysra;
narrative). A required list field fails when it is empty, so a loot table cannot lose its last drop. A mistyped id, enum value, column or table is reported with the nearest name when one is close (one
edit up to six letters, two up to nine; a swap of two letters is one edit). Columns not listed fail unless the table
says "extra": "allow". A JSON table is an array of
objects or an object keyed by id. A CSV table without importer="keep" in its committed .import fails, since it would
not ship. Numbers in CSV cells are parsed with the invariant culture. One JUnit case per table;
tests/Test-Data.ps1 checks it against tests/data-fixture.
Dialogue tables ("kind": "responses")
A JSON table of "kind": "responses" holds the rules of Example.Core's ResponseSystem (Valve's dialogue rule
database, valve.md item 48), one row per rule; ResponseTable.Load in
examples/csharp/src/Example.Core/Narrative/ResponseTable.cs builds the rules from the same file, so what the runner
passed is what the game loads:
{ "tables": { "barks": { "file": "data/barks.json", "kind": "responses", "facts": ["lives", "combo"], "voiced": false } } }
| Column | Rule | Required |
|---|---|---|
id |
Rule.Name, unique |
yes |
who |
the speaker; absent means anyone | no |
concept |
what happened (LifeLost, Idle) |
yes |
criteria |
text in the grammar below: lives>0;!cheer_cooldown |
no |
lines |
text, or { "text", "voice" } with the voice file (res:// or relative to the project); one is picked at random |
yes, at least one |
then |
{ "who", "concept" }: the follow-up, looked up when the line ends; no who asks everyone in earshot |
no |
remember |
[{ "fact", "value", "world", "for" }]: value a number, true, false or a name (1 if absent), world for everyone, for seconds above 0 |
no |
once |
the rule removes itself after it plays | no |
The columns come predefined (a table's own fields add more, with "extra": "allow" as usual; ResponseTable.Load
takes their names as extraColumns). Besides the field rules the runner fails: a criteria syntax error, with the column
('coach_hurt' criteria column 8: '<' needs a number, not 'lots'); a then naming a concept no rule of any responses
table has (with the near name), or sent to a speaker with no rule for it; a voice file that does not exist; with
"voiced": true, a line without one; two rules with the same who, concept and criteria in any order (they tie, and one
is drawn at random each time: one rule with all the lines says the same); and criteria that no facts can satisfy together
(health<10;health>20, weapon=axe;weapon=bow, !seen;seen=1). A fact read in criteria that no rule remembers and the
table's facts (those the game supplies: health, the event's facts) does not list is a warning, fact-never-written,
with the near name (gag_cooldow for gag_cooldown). Translations sit on each line, { "text": "Next wall.", "locales": { "fr": "Mur suivant." } } or { "fr": { "text", "voice" } }; with "locales": ["fr", "de"] on the table
the runner fails a line without a locale's text ('intercom_bye' line 1 has no 'de' text), a locale the table does not
list (with the near name), a locale's voice file that does not exist and, with "voiced": true, a locale without one.
ResponseTable.Load(json, symbols, locale: "fr") reads a locale's lines and voices; the C# example's barks.json is in
English and French. tests/data-fixture has a broken table for each
check, examples/csharp/game/data/barks.json a green one that ResponseTableTests plays.
"type": "criteria" checks the same grammar in any table (quest conditions, unlock rules). The grammar, one for both
parsers (CriteriaSyntax in ResponseRules.cs, Read-ResponseCriteria in ci/godot/lib/Data.ps1):
criteria = blank | criterion { ";" criterion }
criterion = blank ( "!" name | name blank op blank value ) blank
op = "<=" | ">=" | "<" | ">" | "="
value = number | "true" | "false" | name after <, <=, >, >=: a number only
number = [ "-" ] digit { digit } [ "." digit { digit } ] ASCII digits; no exponent, NaN or Infinity
name = ( letter | "_" ) { letter | digit | "_" | "." | "-" }
blank = { " " | "\t" }
!key passes when the fact is missing or 0; true is 1 and false 0; a name is a symbol, equal only to itself; x<n is
the interval up to the double just below n. Errors name the 1-based column in the whole text. Both parsers run the 37
cases of examples/csharp/tests/Example.Core.Tests/criteria-examples.json (ResponseTableTests in C#,
tests/Test-Data.ps1 in PowerShell) and must give the same criteria and the same message for every error.
Soft-lock levels ("kind": "softlock")
Example.Core's SoftLockCheck (examples/csharp/src/Example.Core/Level/SoftLock.cs, game-playtest section 6)
searches every order of play for a state from which the goal can no longer be reached. A JSON table of
"kind": "softlock" writes levels down as data, so the data runner runs the same search (Invoke-SoftLockSearch in
ci/godot/lib/Data.ps1) for any game, GDScript included, and a C# game loads the file with SoftLockTable.Load
(examples/csharp/src/Example.Core/Level/SoftLockTable.cs):
{ "id": "vault", "areas": ["Hall", "Closet", "Exit"], "start": "Hall", "goals": ["Exit"],
"items": ["key", { "name": "box", "max": 1, "canBeLost": true, "start": 0 }],
"pickups": [{ "area": "Hall", "item": "key", "count": 1 }],
"supplies": [{ "area": "Hall", "item": "key", "upTo": 1, "name": "key rack" }],
"passages": [{ "from": "Hall", "to": "Closet", "name": "closet door", "oneWay": false, "needs": "box", "needsCount": 1,
"uses": "key", "useCount": 1 }],
"everyItemCanBeLost": false, "maxStates": 100000 }
Only areas and goals are required; start defaults to the first area, a count to 1, an item's max to 1. Rules are
added as pickups, then supplies, then passages, each in file order, and both searches try actions in that order, so a
failure is the same text in both: 'vault' Soft-lock after 2 step(s): pick up key in Hall; use key on closet door, go Hall -> Closet. Stuck in Closet, holding nothing: 2 of 5 reachable states cannot reach the goal. The runner also fails an
area or item a rule names that the level does not list (with the near name), a count outside 1 to the item's max, an
unknown key on an item or rule, and a level past maxStates. The ten levels of
examples/csharp/tests/Example.Core.Tests/softlock-examples.json run in both (SoftLockTests, tests/Test-Data.ps1)
and must give the same text, state counts included.
Currencies need a sink ("economy")
A table of money flows per hour, Example.Core's EconomySim rows (a currency, a rate, negative for a sink), names its
two fields: "economy": { "currency": "currency", "rate": "perHour" }. The runner fails a currency with a source and no
sink (currency 'gems' has no sink: daily_gems, quest_gems pay it in and nothing takes it out, so it only piles up),
as EconomySim.WithoutSink does in code; when the currency field is a ref, every currency of that table needs a sink
even with no flow at all. Simulating the balance over 10, 100 and 1,000 hours stays EconomySim's (progression).
Placed objects in levels
ci/godot/templates/placement_check.gd, copied into a game's tests, checks every node of the groups ai_point,
interactable, zipline, landmark and route in each level against limits read from gameplay code (game-data section 6).
tests/Test-Placement.ps1 proves each rule on tests/placement-fixture. Navigation answered under --headless after 9
to 11 physics frames for the first level of a GUT run; the checks took about 10 ms per 1,000 nodes. Source and the
talk's numbers: Assassin's Creed Origins: world design data. The
landmark and route groups check wayfinding (a landmark that moves or is seen from one stretch only, a route with no
landmark ahead on the way back): Stop Getting Lost: Make Cognitive Maps.
Not confirmed
- A
_Set(StringName, Variant)override mapping a renamed property; whetherCacheMode.Replaceemitschanged. - CastleDB's use in Dead Cells and Northgard; the 4.7 compatibility of CSV Data Importer; neither editor tool was tried.
- The Orbitect, Dungeon Siege, Hades and CastleDB details come from search summaries.