A LitRPG engine in TypeScript
I read an embarrassing amount of LitRPG — the genre where the protagonist levels up with literal stat screens and progression math. So naturally I built a small engine for it: stats, curves, and generated encounters as plain data. It’s the side project I reach for when I should be sleeping, and it’s taught me more about modeling than some of my paid work.
The fun of LitRPG is that the numbers are the story. A good engine treats the numbers as data you can reason about, not logic buried in if-statements — which, conveniently, is exactly how I like to build everything else.
Stats and curves as data
The core insight: a character is a stat block, and progression is a pure function of level. Don’t scatter if (level > 10) checks through the code — describe the curve once, as data, and evaluate it.
type Stat = "str" | "dex" | "int" | "vit";
type StatBlock = Record<Stat, number>;
// progression is a curve, not a pile of conditionals
const curve = (base: number, growth: number) => (level: number) =>
Math.floor(base + growth * (level - 1) ** 1.15);
const hpFor = curve(40, 12); // smooth, tunable, inspectable
hpFor(1); // 40
hpFor(10); // ~190
Now balancing the game is editing numbers in one place, and I can graph a curve before a single encounter runs. The exponent is the dial that turns “linear and boring” into “exponential and menacing.”
Encounters as generated data
An encounter is a seed plus a recipe: pick a template, scale it to the party level, roll the variance. The output is plain data — a list of combatants with stat blocks — that the combat sim then consumes. Generation and simulation are separate, which means I can generate a thousand encounters and check the difficulty distribution without rendering anything.
function generateEncounter(level: number, rng: Rng): Combatant[] {
const count = 1 + rng.int(0, Math.min(4, level / 5));
return Array.from({ length: count }, () => scale(pickTemplate(rng), level, rng));
}
The part that makes it real: deterministic RNG
Randomness is the enemy of testing, so the RNG is injected and seedable. Same seed, same fight, every time. That one decision turns “I think combat feels fair” into “here’s a test that runs 10,000 seeded fights and asserts the win rate sits between 55% and 70%.”
const rng = makeRng("seed-1234"); // deterministic
const fights = Array.from({ length: 10_000 }, (_, i) =>
simulate(party, generateEncounter(20, makeRng(`fight-${i}`))),
);
const winRate = fights.filter((f) => f.partyWon).length / fights.length;
// assert(winRate > 0.55 && winRate < 0.7)
A balance regression now fails in CI like any other bug. That’s the whole reason it’s in TypeScript with strict types instead of a spreadsheet: the game’s “business logic” gets the same safety net as a real system.
Numbers going up is satisfying. Numbers going up and being testable is the part that keeps me building it.
Why bother
Partly because it’s pure fun — numbers go up, it’s very satisfying. But also because a game engine is a surprisingly honest modeling exercise: lots of state, lots of rules, immediate feedback when you get it wrong. Treat the stats as data, keep the RNG deterministic, and separate generation from simulation, and a “toy” turns into something you can actually reason about. Which is the only kind of project I know how to leave alone.
Filed under games. Build game systems for fun too? Trade dials with me.