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_warrior")
name: string; // Display name (e.g., "Goblin Warrior")
category: NPCCategory; // "mob" | "boss" | "neutral" | "quest"
modelPath: string; // Path to GLB model
stats: {
level: number; // Combat level (1-126)
attack: number; // Attack level
strength: number; // Strength level
defense: number; // Defense level
health: number; // Max HP
ranged?: number; // Ranged level (for ranged mobs)
magic?: number; // Magic level (for magic mobs)
};
combat: {
attackable: boolean; // Can be attacked by players
aggressive: boolean; // Initiates combat
retaliates: boolean; // Fights back when attacked
aggroRange: number; // Detection range in tiles
combatRange: number; // Attack range in tiles
leashRange: number; // Max chase distance from spawn
attackSpeedTicks: number; // Ticks between attacks
respawnTime: number; // Respawn delay in ms
xpReward: number; // XP granted on kill
poisonous: boolean; // Can poison players
immuneToPoison: boolean; // Immune to poison damage
attackType?: "melee" | "ranged" | "magic"; // Attack type (default: "melee")
spellId?: string; // Spell ID for magic mobs (e.g., "wind_strike")
arrowId?: string; // Arrow ID for ranged mobs (e.g., "bronze_arrow")
};
appearance: {
modelPath: string; // Path to GLB/VRM model
iconPath?: string; // Minimap icon
scale: number; // Model scale multiplier
tint?: string; // Hex color tint
heldWeaponModel?: string; // Weapon GLB to attach to hand (e.g., "asset://weapons/bow.glb")
};
aggression: {
type: AggressionType; // Aggro behavior
maxLevel?: number; // For level_gated type
};
spawnBiomes: string[]; // Where NPC can spawn
respawnTime?: number; // Respawn delay in seconds
drops: DropTable; // Loot drops
}
NPC Categories
| Category | Description | Example |
|---|
mob | Hostile enemy | Goblin, Bandit |
boss | Powerful enemy | Giant Spider, Dragon |
neutral | Non-combat NPC | Shopkeeper, Banker |
quest | Quest giver/target | Quest NPC, Guard |
Aggression Types
NPCs have different aggression behaviors:
type AggressionType =
| "passive" // Never attacks first
| "aggressive" // Attacks players below double its level
| "always_aggressive" // Attacks all players
| "level_gated"; // Only attacks below specific level
Aggro Rules
| Type | Behavior |
|---|
passive | Never initiates combat |
aggressive | Attacks if player level < 2 × NPC level |
always_aggressive | Attacks all players regardless of level |
level_gated | Attacks if player level ≤ maxLevel threshold |
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 GLB models from /assets/world/forge/:
| Model Path | Used For |
|---|
goblin/goblin_rigged.glb | Goblins |
thug/thug_rigged.glb | Bandits, thugs |
human/human_rigged.glb | Guards, knights, shopkeepers |
troll/troll_rigged.glb | Hobgoblins |
imp/imp_rigged.glb | Dark warriors |
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
Melee Mob (Default)
{
"id": "goblin_warrior",
"name": "Goblin Warrior",
"category": "mob",
"stats": {
"level": 5,
"attack": 5,
"strength": 5,
"defense": 5,
"health": 20
},
"combat": {
"attackable": true,
"aggressive": true,
"retaliates": true,
"aggroRange": 4,
"combatRange": 1,
"leashRange": 10,
"attackSpeedTicks": 4,
"respawnTime": 30000,
"xpReward": 20,
"poisonous": false,
"immuneToPoison": false
},
"appearance": {
"modelPath": "goblin/goblin_rigged.glb",
"scale": 1.0
},
"aggression": {
"type": "aggressive"
},
"spawnBiomes": ["forest", "plains"],
"drops": {
"defaultDrop": {
"enabled": true,
"itemId": "bones",
"quantity": 1
},
"common": [
{ "itemId": "bronze_dagger", "minQuantity": 1, "maxQuantity": 1, "chance": 0.25 }
]
}
}
Magic Mob
{
"id": "dark_wizard",
"name": "Dark Wizard",
"category": "mob",
"stats": {
"level": 20,
"attack": 1,
"strength": 1,
"defense": 10,
"health": 40,
"magic": 25
},
"combat": {
"attackable": true,
"aggressive": true,
"retaliates": true,
"aggroRange": 5,
"combatRange": 10,
"leashRange": 12,
"attackSpeedTicks": 5,
"respawnTime": 45000,
"xpReward": 50,
"poisonous": false,
"immuneToPoison": false,
"attackType": "magic",
"spellId": "fire_strike"
},
"appearance": {
"modelPath": "wizard/wizard_rigged.glb",
"scale": 1.0,
"heldWeaponModel": "asset://weapons/staff.glb"
},
"drops": {
"defaultDrop": {
"enabled": true,
"itemId": "bones",
"quantity": 1
},
"common": [
{ "itemId": "fire_rune", "minQuantity": 5, "maxQuantity": 15, "chance": 0.4 },
{ "itemId": "air_rune", "minQuantity": 10, "maxQuantity": 20, "chance": 0.4 }
]
}
}
Magic Mobs: The magic stat determines damage output. The spellId must reference a valid spell from the spell manifest. Mobs have infinite runes and don’t consume resources when casting.
Ranged Mob
{
"id": "dark_ranger",
"name": "Dark Ranger",
"category": "mob",
"stats": {
"level": 15,
"attack": 1,
"strength": 1,
"defense": 8,
"health": 35,
"ranged": 20
},
"combat": {
"attackable": true,
"aggressive": true,
"retaliates": true,
"aggroRange": 5,
"combatRange": 7,
"leashRange": 10,
"attackSpeedTicks": 4,
"respawnTime": 40000,
"xpReward": 40,
"poisonous": false,
"immuneToPoison": false,
"attackType": "ranged",
"arrowId": "bronze_arrow"
},
"appearance": {
"modelPath": "ranger/ranger_rigged.glb",
"scale": 1.0,
"heldWeaponModel": "asset://weapons/shortbow.glb"
},
"drops": {
"defaultDrop": {
"enabled": true,
"itemId": "bones",
"quantity": 1
},
"common": [
{ "itemId": "bronze_arrow", "minQuantity": 5, "maxQuantity": 15, "chance": 0.5 }
]
}
}
Ranged Mobs: The ranged stat determines damage output. The arrowId must reference a valid arrow from the ammunition manifest. Mobs have infinite arrows and don’t consume ammunition when firing.
Combat Type Configuration
Attack Type Fields
For Melee Mobs (default):
- No special fields required
- Uses
attack and strength stats
combatRange defaults to 1 tile
- Plays
COMBAT or SWORD_SWING animation
- Immediate hit (0 tick delay)
For Ranged Mobs:
attackType: "ranged" (required)
arrowId: Required (e.g., "bronze_arrow", "iron_arrow", "steel_arrow")
ranged: Required stat for damage calculation
combatRange: Typically 7-10 tiles
attackSpeedTicks: Typically 4 ticks (2.4 seconds)
heldWeaponModel: Optional bow GLB for visuals (e.g., "asset://weapons/shortbow.glb")
- Plays
RANGE animation
- Arrow launch delay: 400ms
- Hit delay:
1 + floor((3 + distance) / 6) ticks
For Magic Mobs:
attackType: "magic" (required)
spellId: Required (e.g., "wind_strike", "fire_bolt", "water_strike")
magic: Required stat for damage calculation
combatRange: Typically 10 tiles
attackSpeedTicks: Typically 5 ticks (3.0 seconds)
heldWeaponModel: Optional staff GLB for visuals (e.g., "asset://weapons/staff.glb")
- Plays
SPELL_CAST animation
- Spell launch delay: 600ms
- Hit delay:
1 + floor((1 + distance) / 3) ticks
Missing Configuration: If a mob has attackType: "magic" but no spellId, or attackType: "ranged" but no arrowId, the attack will be skipped with a console warning. Always configure both the attack type and the corresponding resource ID.
Held Weapon Models
The heldWeaponModel field attaches a 3D weapon to the mob’s hand bone:
{
"appearance": {
"heldWeaponModel": "asset://weapons/shortbow.glb"
}
}
Supported Formats:
- Uses Asset Forge attachment metadata (same as player equipment)
- Supports V1 (direct attachment) and V2 (pre-baked matrix) formats
- Weapons are cached and shared across mobs of the same type
- Automatically attaches to VRM
rightHand bone (or custom bone from metadata)
Weapon Cache System:
- Static
_weaponCache in MobVisualManager shares loaded GLB scenes
_pendingLoads map deduplicates concurrent fetches for the same URL
- First mob to load a weapon caches the scene, subsequent mobs clone from cache
- Eliminates duplicate network requests when multiple mobs of the same type spawn
- Cache cleared on world teardown via
MobNPCSpawnerSystem.destroy()
- Weapons use
clone(true) to share geometry/material buffers (GPU efficient)
Available Weapon Models:
asset://weapons/shortbow.glb - Shortbow (for ranged mobs)
asset://weapons/staff.glb - Magic staff (for magic mobs)
asset://weapons/sword.glb - Sword (for melee mobs)
- Custom weapons from Asset Forge
Attachment Metadata:
Weapons exported from Asset Forge include attachment metadata:
vrmBoneName: Target bone name (default: "rightHand")
version: Metadata format version (1 or 2)
relativeMatrix: Pre-baked 4×4 transform matrix (V2 format only)
Cleanup:
Weapons are properly cleaned up when mobs are destroyed:
removeFromParent() detaches weapon from bone
- Geometry and materials are NOT disposed (shared across mob instances)
- JavaScript garbage collection handles the rest
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
Configure Combat Type
Set attackType, spellId/arrowId, and heldWeaponModel if using ranged/magic
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.