Dekiro Dev Blog

Part 22 of 28

Bots That Play for Balance

A thousand lines of GDScript that play the real game headless: bots with the gear, skills and attributes a player of their level would have, fighting every zone, Elite, Shadow and boss, and stepping out of every telegraph. What they found, what changed, and how a human's first playtest moved the numbers again.

Would you want to do a balancing exercise first before I playtest?

— the developer

Why a simulation, and why bots #

By day six the game had 177 monster types, 20 dungeons with their bosses, four schools of six skills each, and gear from level 1 to 90. Every number had been picked when its feature was built, by whoever built it. Nobody had ever checked whether a level 44 Blade in level 44 gear kills a level 44 boss in one minute or in one second.

The balancing pass had two halves, run by two agents in parallel. One was a Python model of a whole career: how many hours to level 90, where the gold goes, when gear arrives.1 The balancing post (15) covers that model, the 40-hour target and the economy fixes in depth. This post stays with the combat side. This post is about the other half, the one that answers a narrower question with much more fidelity: what actually happens in a fight?

A spreadsheet can multiply attack by skill coefficients. It can't tell you that a boss's slam is undodgeable when three of its adds are body-blocking you, that a mirror boss copying your skills one-shots your whole party, or that a warded goblin king makes players hit an invulnerable target for four minutes. Those come from the game's own code: its AI, its telegraphs, its dungeon scripts. So the simulation runs the game itself.

The rule that made it cheap #

tests/sim_combat.gd is a SceneTree script of about 1,000 lines. It builds the real World, real Actors, real dungeon instances with their beams and wards and phases, and runs them headless with --fixed-fps 30, as fast as the CPU allows: about 70 times real time.

The bots needed almost no new game code because of an architecture rule from day one: controllers drive actors only through command_* methods. The player's controller, the monster AI, the companions' AI and network input all go through the same small interface (command_move, command_attack, command_use_skill, command_use_potion, command_sprint). A bot is just one more controller. It can't cheat, because there's nothing to cheat with: it moves at the speed the actor moves, swings at the speed the combo allows, and casts only what's off cooldown and affordable.

The one change to the game was a hook. Telegraphs (the red circles and strips that warn of a boss's slam or an archer's shot) are drawn by SkillFx, which had no reason to tell anyone they exist. Now it has a static Callable, SkillFx.warning_watch, which is empty in the game and set by the sim. Every warning the player could see, the bots see too: a circle or a line, where, and when it goes off.

What the bots read: the Sunken Pharaoh's sun lance (the red strip) about to fire along the floor, and a lit light beam across it. The bots get the same warning a player sees, and step out after 0.3 seconds

A bot that plays like a careful player #

Each bot is built for one school and one level, the way a reasonable player would have it:

  • Gear: for every slot, the best normal (not rare) item up to its level, Common rarity, upgraded +0 to +6 by level (one step per ten levels). School weapon from level 5.
  • Attributes: all points spent by a school's weights, e.g. Blade 50% STR, 30% VIT, 20% DEX; Sanctum 60% INT. Capped at 90 per attribute like the game.
  • Skills: one point in each skill, then the four damage skills in turn to Master; from level 47 one more Mastery rank every seven levels, as if books dropped.
  • Potions: below 50% HP it drinks the potion tier the game drops at its level; mana below 30%.

In a fight, the bot thinks four times a second. It picks a target (a Shadow stone or a boss is its focus, because area skills catch the adds around it anyway), casts the first skill that would hit (heals below 55% HP, buffs when a fight is close), and otherwise walks into range and swings its combo.

And it dodges. Every physics frame it asks the Danger tracker which warnings cover its position and go off within 2.5 seconds, but only those it has seen for at least 0.3 seconds: a human's reaction time. If there's one, it searches rings of 16 points at 1.5 to 13 m around itself for the closest spot that is safe, on the floor, and not in a lit beam, and sprints there. What still hits it is, by construction, what a careful player couldn't avoid either.

A few lines decide which target to hit, and one of them is an admission:

# a warded boss (Gorrak's shamans: a big "defense" buff) can wait: its guards first, like a player would
if _valid(focus) and _hittable(focus as Actor) and not ((focus as Actor).buff_total("defense") >= 0.5 and best != null):

The first runs had the bots beating on Gorrak the Goblin King for 150 to 230 seconds while his shamans kept his ward up. Nothing was wrong with the data; the bots simply didn't know to kill the shamans first. They had to be told, which raised the real question for the playtest: will players see it? He does shout "Shamans! Shield your king!" when the ward goes up; whether that's enough is on the playtest list.

Three configurations, four suites #

The sim runs any of four suites, each over any subset of schools and configurations:

