Skills, Items and Loot as Data
Twenty-four skills, 203 items and a +9 upgrade track in the style of a popular Korean MMO, with almost none of it hard-coded.
Why data first #
A grind game lives on its numbers. How wide is Whirlwind? How often does a Shadow drop a skillbook?
What does a +6 attempt cost, and how often does it fail? Those numbers change all the time while you
balance, so we decided early that they live in data/*.json and not in code. CLAUDE.md says it bluntly:
- New skills go in
data/skills.jsononly. If a skill needs a new kind of effect, add a building block to SkillExecutor (and a test), never a one-off script per skill.- Content is data: new NPCs, shops, mobs, Shadows, zones and maps go in
data/, not code.
This post walks through three systems built that way: skills, items with random bonuses, and the blacksmith's upgrade track.
Skills are effect blocks #
There are four schools with six skills each. Every skill is a JSON entry made from a small set of
building blocks: a shape (arc, circle, line, ground or self) plus modifiers like hits,
knockback, pull, dash, heal and buffs. Here is the Blade school's Whirlwind:
{
"id": "whirlwind", "motion": "spin",
"fx": {"hit": [{"block": "spin"}, {"block": "ring", "scale": 1.05}, {"block": "burst", "kind": "sparks", "at": "caster", "count": 16}, {"block": "hitstop"}, {"block": "shake", "amount": 0.08}]},
"name": "Whirlwind", "mana": 15, "cooldown": 6,
"description": "Spin, striking everything around you. The dark comes from every side; so does the Blade.",
"effect": { "shape": "circle", "radius": 2.6, "damage_mult": 1.6 },
"mastery": { "radius": 3.2 },
"grandmaster": { "radius": 3.8, "knockback": 1.6 }
}
Two keys in that entry arrived later and are purely visual. motion names the body motion the player's
sprite plays (one of 13 shared motions such as spin, sweep, dash or slam, rendered per weapon type). fx is
a recipe made from a pool of 17 effect blocks (slash, ring, pillar, beam, afterimages, shake, hitstop and
so on). The hit itself is still only the effect block: what you see can change without touching what
gets hit.
The mastery and grandmaster blocks are overrides. A skill's parameters at a given rank are the base
effect with the overrides merged on top:
func params_for_rank(rank: int) -> Dictionary:
var p := effect.duplicate(true)
if rank >= SkillRules.MASTERY_RANK:
p.merge(mastery.duplicate(true), true)
if rank >= SkillRules.GRANDMASTER_RANK:
p.merge(grandmaster.duplicate(true), true)
return p
This makes tiers change what a skill does, not just its numbers. Cleave's arc widens from 160 to 210 degrees at Master and hits twice at Grandmaster. Charge dashes 6 m, then 8 m. The design doc asks for exactly that: "Each tier changes the effect itself, not only the numbers".
SkillExecutor turns the merged parameters into gameplay. It has one branch per shape, and every branch
asks the pure Combat queries what's inside the shape:
match shape:
"arc":
var arc_deg := float(p.get("arc_deg", 90.0))
targets = Combat.actors_in_arc(scope, here, dir, radius, arc_deg, caster.team, caster)
SkillFx.fan(caster, here, dir, radius, arc_deg, skill.color, tier)
"circle":
targets = Combat.actors_in_circle(scope, here, radius, caster.team, caster)
SkillFx.disc(caster, here, radius, skill.color, tier)
That's the second architecture rule at work: "Combat is shape-based, never tab-target." There is no target selection anywhere in the game. You aim at the ground, and everything in the shape gets hit. For a game about mowing down packs, that's the whole point.
Progression rules, also in one place #
The rules for raising skills sit in SkillRules as named constants, so they're easy to find and test:
- One skill point per level. The 15th point in a skill turns it into M1 (Mastery).
- M1 to M10 can only be raised with skillbooks, which drop from destroyed Shadows (40%) and never from ordinary mobs.
- A read succeeds 70% of the time at M1, falling to 35% at M10. Each failure in a row adds 10% to the next try (bad-luck protection), and you have to destroy a Shadow between reads.
- The 10th successful read makes the skill Grandmaster.
- Damage grows 7% per rank, heals and buffs 3%.
The points math is deliberate. Taking all six of a school's skills to M1 costs 90 points, so you have to specialize.
Items: base stats plus random bonuses #
data/items.json holds stat names, rarities, the random-bonus pool, rune rules, loot tables and the
items themselves. Rarity decides how many random bonuses a piece of gear rolls:
"rarities": [
{ "name": "Common", "color": "#d4d4d4", "min_bonuses": 0, "max_bonuses": 0 },
{ "name": "Magic", "color": "#5aa2ff", "min_bonuses": 1, "max_bonuses": 2 },
{ "name": "Rare", "color": "#f2d64b", "min_bonuses": 3, "max_bonuses": 3 },
{ "name": "Legendary", "color": "#ff8c2a", "min_bonuses": 4, "max_bonuses": 5 },
{ "name": "Unique", "color": "#ff3b5c", "min_bonuses": 5, "max_bonuses": 6 }
],
Bonuses come from a pool per slot (attack, HP, crit, attack speed, damage vs Shadows and more) and scale with item level. Unique is reserved for the four Fullmoon weapons, each school's sacred level 30 weapon, which is named after that MMO's Fullmoon Sword. They never come from normal rolls: a level 28+ Shadow drops one at 0.3% per weapon, a boss at 2%.1 They have their own post near the end of the series.
The item list itself has grown from 139 to 203 entries: 155 written in items.json, plus 24 skillbooks
and 24 runes that ItemDb generates from the skill list and the rune table at load. The biggest single
addition was a balancing pass that found a 56-level gap in armor2 Body armor stopped at level 30, the next set was the level 86 raid gear. and filled it with 41 pieces in six sets. Models and icons came from
the same Blender script as everything else, so filling a gap in the data was mostly a data change.
Loot that respects your level #
Players of the old MMO know the trick of farming low monsters at a high level. We cut it off with one small, testable function:
static func drop_factor(killer_level: int, victim_level: int) -> float:
var gap := killer_level - victim_level - DROP_FREE_GAP
return 1.0 if gap <= 0 else maxf(1.0 - DROP_LOSS_PER_LEVEL * gap, 0.0)
Up to 3 levels above a monster you get its full drops. Each level beyond that takes 25% off, so from 7 levels above nothing drops. In a party the gap counts from the highest-level member on the map, so a strong friend can't farm low monsters for the group.
Gold followed the same "one small formula" idea, but the first formula was wrong. Gold was originally
the monster's XP reward times a factor from its loot table. When the balance pass raised late-game XP a
lot, the model showed the median character finishing with about 16 million unspent gold. Gold now
follows the victim's level instead (1.2 x (level + 2), so 14 at level 10 and 110 at level 90),
times the table's factor.3 Ordinary monsters 0.6-1.4, Shadows 10-20, final bosses 100-200. Prices grow
linearly with level too, so the two stay in step.
Saves can't be trusted, so loading validates #
Characters live on the player's own PC and travel to any server they join. A server therefore has to
assume that a character file may have been edited. Every item goes through ItemInstance.from_dict on
load, which clamps and checks everything against the data:
if item_data.is_gear():
inst.rarity = clampi(int(d.get("rarity", 0)), 0, ItemDb.max_rarity_for(inst.data)) # Unique: Fullmoon only
inst.upgrade = clampi(int(d.get("upgrade", 0)), 0, Upgrades.MAX_LEVEL)
var allowed := ItemDb.rarity(inst.rarity).max_bonuses
Bonuses that aren't valid for that slot, values out of range and duplicate stats are all dropped. The
same goes for levels, attribute points and skill points in CharacterSave.sanitize: impossible spends are
refunded. A handful of tests feed deliberately broken characters through it.
The blacksmith: +0 to +9 #
The upgrade track is pure data in data/upgrades.json. Each step has a success chance, a gold factor
(multiplied by the item's value) and materials, which are used up whether you win or lose:
"levels": [
{ "chance": 1.0, "gold_factor": 0.3, "materials": [ { "item": "shadow_shard", "count": 1 } ] },
{ "chance": 0.95, "gold_factor": 0.4, "materials": [ { "item": "shadow_shard", "count": 2 } ] },
{ "chance": 0.9, "gold_factor": 0.5, "materials": [ { "item": "shadow_shard", "count": 3 } ] },
Each level adds 12% to the item's base stats. The chance falls from 100% to 20%, and from +5 a failure also drops the item a level. Shadow Shards drop from every Shadow, and from +6 the steps need rare Shadow Cores from level 10+ Shadows. The last three steps also take Sunstone (1, 2, then 3), glass fused by an ancient light that only the Sunscar Desert yields in quantity, plus an 18% drop from level 55+ Shadows anywhere. The top of the ladder sends you to the desert, which gives an otherwise empty map a reason to exist.
The Fullmoon weapons get their own track in the same file: +36% per level instead of 12%, so a +9 Fullmoon beats a +9 level 60 weapon. Every step also needs the school's Moon Seal, earned through a chain of nine seal quests unlocked from level 30 to 60. Here the data shows its value. A later design change ("a failed Fullmoon upgrade keeps the Moon Seal but drains it: destroy Shadows to recharge") was mostly one new key:
"recharge_shadows": [5, 6, 8, 10, 12, 15, 18, 22, 25],
From glow to sparks #
The first version followed a vague memory of the classic: a +7, +8 or +9 weapon glowed red, green or gold, with
a shine running up the blade. UpgradeGlow laid an additive shader overlay on the weapon's meshes. The
human corrected it the same day: "the +7, +8 and +9 upgrade shouldn't glow but have particular sparks
around the weapon, not too many. In [that game] you can see the sparkling." The rebuild draws a handful of
pixel-art sparkles along the blade in the step's colour, and the +9 shine travels up the edge. Inventory
icons got tiny matching stars instead of a coloured glow behind them. Every machine shows it, because the
weapon's look (weapon_look) is part of each player's replicated state.
Runes and other small systems #
The same pattern repeats across the rest of the item game:
- Runes: every monster has a kind (beast, goblinoid, cultist, undead, elemental, spirit, demon, shadow).
Weapons have sockets by rarity, and a rune adds 2.5% to 10% damage against one kind. The tiers, bind
chances (70 / 45 / 25%) and drop rates are all a few lines in
items.json. - Scroll of Renewal: drag it onto gear to reroll its random bonuses. There are no "add bonus" scrolls; the human decided against them.
- Boost potions, storage shared between your characters, trading with a two-step lock and accept, and daily bounties: each is data plus one validated server action.
Tested from the data #
Because content is data, a whole group of tests checks the data itself: every skill has a valid shape,
every item referenced by a loot table exists, every quest points at real NPCs and monsters, every upgrade
step names a real material. When someone (human or AI) edits a JSON file by hand, the suite says what is
broken before the game does. It doesn't make mistakes impossible. Once, a map's description text gained
unescaped quotes, the whole map file stopped parsing, and it took a fix commit three minutes later. This is
why test_map_data_is_consistent loads every map file, including the ones no other test uses, and
checks its zones, spawns and references.
Takeaways #
- Pick your building blocks and stick to them. Five shapes and a handful of modifiers carry 24 skills and their tiers. New effects become new blocks with tests, never one-off scripts.
- Data makes design changes cheap. "Make Rare and Legendary much scarcer" or "drain the seal instead of losing it" becomes a data edit plus a test, not a refactor.
- Validate on load. Portable characters are a lovely feature and a security hole. Clamp everything against the rules.
What's next #
All of these systems run on the server and work the same for one player or sixteen. The next post is about how that works: server-authoritative multiplayer in Godot.