Overriding the ruleset
Damage, experience, and vital-regen formulas aren't free functions
systems call directly — they're methods and constants on a
Ruleset object, injected once at server startup. Override as
much or as little of it as you want.
The interface
Ruleset lives in packages/shared/src/rules/ruleset.ts
and covers every formula/constant the engine's combat and progression
systems use:
| Member | Used for |
|---|---|
meleeAttack(str, weaponBonus?) | Base melee attack power |
physicalDefense(def, equipmentBonus?) | Damage reduction |
resolveDamage(attack, defense, spread, rng?) | Final damage roll |
damageSpreads | The 3 attacker/defender variance presets resolveDamage is called with |
attackCooldownMs, respawnHpFraction | Melee cooldown; HP fraction on respawn |
expForLevel(level), expReward(stat), applyExp(level, exp) | The leveling curve and NPC-kill rewards |
maxLevel, pointsPerLevel, levelUpVitalGain | Level cap and per-level gains |
vitalRegen(stat), clampVital(value, max), minRegen | Per-tick HP/MP/SP regeneration |
Overriding just what you need
createRuleset(overrides) starts from
defaultRuleset (the engine's historic numbers) and merges in
only the fields you supply. applyExp and
vitalRegen are composite — their default
implementations are re-derived from whatever expForLevel/
maxLevel/minRegen/etc. end up in the merged
result, so overriding just the leaf formula is enough; you don't also
have to reimplement the leveling loop or the regen calculation:
import { createRuleset } from '@ms6/shared';
export const myRuleset = createRuleset({
// A flatter, faster leveling curve than the historic level² × 100.
expForLevel: (level) => level * 80,
maxLevel: 60,
// Tougher combat: less variance, defense matters more.
resolveDamage: (attack, defense, _spread, rng = Math.random) =>
Math.max(0, attack - defense * 1.5 + Math.floor(rng() * 2)),
});
Supplying your own applyExp or vitalRegen
directly (rather than just their leaf dependencies) bypasses the
re-derivation entirely — your function is used as-is.
Wiring it in at server startup
createEngineOptions() (packages/server/src/engine/bootstrap.ts)
is the customization entry point — pass your ruleset through it as the
room's options:
// packages/server/src/index.ts
import { createEngineOptions } from './engine/bootstrap.js';
import { myRuleset } from './mygame/ruleset.js';
gameServer.define('game', GameRoom, createEngineOptions({ ruleset: myRuleset }));
Every system reaches it the same way: this.room.ruleset. If
you're writing a custom behavior handler (see
Behaviors & Rules) and need a
formula, use ctx.room.ruleset rather than importing
defaultRuleset directly — that way your handler respects
whatever ruleset the server was actually started with.
The client doesn't currently duplicate these formulas for prediction —
damage numbers and HP/MP/SP bars are server-authoritative and arrive
over the wire. If you add client-side prediction that depends on one of
these formulas, mirror the same Ruleset value there rather
than hand-rolling the math a second time, so the two can never drift
apart.
Regression safety
The pure functions defaultRuleset is composed from
(meleeAttack, resolveDamage,
expForLevel, applyExp, vitalRegen,
etc. in packages/shared/src/rules/{combat,progression,vitals}.ts)
are unchanged from the engine's original implementation and have their
own unit tests in the matching *.test.ts files — run
pnpm test after changing anything here.