Suite What fights Configurations
zones every hunting zone at its middle level: each pack group, one Elite of each monster type, the zone's Shadow solo, per school
bosses every boss, mini-boss and rare boss at its level, in its own room of a fresh dungeon instance solo per school, Blade + 2 companions, a party of the four schools
rooms every dungeon's trash rooms solo Blade, party of four
curve a reference swing at every level (the yardstick for the HP targets below)

The zone suite takes about a minute, the boss suite about twelve. A dead bot comes back after 15 seconds (respawn plus the walk back), and the death is counted.

The brief asked for "solo with three companions"; the game caps a party at two, so the sim used two and said so. That kind of small, explicit correction turned out to be a pattern in this pass.

Every fight produces a row: time to kill, damage taken as a share of max HP, potions, deaths, the biggest single hit, and what dealt the damage. Damage is matched to the telegraph that went off ("crypt_lord: slam"), or labelled melee or "environment" (beams, traps, lava). tools/balance/sim_report.py turns the JSON into the markdown tables of docs/BALANCE_COMBAT.md: per band, per school, per zone, per boss, and the top damage sources for every boss.

What it found #

The headline was that the game was too easy in a way no single fight shows. Player power grows about 45 times from level 1 to 90 (attack, weapon times upgrade, STR, skill ranks times Mastery), monster HP only about 20 times, and the last map's monsters still had early-game stat lines. Packs died to the opening skill burst in about a second. Article 15 has that story; here's the shape of it in the sim's numbers.

Medians over the four schools by level band, before (orange) and after (blue) the combat pass: seconds to kill a pack from aggro, seconds to break a Shadow stone solo, and basic hits per monster. The green bands are the brief's targets

Some findings only a fight could produce:

  • Your Shadow (a level 85 boss that copies your skills and swing speed) had a damage multiplier of 0.6 of yours. Against a party of four that meant four copies of each school's burst at once: it one-shot the party, and the party killed it in 0.4 seconds. It now has 600 times your HP and 0.07 of your damage.
  • Ranged telegraphs too short to dodge in a crowd: some shots warned for 0.5 seconds, some bursts for 1.0 with a 3 m radius, and with wave monsters body-blocking, about half the bots' dodges failed. Shots now warn at least 0.65 s, bursts at least 1.1 s with a radius of at most 2.6 m, and a test (test_ranged_telegraphs_can_be_dodged) checks every ranged monster's data against those floors.
  • Iron Fist was 1.5 times slower than the other schools: weaker fist weapons, two of six skills defensive, no damage buff. Four skill multipliers more than doubled and Iron Skin gained a damage bonus. Packs are now 0.92 to 1.15 times the mean for every school up to level 60.
  • Companions had no gear. In boss fights they died every 20 to 60 seconds and got back up eight seconds later. A follow-up gave them their level's armor; companion deaths per boss dropped to 0 to 2, and in the final raid from 70 to 3.
  • Flat potions: with one 250 HP potion for everything past level 15, every Shadow from level 50 on killed a careful bot about once. That one went to the economy agent, who made four potion tiers.

The boss numbers were the most dramatic. Bosses were sized with a target in mind (about a minute for a party of four, about two minutes solo with two companions) and the sim measured the fit:

Party-of-four kill times for every dungeon boss, before and after, on a log scale. The final raid's target was four to six minutes. The Crypt Lord's "before" had no party run

The "what hurt" table was the other surprise. For almost every boss, 60 to 97% of the damage a solo bot took came from plain melee, and the telegraphed slams were 2 to 13%. That's what dodging bots should produce: the big hits are avoidable, the steady ones aren't. The biggest single hit on any bot after the pass was 5 to 15% of max HP (before: up to 82%, on unarmored companions). No unavoidable one-shots.

It also confirmed a design choice from the desert2 The desert post (21) explains the "less but stronger" wanderers.: on the empty dunes, an Elite King Scorpion takes a level 42 Blade 53 basic hits, against 14 for the same monster on the islands.

The targets that had to move #

The brief had numbers: a normal monster dies in 2 to 4 hits, a pack in 8 to 15 seconds, Shadows in 30 to 60 seconds solo. The sim showed that the first two can't both hold once skills scale. At level 40, a Blade's first second (Cleave, Whirlwind, Earthsplitter, Blade Storm) is worth about 14 basic hits on everything in range. If a monster dies in four basic hits, the pack is gone before the second skill lands.

So the agent kept the pack time and the time to kill a lone monster (about 2 to 3 seconds), and let basic hits per monster grow with level: 4 at the start, then 7, 9, 11. Monster HP was rebuilt from that rule, using the curve suite as the yardstick: HP equals the target number of hits times the average combo hit of the physical schools at that level. Damage became "5% of the player's max HP after armor per hit for the zone's typical monster", easing to 4% at level 90.

