What makes Archmage designer-friendly: adding one spell, end to end
Config data changes constantly throughout game development: every new feature and every balance pass touches it, and game designers carry the majority of those edits. That makes the designer experience the true test of a config tool. Archmage meets that test in nine ways:
- Yours to define. You define the fields and enums yourself, and every field’s type is declared next to it.
- Natural. You fill in values the way you think of them, such as
2.5s. - Names, not IDs. You refer to a config entry by name and never look up an ID.
- Early errors. A mistake fails the data export, not the playtest.
- Any shape. Tree-shaped data stays a tree, in YAML-like files beside the spreadsheets.
- One copy. A single set of files serves different releases.
- No handoff. Programmers get the data and the code without a handoff document.
- Readable. The
--readableconfigs make sense on their own, to an AI agent as well as to you. - Edit together. Designers edit the same sheets at once — no file locks, no merge step.
Consider a new spell, Ember Lance, for a small game. It has five requirements:
- It has a cooldown of 2.5 seconds.
- It deals 60 to 75 damage.
- It is bound to the Emberstrike rune.
- It is the reward of a new quest.
- It ships in version
1.2.0.
The sections below start with the game’s existing config data and add Ember Lance step by step. The nine points above appear in the same order, one section each, and each of those sections opens with a Before note on how it usually went without Archmage. All exported data, error messages, and generated code in this article come from real Archmage runs.
Project layout
All of the game’s config data lives in these files:
configs/
├── quest.yaml
├── spell-rune.xlsx
└── spell.xlsx
enums/
└── game.enum.yamlThe existing spell data
Before Ember Lance is added, the game has two spells, stored in spell.xlsx:
| A | B | C | D | |
|---|---|---|---|---|
| 1 | Desc | Spell name | Damage | Bound rune |
| 2 | Name | name | damage | rune |
| 3 | Type | l10n | >>minmax|int | ref@spell-rune |
| 4 | Options | |||
| 5 | 101 | Fireball | 40 | 55 | emberstrike |
| 6 | 102 | Frost Nova | 30 | 38 | deep-freeze |
The first rows define the fields:
Descis the description for each field.Nameidentifies each field across both the exported data and the generated code.Typespecifies the data type.Optionsdefines filters and constraints (empty here).
Column A also stores the config IDs. Anyone opening the file can immediately see what each column represents and how to fill it in.
You define the fields and enums yourself
Before: Fields and enums were defined in code. A new field started with a request to a programmer, and you were blocked until someone updated the schema.
Before: Default values were generally unsupported, forcing every cell to be filled in. Even when supported, you were limited to the zero value, or non-zero defaults were scattered across documents or conversations.
Ember Lance needs a cooldown, but the spell table does not have a cooldown field yet. Add the field to the table as follows:
| A | B | C | D | E | |
|---|---|---|---|---|---|
| 1 | Desc | Spell name | Damage | Bound rune | Cooldown |
| 2 | Name | name | damage | rune | cooldown |
| 3 | Type | l10n | >>minmax|int | ref@spell-rune | duration=1s |
| 4 | Options | ||||
| 5 | 101 | Fireball | 40 | 55 | emberstrike | |
| 6 | 102 | Frost Nova | 30 | 38 | deep-freeze |
The cells added in this step are marked in blue.
The type sits right under the field name, so anyone who opens the sheet later sees what each column holds.
The =1s after the type specifies a default export value. Fireball and Frost Nova use the game’s standard cooldown of 1s, so their cells may stay blank. For a field without a default export value, a blank cell causes export to fail, ensuring a missing value cannot go unnoticed.
Values are entered the way you think of them
Before: A spell name required a localization key and a matching entry in a separate translation sheet. A damage range took two columns, one for the minimum and one for the maximum. A cooldown was entered as a raw integer in a verbally agreed unit, like seconds or milliseconds.
With the cooldown field in place, add Ember Lance. Its rune cell stays blank for now:
| A | B | C | D | E | |
|---|---|---|---|---|---|
| 1 | Desc | Spell name | Damage | Bound rune | Cooldown |
| 2 | Name | name | damage | rune | cooldown |
| 3 | Type | l10n | >>minmax|int | ref@spell-rune | duration=1s |
| 7 | 103 | Ember Lance | 60 | 75 | 2.5s |
Each value is entered in its most natural form:
Ember Lanceis anl10nvalue: a localizable string. You type it as plain text, andexportautomatically collects it together with other localizable strings for translation.60 | 75is a>>minmax|intvalue: the minimum and maximum damage in one cell. The>>prefix stands for compact.2.5sis adurationvalue. Everydurationvalue includes its unit, as in2.5s,300ms, or1m30s, so there is no doubt whether a number means seconds or milliseconds.
Config entries are referred to by name
Before: A reference was a config ID. To fill it in, you looked the ID up in another table, copied it, and pasted it into the cell. Later, to find out what an ID meant, you looked it up in that table again.
Before: If a rune needed to track which spells used it, you had to manually maintain a list of spells on the rune table — and a forgotten update quickly left the two tables out of sync.
Now fill in the rune of Ember Lance, by name:
| A | B | C | D | E | |
|---|---|---|---|---|---|
| 1 | Desc | Spell name | Damage | Bound rune | Cooldown |
| 2 | Name | name | damage | rune | cooldown |
| 3 | Type | l10n | >>minmax|int | ref@spell-rune | duration=1s |
| 7 | 103 | Ember Lance | 60 | 75 | emberstrike | 2.5s |
The rune field has the type ref@spell-rune: each of its cells refers to a rune in spell-rune.xlsx. A cell could hold the rune’s config ID, but emberstrike is easier to read. It is the anchor of rune 3:
| A | B | C | D | |
|---|---|---|---|---|
| 1 | Desc | Rune name | Element | Bound spells |
| 2 | Name | anchor | element | spells |
| 3 | Type | anchor | enum@Element | backref-n@spell |
| 4 | Options | |||
| 5 | 3 | emberstrike | Fire | |
| 6 | 5 | deep-freeze | Frost |
An anchor gives a config ID a readable name, so a ref cell can use the name instead of the number. The exported value is still the config ID, 3.
The element field in spell-rune.xlsx is an enum. The enum is defined in enums/game.enum.yaml rather than in code. You enter its items, such as Fire and Frost, by name as well:
Element:
None: 0
Fire: 1
Frost: 2
Nature: 3The spells field is a backref-n, and its cells stay blank. For each rune, export fills in this field with the spells that reference it through their rune field:
"3": {
"element": 1,
"spells": [101, 103]
}References are entered on the spell side, as emberstrike and deep-freeze, and the back-references on the rune side are kept in sync automatically.
Archmage offers over 50 data types like these, each designed to be easy to fill in, read, and maintain.
A typo fails the export, not the playtest
Before: A typo went into the runtime data unnoticed. It showed up in the playtest as a missing effect, and then someone had to trace it back to the cell.
If Ember Lance’s rune name is mistyped as emberstrik, export fails with the following message:
failed to parse "emberstrik"; type: ref@spell-rune | pos: D7 | file: configs/spell.xlsx
└── invalid ref value: the referenced config ID or anchor is missing or not exportedThe message includes the file, the cell, the value, and the reason, so you can fix the typo in the spreadsheet before it reaches the playtest.
Every data type in Archmage comes with built-in validation rules. Beyond that, options let you add more constraints: interval=[1,100] keeps a number within a range, maxChars limits the number of characters in a string, and unique rejects duplicates. Archmage provides a dozen validation options. In addition, an assert field can check any rule expressed as an Excel formula.
Tree-shaped data stays a tree
Before: Tree-shaped data was either flattened into spreadsheets or split across multiple tables.
Ember Lance is the reward of a new quest. Quest data is tree-shaped: each objective type has distinct fields — an enemy to kill, an item to collect, or an area to reach. Flattening it into a table would leave most columns empty. Instead, the quest is defined in quest.yaml. Archmage supports both spreadsheets and tree-structured data files. You can mix these formats and use whichever fits your data shape.
lance-trial:
title: Trial of the Lance
level: 5
repeatable: false
objectives:
- kind: kill
enemy: cinder-wisp
count: 20
rewards:
coins: 400
spell: 103
spell__meta__:
type: ref@spellThe spell table has no anchors, so the new quest refers to Ember Lance by its config ID, 103 — a typical bad example where raw numbers obscure meaning and force manual lookups.
Archmage infers most data types from the values. The meta node spell__meta__ declares spell as a reference to the spell table, so export validates it in the same way as the rune reference explained earlier.
One set of files serves every release
Before: Each release had its own copy of the tables, one for
1.1.0and one for1.2.0. The copies drifted apart as soon as someone fixed one and forgot the other.
Ember Lance belongs to version 1.2.0, while version 1.1.0 still needs to be exported from the same files. A condition field handles this:
| A | B | C | D | E | F | |
|---|---|---|---|---|---|---|
| 1 | Desc | Spell name | … | … | Cooldown | Available since |
| 2 | Name | name | … | … | cooldown | release |
| 3 | Type | l10n | … | … | duration=1s | condition=/ |
| 4 | Options | … | … | |||
| 5 | 101 | Fireball | … | … | ||
| 6 | 102 | Frost Nova | … | … | ||
| 7 | 103 | Ember Lance | … | … | 2.5s | v1.2.0 |
In an export for version 1.1.0 (--semver 1.1.0), the condition v1.2.0 in the table excludes Ember Lance. The other spells have a blank condition cell, and since condition=/ treats a blank cell as true, those entries are exported.
With this change alone, the 1.1.0 export fails:
<ref> could not find the config ID 103 in "spell" (missing or not exported) | node: $['lance-trial'].rewards.spell | file: configs/quest.yamlThe quest still rewards the spell that version 1.1.0 does not contain. Adding one line to quest.yaml resolves the error:
lance-trial:
__condition__: v1.2.0
title: Trial of the Lance
# ...The 1.1.0 export now excludes both the spell and the quest.
The condition type and the __condition__ node filter config entries and tree nodes without changing the underlying data structure. By contrast, options like tags alter the schema itself: a field marked tags=++anniversary joins the export only when --tags includes anniversary. Archmage provides more than ten filtering mechanisms like these.
Programmers get the data and the code with no handoff
Before: A new field reached the programmers as a handoff document, and they wrote code to match it. The data kept changing, and the code and the document fell behind.
All of the work so far took place in spreadsheets and a YAML file. Data export and code generation run as independent pipelines over the same config source. In the 1.2.0 export, archmage export outputs the following runtime data for Ember Lance to spell.json:
"103": {
"name": "spell|103|$.name",
"damage": { "min": 60, "max": 75 },
"rune": 3,
"cooldown": [1, 2500]
}Runtime data is shaped for the game to load, not for reading: the rune is a number, the cooldown is a pair of integers, and the name is a localization key.
archmage struct generates the matching C# types from the same files:
public partial class SpellCfg
{
[JsonProperty("id")] public SpellCfgId Id { get; set; }
/// <summary>Spell name</summary>
[JsonProperty("name")] public L10n Name { get; set; }
/// <summary>Damage</summary>
[JsonProperty("damage")] public MinMax<long> Damage { get; set; }
/// <summary>Bound rune</summary>
[JsonProperty("rune")] public XRef<SpellRuneCfgId, SpellRuneCfg> Rune { get; set; }
/// <summary>Cooldown</summary>
[JsonProperty("cooldown")] public Duration Cooldown { get; set; }
}The release field does not appear, because it only determines what is exported. Archmage generates the matching enums, such as Element, for 12+ programming languages, including C#.
The Archmage SDK loads the data into these types, populates a ConfigAtlas, and resolves cross-table references. The C# SDK works in Unity, Godot, and .NET.
var atlas = new ConfigAtlas();
Archmage.LoadAtlas("configs/atlas.json", "configs/", atlas);
ConfigAtlas.Instance = atlas;Programmers then read the spell in game code:
var spell = atlas.SpellTable[103];
ntf.Text = spell.Name.Text; // l10n → localized text
var dmg = spell.Damage.Sample(rng); // minmax → a random value in range
var elem = spell.Rune.Ref.Element; // ref → the rune's element, no lookup
var cd = spell.Cooldown.Seconds(); // duration → 2.5spell.Rune.Ref gives the programmer the rune’s config entry directly: no ID lookup, no manual wiring.
Programmers write neither the config types nor the code that parses the data. Whenever configs change, a quick re-export or code generation keeps everything in sync; there is no handoff document to prepare and no config code to update by hand.
An AI agent can read the exported data as it is
Before: The runtime data was full of numbers: a rune was
3and an element was1. To ask an AI agent about balance, you first had to explain what each number meant.
--readable puts the meaning back into values. With it, Ember Lance’s rune changes from the number 3 to emberstrike, its cooldown from a pair of integers to 2.5s, and its name from a localization key to Ember Lance:
"103": {
"id": 103,
"name": "Ember Lance",
"damage": { "min": 60, "max": 75 },
"rune": "emberstrike",
"cooldown": "2.5s"
}--schema hands over the blueprint: each exported file gets a .schema file beside it, listing every field once, with its path, its type, and its description:
$ map[int64]
$.* {}
$.*.id int64
$.*.name l10n # Spell name
$.*.damage minmax # Damage
$.*.damage.min int
$.*.damage.max int
$.*.rune [email protected] # Bound rune
$.*.cooldown duration # CooldownAn AI agent can consume these files directly to review balance, catch outliers, or iterate on mechanics — no spreadsheets to open, and no opaque numbers to decode.
Edit together
Before: Spreadsheet files were binary. Two designers editing the same file meant waiting behind a lock, or resolving the conflicts cell by cell afterwards.
Archmage works with Google Sheets. While one designer adds Ember Lance, another can tune Frost Nova in the same sheet, with no file locks and no merge step. Archmage then pulls the sheets into your repository as spreadsheet files, so a release can be pinned to a commit like any other file. Permissions inherit directly from your Google Drive workspace — no new accounts to manage, and no extra access controls to configure.
Five requirements, one spell
Ember Lance went from five requirements to a spell in the game without a handoff document. You added the cooldown field, entered 2.5s and “Ember Lance” the way you think of them, and picked the rune by its name. A typo was caught at export, not in the playtest. You kept the quest in the file that suits it, and one set of files served both releases. Programmers received the data and the code from the same files, an AI agent could read the result as it was, and the whole team could edit the sheets together.
The design decisions stay with the designers; the toolchain takes care of the rest.