Mirage Source 6
Extending / Custom Ruleset

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:

MemberUsed for
meleeAttack(str, weaponBonus?)Base melee attack power
physicalDefense(def, equipmentBonus?)Damage reduction
resolveDamage(attack, defense, spread, rng?)Final damage roll
damageSpreadsThe 3 attacker/defender variance presets resolveDamage is called with
attackCooldownMs, respawnHpFractionMelee cooldown; HP fraction on respawn
expForLevel(level), expReward(stat), applyExp(level, exp)The leveling curve and NPC-kill rewards
maxLevel, pointsPerLevel, levelUpVitalGainLevel cap and per-level gains
vitalRegen(stat), clampVital(value, max), minRegenPer-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.