Two other targets turned out not to be measurable: "a dungeon run of 15 to 30 minutes" is mostly puzzles and walking, which bots can't do (they walk in straight lines, so rooms behind a maze, a rail line or deep water end "stuck"). The sim reports fighting time only (2 to 5 minutes with companions) and says the rest needs a stopwatch.

The sim as a measuring stick #

Once the combat numbers settled, the sim became the reference for the career model. The model had been estimating solo boss fights at 12 to 40 minutes; the sim said 3 to 4. A calibration factor (solo Blade kills take about 0.87 times the model's HP-over-DPS estimate) replaced the model's guess, dungeons dropped from 8.3 to 5.9 of the 40 hours, and the XP data was refit around the result. That's the order that worked: the fight first, measured; the career built on top.

Playtest 1: half the hours, and feeling powerful #

About a week later the developer played the game for the first time, at length, and came back with a long list.3 The first-playtest post (18) covers the rest of that list. Two items rewrote the balance:

EXP progression can feel a bit faster. Let's scale to 20 hours.

I don't feel so powerful, especially on the lower levels. Can you adjust the health slightly?

The first went to the career model: its fit mode searched for an XP curve exponent and quest XP that hit a target of half the hours at every level. The XP bar dropped from 80 x level^2.58 to 80 x level^2.37 (about 250,000 XP at level 30 instead of 520,000), quest XP followed the bar, and drop rates for the rare things (Fullmoon weapons, Sunstone, key fragments) doubled so that the shorter career still finds them. Out-of-combat regeneration (back to full in about 25 seconds) and stronger potions came from the same list.

Hours to reach each level after playtest 1: the median of 60 simulated characters (blue, with the spread) against the 20-hour target points (green). It lands at 20.7 hours

The second item was ambiguous, and the AI said so. "Health" could mean the player's or the monsters'. It took it to mean the monsters' (the combat pass had raised low-level monster HP 2.7 times, so you needed noticeably more hits than in the version the developer remembered), acted on that, and added: "If you meant your own character's health, tell me and I'll raise that too."

The change is one new key in monsters.json: a monster HP factor by level, applied on top of the normal scaling. Points [15, 0.65], [30, 0.85], [40, 1.0]: 35% less HP up to level 15, tapering to none from 40. It applies to packs, Elites and Shadows. The sim then reran the zones, before and after:

The new HP factor by level (left), and what the sim measured: basic hits for a Blade to kill a typical pack monster, before and after (right)
Level Pack kill time Shadow, solo
3 3.0 s -> 2.1 s 47 s -> 31 s
12 2.6 s -> 1.7 s 30 s -> 19 s
20 2.7 s -> 2.0 s
24 34 s -> 26 s
28 4.7 s -> 3.1 s

Two details show why the sim matters even for a one-line change:

  • Bosses are exempt ("hp_by_level_bosses": false). With the factor on bosses too, the sim had the Crypt Lord, the Leech Matriarch and Old Mother Gloam, the first three dungeon bosses, falling in 38 to 43 seconds with two companions. They'd have melted. Without it they stay around a minute.
  • XP doesn't move. Monster XP is tied to base HP ("XP follows HP" was a rule of the first pass), and the factor sits on top of it, so lighter monsters die faster but pay the same. That's exactly the "powerful" feeling: more kills per minute at the same reward, and the 20-hour timeline unchanged.

What bots can't tell you #

The sim's report ends with a list of things it can't know, and they're worth repeating:

  • It's a careful player. Perfect potion timing, 0.3 second reactions, no greed. Real players take more from telegraphs and less from standing in crowds. Nobody simulated kiting or pulling three packs at once.
  • It walks in straight lines. Mazes, minecart rails and deep water stop it; dungeon run times are fighting only.
  • It has no opinion. Whether 9 to 11 basic hits on a monster feels spongy while your skills are on cooldown, whether a two-minute boss with companions is fun, whether a 45-second Shadow is 45 seconds well spent: those went on a "needs a human" list, and the first item to come back from the human was exactly that kind of thing.

Takeaways #

  • Run the real code. A bot driving the same command_* interface as the player found the one-shots, the undodgeable shots and the warded king that no spreadsheet would.
  • Give the bots human limits. A reaction time and a "seen the warning" rule turn "damage taken" into "damage a careful player can't avoid", which is the number you actually want.
  • Attribute every point of damage. "60 to 97% plain melee" tells you more about a boss than its total.
  • Let targets fail in the open. "2 to 4 hits" and "8 to 15 s a pack" couldn't both hold; the sim said so and the brief changed.
  • Measure the fight, then model the career. The sim calibrated the career model, not the other way round.
  • A playtest beats both. One sentence ("I don't feel so powerful") changed the low levels more than a day of simulation, and the sim's job became checking that the fix didn't break the bosses.

What's next #

The bots dodge telegraphs on flat dungeon floors. Out in the world, some telegraphs belong to the weather itself, and the ground isn't flat. That's the next post.