NPC Data Structure
NPCs (Non-Player Characters) and mobs are defined in JSON manifests and loaded at runtime. This data-driven approach allows content to be modified without code changes.NPC data is managed in
packages/shared/src/data/npcs.ts and loaded from world/assets/manifests/npcs.json.Data Loading
NPCs are NOT hardcoded. TheALL_NPCS map is populated at runtime:
// From npcs.ts
export const ALL_NPCS: Map<string, NPCData> = new Map();
// Populated by DataManager from JSON
DataManager.loadNPCs(); // Reads world/assets/manifests/npcs.json
NPC Data Schema
Each NPC has the following structure:interface NPCData {
id: string; // Unique identifier (e.g., "goblin")
name: string; // Display name (e.g., "Goblin")
description: string; // NPC description
category: NPCCategory; // "mob" | "boss" | "neutral" | "quest"
faction?: string; // Faction (e.g., "monster", "town")
levelRange?: [number, number]; // Min/max level range for mobs
stats: {
level: number; // Combat level (1-126)
health: number; // Max HP
attack: number; // Attack level
strength: number; // Strength level
defense: number; // Defense level
defenseBonus: number; // Defense bonus
ranged?: number; // Ranged level (optional)
magic?: number; // Magic level (optional)
};
combat: {
attackable: boolean; // Can be attacked
aggressive?: boolean; // Attacks players
retaliates?: boolean; // Fights back when attacked
aggroRange?: number; // Aggro detection range
combatRange?: number; // Attack range
leashRange?: number; // Max distance from spawn
attackSpeedTicks?: number; // Attack speed
respawnTicks?: number; // Respawn delay in ticks
};
movement: {
type: MovementType; // "stationary" | "wander" | "patrol"
speed: number; // Movement speed
wanderRadius: number; // Wander distance from spawn
};
services?: {
enabled: boolean; // Provides services
types: string[]; // Service types (e.g., "bank", "shop")
};
dialogue?: {
entryNodeId: string; // Starting dialogue node
nodes: DialogueNode[]; // Dialogue tree
};
appearance: {
modelPath: string; // Path to VRM/GLB model
iconPath?: string; // Icon path
scale: number; // Model scale
};
spawnBiomes?: string[]; // Where NPC can spawn
drops?: DropTable; // Loot drops (for mobs)
}
NPC Categories
| Category | Description | Example |
|---|---|---|
mob | Hostile enemy | Goblin, Bandit, Dark Wizard |
boss | Powerful enemy | Giant Spider, Dragon |
neutral | Non-combat NPC | Shopkeeper, Bank Clerk |
quest | Quest giver/target | Captain Rowan, Forester Wilma |
Movement Types
NPCs have different movement patterns:type MovementType =
| "stationary" // Does not move
| "wander" // Random movement within radius
| "patrol"; // Follows predefined path
| Type | Behavior |
|---|---|
stationary | NPC stays in one location (e.g., shopkeepers, bankers) |
wander | NPC randomly moves within wanderRadius from spawn point |
patrol | NPC follows a predefined patrol route |
Combat System
Aggression Behavior
Mobs use thecombat.aggressive flag to determine if they attack players:
- Aggressive Mobs: Automatically attack nearby players within
aggroRange - Passive Mobs: Only attack when provoked
- Level-Based Aggro: Some mobs use
levelRangeto determine valid targets
Combat Properties
| Property | Description |
|---|---|
attackable | Whether players can attack this NPC |
aggressive | Whether NPC attacks players on sight |
retaliates | Whether NPC fights back when attacked |
aggroRange | Detection radius for aggressive behavior |
combatRange | Attack range (1 for melee, higher for ranged/magic) |
leashRange | Max distance from spawn before resetting |
attackSpeedTicks | Ticks between attacks (4 ticks = 2.4 seconds) |
respawnTicks | Ticks until respawn after death |
Services System
NPCs can provide services to players:interface Services {
enabled: boolean;
types: string[]; // "bank", "shop", "quest", etc.
}
Service Types
| Type | Description | Example NPC |
|---|---|---|
bank | Opens bank interface | Bank Clerk |
shop | Opens shop interface | Shopkeeper, Dommik |
quest | Quest giver | Captain Rowan, Forester Wilma |
Dialogue System
NPCs can have multi-node dialogue trees:interface Dialogue {
entryNodeId: string;
nodes: DialogueNode[];
}
interface DialogueNode {
id: string;
text: string;
responses?: DialogueResponse[];
}
interface DialogueResponse {
text: string;
nextNodeId?: string;
effect?: string; // "openBank", "openStore", etc.
}
Example Dialogue Flow
{
"entryNodeId": "greeting",
"nodes": [
{
"id": "greeting",
"text": "Welcome to the bank! How may I help you today?",
"responses": [
{
"text": "I'd like to access my bank.",
"nextNodeId": "open_bank",
"effect": "openBank"
},
{
"text": "Goodbye.",
"nextNodeId": "farewell"
}
]
},
{
"id": "open_bank",
"text": "Of course! Here are your belongings."
},
{
"id": "farewell",
"text": "Take care, adventurer!"
}
]
}
Drop Tables
Each NPC has aDropTable defining loot:
interface DropTable {
defaultDrop: {
enabled: boolean;
itemId: string;
quantity: number;
};
always: Drop[]; // 100% drop rate
common: Drop[]; // High chance
uncommon: Drop[]; // Medium chance
rare: Drop[]; // Low chance
veryRare: Drop[]; // Very low chance
}
interface Drop {
itemId: string;
minQuantity: number;
maxQuantity: number;
chance: number; // 0.0 to 1.0
}
Drop Calculation
// From npcs.ts
export function calculateNPCDrops(npcId: string): Array<{ itemId: string; quantity: number }> {
const npc = getNPCById(npcId);
if (!npc) return [];
const drops: Array<{ itemId: string; quantity: number }> = [];
// Default drop (always if enabled)
if (npc.drops.defaultDrop.enabled) {
drops.push({
itemId: npc.drops.defaultDrop.itemId,
quantity: npc.drops.defaultDrop.quantity,
});
}
// Roll for each tier
const processDrop = (drop: Drop) => {
if (Math.random() < drop.chance) {
const quantity = Math.floor(
Math.random() * (drop.maxQuantity - drop.minQuantity + 1) + drop.minQuantity
);
drops.push({ itemId: drop.itemId, quantity });
}
};
npc.drops.always.forEach(processDrop);
npc.drops.common.forEach(processDrop);
npc.drops.uncommon.forEach(processDrop);
npc.drops.rare.forEach(processDrop);
npc.drops.veryRare.forEach(processDrop);
return drops;
}
Available 3D Models
NPCs use rigged VRM models from the assets repository:Mob Models
| Model Path | Used For |
|---|---|
asset://models/mobs/goblin/goblin.vrm | Goblins |
asset://models/mobs/bandit/bandit.vrm | Bandits |
asset://models/mobs/barbarian/barbarian.vrm | Barbarians |
asset://models/mobs/dark-ranger/dark-ranger.vrm | Dark Rangers |
asset://models/mobs/dark-wizard/dark-wizard.vrm | Dark Wizards |
asset://models/mobs/gaurd/gaurd.vrm | Guards |
NPC Models
| Model Path | Used For |
|---|---|
asset://models/npcs/captain-rowan/captain-rowan.vrm | Captain Rowan (quest giver) |
asset://models/npcs/forester-wilma/forester-wilma.vrm | Forester Wilma (woodcutting trainer) |
asset://models/npcs/fisherman-pete/fisherman-pete.vrm | Fisherman Pete (fishing trainer) |
asset://models/npcs/torvin/torvin.vrm | Torvin (smithing trainer) |
asset://models/npcs/banker/banker.vrm | Bank Clerk |
asset://models/npcs/shopkeeper/shopkeeper.vrm | Shopkeeper |
asset://models/npcs/dommik/Dommik.vrm | Dommik (crafting supplier) |
asset://models/npcs/horvik/Horvik.vrm | Horvik (armor shop) |
asset://models/npcs/Lowe/Lowe.vrm | Lowe (bowyer) |
asset://models/npcs/Zamorin/Zamorin.vrm | Wizard Zamorin (magic trainer) |
asset://models/npcs/tanner-ellis/tanner-ellis.vrm | Tanner Ellis (leather goods) |
asset://avatars/avatar-male-01.vrm | Generic male NPCs |
asset://avatars/avatar-female-01.vrm | Generic female NPCs |
Helper Functions
Get NPC by ID
export function getNPCById(npcId: string): NPCData | null {
return ALL_NPCS.get(npcId) || null;
}
Get NPCs by Category
export function getNPCsByCategory(category: NPCCategory): NPCData[] {
return Array.from(ALL_NPCS.values()).filter(
(npc) => npc.category === category
);
}
Get NPCs by Biome
export function getNPCsByBiome(biome: string): NPCData[] {
return Array.from(ALL_NPCS.values()).filter((npc) =>
npc.spawnBiomes?.includes(biome)
);
}
Get NPCs by Level Range
export function getNPCsByLevelRange(minLevel: number, maxLevel: number): NPCData[] {
return Array.from(ALL_NPCS.values()).filter(
(npc) => npc.stats.level >= minLevel && npc.stats.level <= maxLevel
);
}
Check if NPC Can Drop Item
export function canNPCDropItem(npcId: string, itemId: string): boolean {
const npc = getNPCById(npcId);
if (!npc) return false;
// Check default drop
if (npc.drops.defaultDrop.enabled && npc.drops.defaultDrop.itemId === itemId) {
return true;
}
// Check all drop tiers
const allDrops = [
...npc.drops.always,
...npc.drops.common,
...npc.drops.uncommon,
...npc.drops.rare,
...npc.drops.veryRare,
];
return allDrops.some((drop) => drop.itemId === itemId);
}
Combat Level Calculation
NPC combat level is calculated from stats:// From npcs.ts
export function calculateNPCCombatLevel(stats: NPCStats): number {
const base = 0.25 * (stats.defense + stats.health + 1);
const melee = 0.325 * (stats.attack + stats.strength);
const ranged = 0.325 * Math.floor((stats.ranged || 1) * 1.5);
return Math.floor(base + Math.max(melee, ranged));
}
Spawn Constants
Global spawn settings:export const NPC_SPAWN_CONSTANTS = {
DEFAULT_RESPAWN_TIME: 30, // 30 seconds
BOSS_RESPAWN_TIME: 300, // 5 minutes
MAX_NPCS_PER_ZONE: 50,
AGGRO_CHECK_INTERVAL: 600, // Every tick (600ms)
};
Example NPC Definitions
Hostile Mob (Goblin)
{
"id": "goblin",
"name": "Goblin",
"description": "A weak goblin creature, perfect for beginners",
"category": "mob",
"faction": "monster",
"levelRange": [1, 3],
"stats": {
"level": 2,
"health": 5,
"attack": 1,
"strength": 1,
"defense": 1,
"defenseBonus": 0,
"ranged": 1,
"magic": 1
},
"combat": {
"attackable": true,
"aggressive": true,
"retaliates": true,
"aggroRange": 4,
"combatRange": 1,
"leashRange": 7,
"attackSpeedTicks": 4,
"respawnTicks": 35
},
"movement": {
"type": "wander",
"speed": 3.33,
"wanderRadius": 5
},
"drops": {
"defaultDrop": {
"enabled": true,
"itemId": "bones",
"quantity": 1
},
"common": [
{
"itemId": "coins",
"minQuantity": 5,
"maxQuantity": 15,
"chance": 1.0,
"rarity": "common"
}
],
"uncommon": [
{
"itemId": "bronze_sword",
"minQuantity": 1,
"maxQuantity": 1,
"chance": 0.1,
"rarity": "uncommon"
}
]
},
"appearance": {
"modelPath": "asset://models/goblin/goblin.vrm",
"iconPath": "asset://icons/npcs/goblin.png",
"scale": 0.75
},
"spawnBiomes": ["forest", "plains"]
}
Service NPC (Bank Clerk)
{
"id": "bank_clerk",
"name": "Bank Clerk",
"description": "A helpful bank clerk who manages deposits and withdrawals",
"category": "neutral",
"faction": "town",
"combat": {
"attackable": false
},
"movement": {
"type": "stationary",
"speed": 0,
"wanderRadius": 0
},
"services": {
"enabled": true,
"types": ["bank"]
},
"dialogue": {
"entryNodeId": "greeting",
"nodes": [
{
"id": "greeting",
"text": "Welcome to the bank! How may I help you today?",
"responses": [
{
"text": "I'd like to access my bank.",
"nextNodeId": "open_bank",
"effect": "openBank"
},
{
"text": "Goodbye.",
"nextNodeId": "farewell"
}
]
},
{
"id": "open_bank",
"text": "Of course! Here are your belongings."
},
{
"id": "farewell",
"text": "Take care, adventurer!"
}
]
},
"appearance": {
"modelPath": "asset://avatars/avatar-male-01.vrm",
"iconPath": "asset://icons/npcs/shopkeeper.png",
"scale": 1.0
}
}
Adding New NPCs
1
Add to JSON Manifest
Add entry to
world/assets/manifests/npcs.json2
Choose or Create Model
Use existing model or generate new one in 3D Asset Forge
3
Restart Server
Server must restart to reload manifests
DO NOT add NPC data directly to
npcs.ts. Keep all content in JSON manifests for data-driven design.