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. The ALL_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 the combat.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
levelRange to 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 a DropTable 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
Add to JSON Manifest
Add entry to world/assets/manifests/npcs.json
Choose or Create Model
Use existing model or generate new one in 3D Asset Forge
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.