Suryaveer vs Krodh is our free superhero game suite: a 3D arena fighter, a 2.5D beat ’em up and a 3D open-world city game in one web app. All three end in a fight with Krodh, a green giant who charges, smashes the ground, throws boulders and gets angrier as you hurt him.
Boss fights are easy to get wrong in both directions. If the numbers are too generous, the boss melts in twenty seconds and nobody remembers him. If they are too harsh, players quit at the second phase. Worse, balance drifts: a damage tweak for one attack, a faster wind-up for another, and a fight that was fine last week is now impossible. Nobody notices, because nobody on the team replays the whole fight after every change.
So we gave that job to a bot. On every commit, Vitest plays the arena fight on four seeds and all three beat ’em up stages, start to finish, in well under a second. If a change makes the fight unwinnable, or trivially winnable, the build goes red. This article shows the three pieces that make that possible.
1. A rules engine with no rendering
A bot can only play the game fast if the game can run without a screen. Each of our games is split in two:
- a rules engine: plain TypeScript, no DOM, no Three.js, no Phaser;
- a renderer that only draws the engine’s state and turns key presses into engine input.
The arena engine’s whole public surface is a state object, an input object and one function:
export interface ArenaInput {
mx: number; // movement, -1..1
mz: number;
light: boolean; // punch (edge-triggered)
heavy: boolean; // kick
spin: boolean; // Surya Chakra spin
dodge: boolean;
stance: number; // 0 = keep, 1..4 = switch
}
export function createArena(seed: number): ArenaState;
export function stepArena(s: ArenaState, inp: ArenaInput): void;
stepArena advances exactly one tick of 1/60 s. It moves both fighters, runs Krodh’s state machine (idle, patrol, charge wind-up, charge, smash wind-up, boulder throw, leap, roar, stagger, clash, defeated), resolves hits and pushes events such as hit, shockwave or phase onto s.events. The renderer reads those events to play sounds, spawn sparks and shake the camera. The engine never knows any of that exists.
In the browser, a fixed-step loop calls the engine:
export function loop(tick: (dt: number) => void, render: (dt: number) => void): () => void {
let acc = 0;
let last = performance.now();
const frame = (now: number) => {
const dt = Math.min(0.25, (now - last) / 1000);
last = now;
acc += dt;
let n = 0;
while (acc >= 1 / 60 && n++ < 5) {
acc -= 1 / 60;
tick(1 / 60);
}
if (n >= 5) acc = 0;
render(dt);
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
// ...
}
Because the step is fixed, a 144 Hz monitor and a struggling phone run the same simulation. They only draw it at different rates. In a test there is no loop at all: we call stepArena 60 times per simulated second, as fast as the CPU allows.
2. Seeded randomness, stored in the state
Krodh has to feel unpredictable: which attack comes next, where a boulder lands. But a test that fails one run in five is worse than no test. So the engine never calls Math.random(). It draws from a tiny seeded generator (mulberry32) whose entire state is one number kept on the game state:
export function random(holder: { rng: number }): number {
const r = mulberry32(holder.rng);
holder.rng = r.state;
return r.value;
}
// Krodh picks his next attack from the distance and one roll of the dice.
function chooseAttack(s: ArenaState): void {
const b = s.boss;
const d = dist2d(b.pos, s.player.pos);
const r = random(s);
if (b.phase >= 2 && d > 9 && r < 0.3) b.state = 'leap';
else if (d < 6) b.state = 'smash-windup';
else if (d > 16 || (d > 10 && r < 0.4)) b.state = 'throw-windup';
else b.state = 'charge-windup';
b.t = 0;
}
Same seed plus same inputs gives the same fight, every time, on every machine. Different seeds give genuinely different fights, so a bot that wins on seeds 1 to 4 has beaten four different Krodhs. A test pins this down:
it('is deterministic for a seed', () => {
const a = createArena(9);
const b = createArena(9);
run(a, 30, bot);
run(b, 30, bot);
expect(a.boss.hp).toBe(b.boss.hp);
expect(a.player.pos).toEqual(b.player.pos);
});
3. A bot that plays like a careful beginner
The bot is not a clever AI. Its job is to stand in for a reasonable human who reads the boss’s tells and doesn’t do anything fancy. It is a pure function from state to input:
function bot(s: ArenaState): ArenaInput {
const p = s.player;
const b = s.boss;
const dx = b.pos.x - p.pos.x;
const dz = b.pos.z - p.pos.z;
const d = Math.hypot(dx, dz) || 1;
const f = Math.floor(s.time * 60);
if (s.clash) return press({ light: f % 5 === 0 }); // mash, like a player would
// Dodge through shockwaves and charges, back off from wind-ups.
const wave = s.shockwaves.some((w) => Math.abs(Math.hypot(p.pos.x - w.x, p.pos.z - w.z) - w.r) < 2.5);
const charging = b.state === 'charge' && d < 6;
if ((wave || charging) && p.dodgeCooldown <= 0) return press({ dodge: true, mx: dz / d, mz: -dx / d });
if (b.state === 'smash-windup' || s.shockwaves.length) return press({ mx: -dx / d, mz: -dz / d });
if (b.state === 'charge-windup' || b.state === 'charge') return press({ mx: dz / d, mz: -dx / d });
if (p.energy >= 99 && d > 4 && d < 14) return press({ spin: true, mx: dx / d, mz: dz / d });
if (d > 3.6) return press({ mx: dx / d, mz: dz / d, stance: 1 });
return press({ light: f % 12 === 0, heavy: f % 40 === 20 });
}
Notice what it reads: b.state === 'charge-windup', smash-windup and the expanding shockwave rings. Those are exactly the tells a human sees on screen as Krodh rearing back or the ground cracking. Because the bot uses only the information the player has, its results say something about the human experience.
Then the balance rules become ordinary assertions:
it('is winnable: a simple bot defeats Krodh', () => {
let wins = 0;
for (const seed of [1, 2, 3, 4]) {
const s = createArena(seed);
run(s, 300, bot); // five minutes of game time
if (s.phase === 'won') wins++;
}
expect(wins).toBeGreaterThanOrEqual(2);
});
it('Krodh beats an idle player', () => {
const s = createArena(5);
run(s, 200);
expect(s.phase).toBe('lost');
});
The first test sets the floor: a careful beginner wins at least half the time. The second sets the ceiling: you cannot win by doing nothing, so the boss really fights back. Together they describe the band we want, and they hold every time anyone touches a number.
What the bot actually caught
The first version of the fight felt fine in a few manual runs. The bot disagreed: it could not win reliably, and the fights that did end were over too quickly to show Krodh’s later phases. The cause was simple. A few big hits decided every fight, and there was no way to recover from a mistake. A human would have hit the same wall, just less consistently.
We changed the design, not just the numbers:
- Boss HP went up to 1,900 so the fight lasts long enough to have three distinct phases.
- Each hit got smaller: the smash does 10 + 2 per phase, the charge 16, a boulder 14, the leap 18.
- Health regenerates at 2 HP/s after 4 seconds without being hit, which rewards clean play instead of punishing every slip.
- A dodge dash (0.32 s of movement at 22 m/s, 1 s cooldown) gives players a skilful answer to the charge.
After that, the bot won two to three of four seeds, and the idle test still lost. That is the band we wanted.
The bot also found a real bug. The fist clash, where Suryaveer’s punch meets Krodh’s charge and the player mashes to win, never triggered. The charge hit the player a few frames before the punch reached its impact frame, so the clash check never saw the two meet. A focused test reproduced it in ten lines. The fix starts the clash on contact whenever the player is attacking and facing the charge.
The same trick in the beat ’em up
The 2.5D beat ’em up uses the same pattern with a different bot. It targets the nearest enemy, lines up on the depth axis, turns to face the enemy, punches in rhythm and fires the Power Blast when two or more enemies are close:
it('every stage is beatable by a simple brawler bot', () => {
for (const [i, stage] of STAGES.entries()) {
const s = createBrawl(stage, i, 7);
run(s, 400, bot);
expect(s.phase, `${stage.name}: score ${s.score}, lives ${s.lives}`).toBe('clear');
}
});
When someone adds a wave of mutants to the subway, the test answers the only question that matters before merge: can a reasonable player still get through the City Street, the Subway Tunnel and the Rooftop Helipad, Krodh included? The failure message reports the score and the lives left, so you can see how close a failing run came.
Takeaways
- Separate the rules from the pixels. A game you can step without a renderer is a game you can test, replay and simulate thousands of times.
- Use a fixed timestep and keep the RNG seed in the state. You get determinism for free, and a failing seed becomes a bug report you can reproduce.
- Write bots that read tells, not internals. If the bot can see only what a player sees, its win rate says something about players.
- Assert a band, not a point. "Wins at least two of four seeds" plus "an idle player loses" survives normal tuning, and catches the changes that really break the fight.
The full TypeScript source of Suryaveer vs Krodh, including all three engines, the 40 Vitest tests and the 7 Playwright browser tests, is a free download on the DEV3 marketplace. Suryaveer is an original hero, a fan tribute to classic Indian TV superheroes. For the 3D city with its lure-into-the-park mechanic and the code-drawn characters, read the case study, or play the demo.



