Mirage Source 6
Extending / Behaviors & Rules

Adding a new item type, spell effect, NPC behavior, or tile trigger

Four gameplay dispatch points are registries, not switch statements: itemEffectRegistry, spellEffectRegistry, npcBehaviorRegistry, and tileTriggerRegistry. You register a handler for a new numeric id from your own game's code — you never edit the engine files that define the built-in ones.

Reserve your IDs above the built-in range. The engine's own enums (ItemType, SpellType, NpcBehavior, TileType in packages/shared/src/enumerations.ts) occupy low numbers. Registry keys are plain number, not the closed enum type, specifically so your custom ids (e.g. starting at 100 or 1000) never collide with a future built-in addition. See Enums, Packets & API for the current built-in ranges.

How you actually extend this engine

packages/server is a private, unpublished package — you don't npm install this engine into a separate project. The intended workflow is to fork the repo and add your own game-specific code inside packages/server/src/ (a new folder like src/mygame/ is a reasonable convention), importing the engine's registries and types with regular relative paths, exactly like any other file in the package. The code examples below assume that layout.

Where registration happens

Register your behaviors once, at server startup, after registerDefaultBehaviors() and before gameServer.listen() — the same place packages/server/src/index.ts calls it:

// packages/server/src/index.ts
import { registerDefaultBehaviors } from './engine/behaviors/defaults.js';
import { registerMyGameBehaviors } from './mygame/behaviors.js'; // your code

registerDefaultBehaviors();  // engine defaults first
registerMyGameBehaviors();   // then yours, on top

// ... gameServer.define('game', GameRoom, createEngineOptions());
await gameServer.listen(config.PORT);

1. A new item type

Say you want an item that fully heals every vital at once — a "Phoenix Down" — rather than reusing ConsumableHP. First add the enum value in your own code (or, if you're maintaining a fork, add it to ItemType in packages/shared/src/enumerations.ts):

export const MyItemType = {
  FullRestore: 100, // pick a number above the built-in ItemType range
} as const;

Then register a handler on itemEffectRegistry:

import { itemEffectRegistry } from '../engine/behaviors/itemEffects.js';
import { ServerPacket } from '@ms6/shared';
import { MyItemType } from './itemTypes.js';

itemEffectRegistry.register(MyItemType.FullRestore, (ctx) => {
  const d = ctx.session.data;
  d.vital.hp = d.maxVital.hp;
  d.vital.mp = d.maxVital.mp;
  d.vital.sp = d.maxVital.sp;
  ctx.client.send(ServerPacket.PlayerHp, { sessionId: ctx.client.sessionId, value: d.vital.hp, maxValue: d.maxVital.hp });
  ctx.client.send(ServerPacket.PlayerMp, { sessionId: ctx.client.sessionId, value: d.vital.mp, maxValue: d.maxVital.mp });
  ctx.client.send(ServerPacket.PlayerSp, { sessionId: ctx.client.sessionId, value: d.vital.sp, maxValue: d.maxVital.sp });

  // Consume one from the stack, same as the built-in consumables:
  const inv = d.inventory;
  inv[ctx.slot].quantity -= 1;
  if (inv[ctx.slot].quantity <= 0) inv[ctx.slot] = { itemId: 0, quantity: 0, durability: 0 };
  ctx.client.send(ServerPacket.PlayerInvUpdate, { slot: ctx.slot, item: inv[ctx.slot] });
});

The handler receives an ItemUseContext: client, session (the player using it), slot and item (the inventory slot index and item definition), invSlot (the live inventory slot object), room (for reaching other systems — room.getMapSystem(), etc.), and playerSystem (for helpers like saveCharacter).

Now create an item in the admin editor with type set to 100 — using it fires your handler.

2. A new spell effect

Same shape, on spellEffectRegistry — a context with spell, targetType/targetId, and combatSystem for damage helpers:

import { spellEffectRegistry } from '../engine/behaviors/spellEffects.js';

spellEffectRegistry.register(MySpellType.Poison, (ctx) => {
  if (ctx.targetType !== 'player') return;
  const target = [...ctx.room.getPlayers().values()]
    .find((p) => p.client.sessionId === ctx.targetId);
  if (!target) return;
  // spell.data1 = damage per tick, spell.data2 = number of ticks, say
  let ticks = ctx.spell.data2;
  const interval = setInterval(() => {
    target.data.vital.hp = Math.max(0, target.data.vital.hp - ctx.spell.data1);
    target.client.send('playerHp', { sessionId: target.client.sessionId, value: target.data.vital.hp, maxValue: target.data.maxVital.hp });
    if (--ticks <= 0) clearInterval(interval);
  }, 1000);
});

3. A new NPC behavior

NPC behaviors are the one registry where your handler usually wants to reuse the engine's own AI primitives rather than reimplement pathfinding. The context's npcSystem field exposes aiWander(), aiAttackOnSight(), and aiPursueAndAttack() as public methods for exactly this:

import { npcBehaviorRegistry } from '../engine/behaviors/npcBehaviors.js';

const MyNpcBehavior = { FleeWhenHurt: 100 } as const;

npcBehaviorRegistry.register(MyNpcBehavior.FleeWhenHurt, (ctx) => {
  const hpFraction = ctx.npc.vital.hp / (ctx.def.hp || 1);
  if (hpFraction < 0.3) {
    // Below 30% HP: just wander (a "flee" stand-in — reuse the primitive
    // rather than hand-roll movement away from the nearest player).
    ctx.npcSystem.aiWander(ctx.npc, ctx.mapId);
  } else {
    ctx.npcSystem.aiAttackOnSight(ctx.npc, ctx.def, ctx.mapId, ctx.players);
  }
});

4. A new tile trigger

Tile triggers fire when a player steps onto a tile of that TileType — this is for on-enter effects, not passive tile properties (movement blocking, spawn-point scanning). See the comment atop engine/behaviors/defaults.ts for exactly which built-in TileType values are and aren't triggers and why.

import { tileTriggerRegistry } from '../engine/behaviors/tileTriggers.js';

const MyTileType = { Slow: 100 } as const;

tileTriggerRegistry.register(MyTileType.Slow, (ctx) => {
  ctx.client.send('alertMsg', { msg: 'You wade through mud...' });
  // e.g. apply a temporary movement-speed debuff via your own session field
});

Overriding a built-in behavior

Registry.register() throws if the key is already taken (a deliberate loud failure, so two mods can't silently clobber each other). To replace a built-in behavior on purpose, use .replace() instead:

import { npcBehaviorRegistry } from '../engine/behaviors/npcBehaviors.js';
import { NpcBehavior } from '@ms6/shared';

// Replace the built-in Guard behavior with your own.
npcBehaviorRegistry.replace(NpcBehavior.Guard, (ctx) => { /* ... */ });

Damage & experience formulas are a different mechanism

The four registries above are for behavior — what an action does. The actual damage/experience/regen numbers are a separate, simpler mechanism: the injectable Ruleset. See Custom Ruleset.