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.