Skip to content

Formulas

Most stat-based systems have values that aren’t entered directly — they’re computed from other values. D&D’s ability modifier is computed from the raw ability score. A spellcaster’s maximum spell slots per day might be computed from level and casting stat. A damage roll is computed from strength plus weapon die plus proficiency. These computed values are formulas in Ishvana’s Magic System module, and the formula engine is what makes the whole module feel alive instead of being a glorified spreadsheet.

A formula has a name, an output type, and an expression. The expression references stats and other formulas by their variable keys. When a stat block is evaluated, formulas run in dependency order — formula B that depends on formula A waits for A to compute first, then runs on A’s result.

A classic D&D ability modifier formula looks like this:

name: Strength modifier
key: str_mod
expression: floor((strength - 10) / 2)
output_type: integer

That formula references a stat called strength. When a character’s effective Strength is 15, the formula evaluates to floor((15 - 10) / 2) = 2. A character with Strength 20 gets modifier +5. A character with Strength 8 gets modifier -1.

Every formula has a declared output type that controls how its value gets displayed and used downstream:

  • Integer — A whole number. Ability modifiers, skill points, hit dice count.
  • Float — A decimal. Influence ratings, cultivation progress, percentage-based values.
  • Dice — A dice notation string like 2d6+3. Useful for formulas that compute what die a character rolls, not the result of the roll.
  • Boolean — True or false. “Can this character cast level 3 spells?” is a boolean formula that’s true when caster_level >= 5.

The output type matters because other formulas that consume this formula’s output need to know what shape of value to expect. A formula that multiplies the result of another formula by 2 will error if the source formula returns a dice notation string.

The expression engine supports full dice notation alongside math expressions. You can write:

NotationMeaning
2d6Roll two six-sided dice, sum them
1d20Roll one twenty-sided die
4dFRoll four Fate dice (each -1, 0, or +1)
1d20kh1Roll one d20, keep the highest (trivial here, more useful with multiple dice)
2d20kh1Advantage — roll two d20s, keep the highest
2d20kl1Disadvantage — roll two d20s, keep the lowest
3d6!Exploding — 3d6 where 6s reroll and add
1d6r1Reroll 1s once

Dice expressions can be combined with stat references and arithmetic. 1d20 + strength_mod + proficiency_bonus is a valid formula expression that adds a d20 roll to a stat-derived modifier and a resource-derived modifier.

The probability analyzer in the Magic System module reads these expressions and computes distributions — so you can write a formula for “attack roll” and then run a probability analysis on it without having to re-enter the dice notation somewhere else.

The expression engine whitelists a specific set of functions. You can’t call arbitrary code, and you can’t reference anything outside the ruleset. The allowed functions are:

  • floor(x) — Round down to the nearest integer.
  • ceil(x) — Round up.
  • round(x) — Round to the nearest integer (0.5 rounds up).
  • abs(x) — Absolute value.
  • min(a, b, …) — The smallest of the arguments.
  • max(a, b, …) — The largest of the arguments.
  • clamp(x, low, high) — Clamps x to the range [low, high].

These seven cover almost every formula you’d want to write. If you need something else — a sqrt, a log, a trigonometric function — the answer is almost always “your ruleset is doing too much math, simplify it.” The function whitelist is deliberately small because a Magic System that needs logarithms is probably a system that would be clearer if it didn’t.

A formula can reference another formula. That’s how multi-step derivations work — you compute an intermediate value and then use it in a later formula. The risk is that formula A might reference formula B, which references formula C, which references formula A. That’s a cycle, and the engine has no way to evaluate it.

Ishvana’s validator catches these cycles before they can cause problems. When you save a ruleset, the validator walks the formula dependency graph looking for loops. If it finds one, the save is rejected with a message naming every formula involved in the cycle. You have to break the cycle — either by eliminating one of the references or by introducing a third value — before the ruleset can save.

When a stat block is evaluated, the engine runs formulas in dependency order. Any formula that depends on nothing (references only base stats) runs first. Formulas that depend on those run next. And so on until every formula has a value. The result is a fully computed stat block where every derived value reflects the current base values and modifiers.

Evaluation happens every time a base value or modifier changes. Change a character’s Strength and every formula that depends on Strength updates live in the stat block panel. The update is instantaneous — you’re not waiting for a recompute — because the formulas are simple enough that even a large ruleset evaluates in milliseconds.

The formula engine is intentionally not Turing-complete. A formula can’t:

  • Loop. No while, no for. Every formula runs once per evaluation.
  • Branch. No if/else. You can use min, max, and clamp to get conditional-looking behavior, but there’s no true branching.
  • Call external code. The whitelist is the whitelist. No imports, no function definitions, no side effects.
  • Modify state. Formulas are pure — they read from the stat block and return a value. They don’t write back.

These restrictions exist because a formula is a description of a value, not a program. The moment formulas can have side effects or loops, the evaluation order stops being obvious, and debugging a stat block becomes harder than it needs to be. The small, boring feature set is deliberate.