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")
  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

CategoryDescriptionExample
mobHostile enemyGoblin, Bandit, Dark Wizard
bossPowerful enemyGiant Spider, Dragon
neutralNon-combat NPCShopkeeper, Bank Clerk
questQuest giver/targetCaptain 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
TypeBehavior
stationaryNPC stays in one location (e.g., shopkeepers, bankers)
wanderNPC randomly moves within wanderRadius from spawn point
patrolNPC 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

PropertyDescription
attackableWhether players can attack this NPC
aggressiveWhether NPC attacks players on sight
retaliatesWhether NPC fights back when attacked
aggroRangeDetection radius for aggressive behavior
combatRangeAttack range (1 for melee, higher for ranged/magic)
leashRangeMax distance from spawn before resetting
attackSpeedTicksTicks between attacks (4 ticks = 2.4 seconds)
respawnTicksTicks until respawn after death

Services System

NPCs can provide services to players:
interface Services {
  enabled: boolean;
  types: string[];  // "bank", "shop", "quest", etc.
}

Service Types

TypeDescriptionExample NPC
bankOpens bank interfaceBank Clerk
shopOpens shop interfaceShopkeeper, Dommik
questQuest giverCaptain 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 PathUsed For
asset://models/mobs/goblin/goblin.vrmGoblins
asset://models/mobs/bandit/bandit.vrmBandits
asset://models/mobs/barbarian/barbarian.vrmBarbarians
asset://models/mobs/dark-ranger/dark-ranger.vrmDark Rangers
asset://models/mobs/dark-wizard/dark-wizard.vrmDark Wizards
asset://models/mobs/gaurd/gaurd.vrmGuards

NPC Models

Model PathUsed For
asset://models/npcs/captain-rowan/captain-rowan.vrmCaptain Rowan (quest giver)
asset://models/npcs/forester-wilma/forester-wilma.vrmForester Wilma (woodcutting trainer)
asset://models/npcs/fisherman-pete/fisherman-pete.vrmFisherman Pete (fishing trainer)
asset://models/npcs/torvin/torvin.vrmTorvin (smithing trainer)
asset://models/npcs/banker/banker.vrmBank Clerk
asset://models/npcs/shopkeeper/shopkeeper.vrmShopkeeper
asset://models/npcs/dommik/Dommik.vrmDommik (crafting supplier)
asset://models/npcs/horvik/Horvik.vrmHorvik (armor shop)
asset://models/npcs/Lowe/Lowe.vrmLowe (bowyer)
asset://models/npcs/Zamorin/Zamorin.vrmWizard Zamorin (magic trainer)
asset://models/npcs/tanner-ellis/tanner-ellis.vrmTanner Ellis (leather goods)
asset://avatars/avatar-male-01.vrmGeneric male NPCs
asset://avatars/avatar-female-01.vrmGeneric 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.json
2

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.