rosneri / writing
all posts
Games Aug 2025 · 7 min read

A LitRPG engine in TypeScript

Neri Rosner
Neri Rosner
backend-oriented full-stack engineer

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.

Same energy: strict types make a game engine as testable as a payments system. More writing
Keep reading Zero any, one year on 8 min I keep my reading list in git 4 min