Skip to main content

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

CategoryDescriptionExample
mobHostile enemyGoblin, Bandit
bossPowerful enemyGiant Spider, Dragon
neutralNon-combat NPCShopkeeper, Banker
questQuest giver/targetQuest 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

TypeBehavior
passiveNever initiates combat
aggressiveAttacks if player level < 2 × NPC level
always_aggressiveAttacks all players regardless of level
level_gatedAttacks 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 PathUsed For
goblin/goblin_rigged.glbGoblins
thug/thug_rigged.glbBandits, thugs
human/human_rigged.glbGuards, knights, shopkeepers
troll/troll_rigged.glbHobgoblins
imp/imp_rigged.glbDark 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

1

Add to JSON Manifest

Add entry to world/assets/manifests/npcs.json
2

Choose or Create Model

Use existing model or generate new one in 3D Asset Forge
3

Configure Combat Type

Set attackType, spellId/arrowId, and heldWeaponModel if using ranged/magic
4

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.