> ## Documentation Index
> Fetch the complete documentation index at: https://hyperscape-ai-mintlify-docs-update.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Mob AI & Behavior

> State machine AI for hostile mobs, aggression mechanics, and leashing

# Mob AI & NPC Behavior

The mob AI system implements OSRS-accurate behavior using a state machine pattern. Mobs have distinct behavioral states and transition based on game events like player proximity, combat, and leash distance.

<Info>
  AI code lives in:

  * `packages/shared/src/entities/managers/AIStateMachine.ts` - State machine
  * `packages/shared/src/systems/shared/combat/AggroSystem.ts` - Aggression detection
  * `packages/shared/src/entities/npc/MobEntity.ts` - Mob entity class
</Info>

## AI State Machine

Mobs use a clean state machine with 5 core states:

```
IDLE → WANDER → CHASE → ATTACK → RETURN
         ↑                    ↓
         └────────────────────┘
```

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
enum MobAIState {
  IDLE = "idle",      // Standing still, watching for players
  WANDER = "wander",  // Patrol around spawn point
  CHASE = "chase",    // Pursuing a target
  ATTACK = "attack",  // In combat
  RETURN = "return",  // Returning to spawn (leashed)
}
```

***

## State Behavior Details

### IDLE State

* Mob stands still watching for players
* Random idle duration: 3-8 seconds
* Checks for nearby players each frame
* Transitions to WANDER after idle time expires (if movement type allows)
* Transitions to CHASE if player detected within aggro range

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
class IdleState implements AIState {
  private readonly IDLE_MIN_DURATION = 3000;  // 3 seconds
  private readonly IDLE_MAX_DURATION = 8000;  // 8 seconds

  update(context: AIStateContext): MobAIState | null {
    // Leash check first
    if (context.getDistanceFromSpawn() > context.getLeashRange()) {
      return MobAIState.RETURN;
    }
    
    // Check for nearby players (instant aggro)
    const nearbyPlayer = context.findNearbyPlayer();
    if (nearbyPlayer) {
      context.setTarget(nearbyPlayer.id);
      return MobAIState.CHASE;
    }
    
    // Transition to wander after idle time
    if (elapsed >= this.idleDuration) {
      if (context.getMovementType() !== "stationary") {
        return MobAIState.WANDER;
      }
    }
    
    return null; // Stay in IDLE
  }
}
```

### WANDER State

* Generate random target within wander radius
* Move toward target tile-by-tile
* Uses **tile-based distance checks** to prevent infinite loops
* Transitions to CHASE if player detected
* Transitions to IDLE when target reached

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
class WanderState implements AIState {
  update(context: AIStateContext): MobAIState | null {
    // Aggro check (highest priority)
    const nearbyPlayer = context.findNearbyPlayer();
    if (nearbyPlayer) {
      context.setTarget(nearbyPlayer.id);
      return MobAIState.CHASE;
    }
    
    // Generate wander target if needed
    let target = context.getWanderTarget();
    if (!target) {
      target = context.generateWanderTarget();
      context.setWanderTarget(target);
    }
    
    // Check if at target (TILE-BASED)
    const currentTile = worldToTile(position.x, position.z);
    const targetTile = worldToTile(target.x, target.z);
    
    if (tilesEqual(currentTile, targetTile)) {
      context.setWanderTarget(null);
      return MobAIState.IDLE;
    }
    
    // Move toward target
    context.moveTowards(target, deltaTime);
    return null;
  }
}
```

### CHASE State

* Mob pursues its current target
* Transitions to ATTACK when in melee range
* Transitions to RETURN if target escapes or mob exceeds leash distance
* Handles same-tile step-out (OSRS-accurate)

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
class ChaseState implements AIState {
  update(context: AIStateContext): MobAIState | null {
    const targetId = context.getCurrentTarget();
    const target = context.getPlayer(targetId);
    
    // Target lost - return home
    if (!target) {
      return MobAIState.RETURN;
    }
    
    // LEASH CHECK (OSRS two-tier system)
    const distanceFromSpawn = context.getDistanceFromSpawn();
    if (distanceFromSpawn > context.getLeashRange()) {
      context.exitCombat();
      return MobAIState.RETURN;
    }
    
    // Check if in melee range
    const mobTile = worldToTile(position.x, position.z);
    const targetTile = worldToTile(target.position.x, target.position.z);
    
    if (tilesWithinMeleeRange(mobTile, targetTile, context.getCombatRange())) {
      return MobAIState.ATTACK;
    }
    
    // Same tile handling (OSRS-accurate step-out)
    if (tilesEqual(mobTile, targetTile)) {
      context.tryStepOutCardinal();
      return null;
    }
    
    // Chase toward target
    context.moveTowards(target.position, deltaTime);
    return null;
  }
}
```

### ATTACK State

* Mob performs attacks on tick intervals
* Uses `canAttack(currentTick)` for OSRS-accurate timing
* Transitions to CHASE if target moves out of range
* Transitions to RETURN if target dies or escapes
* Attack type (melee/ranged/magic) determined by mob configuration

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
class AttackState implements AIState {
  update(context: AIStateContext): MobAIState | null {
    const targetId = context.getCurrentTarget();
    const target = context.getPlayer(targetId);
    
    if (!target) {
      return MobAIState.RETURN;
    }
    
    // Leash check during combat
    if (context.getDistanceFromSpawn() > context.getLeashRange()) {
      context.exitCombat();
      return MobAIState.RETURN;
    }
    
    // Check if still in range (uses mob's combatRange from config)
    const mobTile = worldToTile(position.x, position.z);
    const targetTile = worldToTile(target.position.x, target.position.z);
    const distance = tileChebyshevDistance(mobTile, targetTile);
    
    if (distance > context.getCombatRange() || distance === 0) {
      return MobAIState.CHASE;
    }
    
    // OSRS tick-based attack timing
    const currentTick = context.getCurrentTick();
    if (context.canAttack(currentTick)) {
      // performAttack emits COMBAT_MOB_NPC_ATTACK with attackType, spellId, arrowId
      context.performAttack(targetId, currentTick);
    }
    
    return null;
  }
}
```

**Attack Execution:**

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// From MobEntity.ts
performAttackAction(targetId: string): void {
  this.world.emit(EventType.COMBAT_MOB_NPC_ATTACK, {
    mobId: this.id,
    targetId: targetId,
    attackerType: "mob",
    targetType: "player",
    attackType: this.config.attackType ?? "melee",
    spellId: this.config.spellId,
    arrowId: this.config.arrowId,
  });
}
```

The combat system routes this event to the appropriate handler (`MeleeAttackHandler`, `RangedAttackHandler`, or `MagicAttackHandler`) based on `attackType`.

### RETURN State

* Mob walks back to spawn point
* Uses tile-based pathfinding
* Clears combat state and target
* Transitions to IDLE when at spawn

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
class ReturnState implements AIState {
  enter(context: AIStateContext): void {
    context.setTarget(null);
    context.exitCombat();
  }
  
  update(context: AIStateContext): MobAIState | null {
    const position = context.getPosition();
    const spawn = context.getSpawnPoint();
    
    // TILE-BASED distance check
    const currentTile = worldToTile(position.x, position.z);
    const spawnTile = worldToTile(spawn.x, spawn.z);
    
    if (tilesEqual(currentTile, spawnTile)) {
      return MobAIState.IDLE;
    }
    
    context.moveTowards(spawn, deltaTime);
    return null;
  }
}
```

***

## Aggression System

The `AggroSystem` handles mob aggression detection following OSRS rules.

### Level-Based Aggression

OSRS rule: Aggressive mobs only attack players whose combat level is **less than double the mob's level + 1**.

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
function shouldMobIgnorePlayer(mobLevel: number, playerLevel: number): boolean {
  // OSRS: Mob ignores player if player level >= 2 * mob level + 1
  return playerLevel >= mobLevel * 2 + 1;
}
```

Examples:

* Level 2 Goblin → Ignores players level 5+
* Level 14 Dark Wizard → Ignores players level 29+
* Level 89 Abyssal Demon → Ignores players level 179+ (never ignores)

### Tolerance Timer (10-Minute Immunity)

In OSRS, players become immune to aggression after staying in a 21×21 tile region for 10 minutes:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
const TOLERANCE_TICKS = 1000;           // 10 minutes at 600ms/tick
const TOLERANCE_REGION_SIZE = 21;       // OSRS region size

interface ToleranceState {
  regionId: string;                     // "x_z" region key
  enteredTick: number;                  // When player entered
  toleranceExpiredTick: number;         // When immunity starts
}

function checkTolerance(playerId: string, currentTick: number): boolean {
  const tolerance = this.playerTolerance.get(playerId);
  if (!tolerance) return false;
  
  return currentTick >= tolerance.toleranceExpiredTick;
}
```

### Detection Ranges

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// From CombatConstants.ts
AGGRO_RANGE: 8,                         // Detection range in tiles
MELEE_RANGE: 2,                         // Attack range for melee
RANGED_RANGE: 10,                       // Attack range for ranged
```

***

## Leash System

Mobs have a maximum chase distance from their spawn point:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// Default leash range in tiles
const DEFAULT_LEASH_RANGE = 10;         // OSRS two-tier range
const DEFAULT_WANDER_RADIUS = 5;        // Patrol area size

function getLeashRange(): number {
  return this.config.leashRange ?? DEFAULT_LEASH_RANGE;
}

function getDistanceFromSpawn(): number {
  const spawnTile = worldToTile(this.spawnPoint.x, this.spawnPoint.z);
  const currentTile = worldToTile(this.position.x, this.position.z);
  return tileChebyshevDistance(currentTile, spawnTile);
}
```

When a mob exceeds its leash range:

1. Combat state is cleared
2. Target is cleared
3. Mob transitions to RETURN state
4. Health regenerates while returning (optional)

***

## Movement Types

Mobs have configurable movement behaviors defined in their manifest:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
type MovementType = "stationary" | "wander" | "patrol";
```

| Type         | Behavior                                             |
| ------------ | ---------------------------------------------------- |
| `stationary` | Never moves from spawn, only rotates to face targets |
| `wander`     | Random movement within wander radius                 |
| `patrol`     | Walks between defined patrol points                  |

***

## Same-Tile Step-Out

OSRS-accurate behavior when mob is on the same tile as its target:

> "In RS, they pick a random cardinal direction and try to move the NPC
> towards that by 1 tile, if it can. If not, the NPC does nothing that cycle."

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
tryStepOutCardinal(): boolean {
  const directions = ["north", "east", "south", "west"];
  const randomDir = directions[Math.floor(Math.random() * 4)];
  
  const offsets: Record<string, TileCoord> = {
    north: { x: 0, z: -1 },
    east:  { x: 1, z: 0 },
    south: { x: 0, z: 1 },
    west:  { x: -1, z: 0 },
  };
  
  const offset = offsets[randomDir];
  const targetTile = { x: currentTile.x + offset.x, z: currentTile.z + offset.z };
  
  // Check if tile is walkable and unoccupied
  if (this.isWalkable(targetTile) && !this.occupancy.isOccupied(targetTile)) {
    this.moveTowards(tileToWorld(targetTile), deltaTime);
    return true;
  }
  
  return false; // Do nothing this tick
}
```

***

## Mob Entity Configuration

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
interface MobEntityConfig extends CombatantConfig {
  // Combat stats
  attackPower: number;
  attackSpeed: number;           // Ticks between attacks
  defense: number;
  attackRange: number;           // Tiles
  attackType: "melee" | "ranged" | "magic";  // Attack type (default: "melee")
  
  // Projectile attack configuration
  spellId?: string;              // Spell ID for magic mobs (e.g., "wind_strike")
  arrowId?: string;              // Arrow ID for ranged mobs (e.g., "bronze_arrow")
  
  // AI behavior
  aggroRadius: number;           // Detection range in tiles
  leashRange?: number;           // Max chase distance
  wanderRadius?: number;         // Patrol area size
  movementType: MovementType;
  
  // Spawn & respawn
  spawnPoint: Position3D;
  respawnTicks: number;          // Ticks until respawn
  
  // Loot
  dropTableId?: string;          // Reference to loot table
  
  // Visual
  modelUrl?: string;             // GLB model path
  vrmUrl?: string;               // VRM avatar path
  heldWeaponModel?: string;      // Weapon GLB to attach to hand
}
```

### Attack Type Behavior

**Melee Mobs (default):**

* Use `attack` and `strength` stats
* Attack at close range (1-2 tiles)
* Play `COMBAT` or `SWORD_SWING` animation
* No projectiles

**Ranged Mobs:**

* Require `attackType: "ranged"` and `arrowId`
* Use `ranged` stat for damage calculation
* Attack at distance (typically 7-10 tiles)
* Emit arrow projectiles
* Play `RANGE` animation
* Infinite arrows (no consumption)

**Magic Mobs:**

* Require `attackType: "magic"` and `spellId`
* Use `magic` stat for damage calculation
* Attack at distance (typically 10 tiles)
* Emit spell projectiles with element-based visuals
* Play `SPELL_CAST` animation
* Infinite runes (no consumption)

### Combat Range by Type

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// From CombatConstants.ts
MELEE_RANGE_STANDARD: 1,    // Melee mobs
RANGED_RANGE: 10,           // Ranged mobs (can be overridden in manifest)
MAGIC_RANGE: 10,            // Magic mobs (can be overridden in manifest)
```

Mobs respect their configured `combatRange` from the NPC manifest, with fallbacks to these defaults if not specified.

### Held Weapon Visuals

Mobs can display held weapons (bows, staves, swords) using the `heldWeaponModel` field:

```json theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
{
  "id": "dark_ranger",
  "appearance": {
    "modelPath": "ranger/ranger_rigged.glb",
    "heldWeaponModel": "asset://weapons/oak_shortbow.glb"
  },
  "combat": {
    "attackType": "ranged",
    "arrowId": "bronze_arrow",
    "combatRange": 7
  }
}
```

**Weapon Attachment System:**

* Weapons loaded as GLB models and attached to VRM hand bones
* Uses same Asset Forge attachment metadata as player equipment
* Supports both V1 (direct) and V2 (pre-baked matrix) attachment formats
* Static weapon cache shares loaded models across mob instances
* `clone(true)` creates per-mob instances with shared geometry/materials
* Weapons removed from parent on mob destroy (geometry shared, not disposed)
* Cache cleared on world teardown to free GPU resources

**Weapon Cache Performance:**

* First mob of a type loads the weapon GLB from network
* Subsequent mobs clone from cached scene (no network request)
* Deduplicates concurrent fetches via `_pendingLoads` promise cache
* Prevents duplicate geometry and GPU memory waste

**Supported Weapon Types:**

* Bows (shortbow, oak shortbow, willow shortbow, etc.)
* Staves (staff of air, staff of fire, etc.)
* Swords (bronze sword, iron sword, etc.)
* Any GLB model with Asset Forge attachment metadata

**Animation Integration:**

Mobs with held weapons play appropriate attack animations:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// From MobVisualManager.ts
// Mobs with heldWeaponModel play sword_swing instead of default punch
if (config.heldWeaponModel && attackType === "melee") {
  emote = Emotes.SWORD_SWING;
} else if (attackType === "magic") {
  emote = Emotes.SPELL_CAST;
} else if (attackType === "ranged") {
  emote = Emotes.RANGE;
} else {
  emote = Emotes.COMBAT;  // Default punch animation
}
```

***

## AI Events

| Event                      | Data                            | Description            |
| -------------------------- | ------------------------------- | ---------------------- |
| `MOB_NPC_SPAWNED`          | `mobId`, `mobType`, `position`  | New mob created        |
| `MOB_NPC_DESPAWN`          | `mobId`                         | Mob removed from world |
| `MOB_NPC_POSITION_UPDATED` | `mobId`, `position`             | Mob moved              |
| `MOB_AI_STATE_CHANGED`     | `mobId`, `oldState`, `newState` | AI state transition    |
| `COMBAT_STARTED`           | `attackerId`, `targetId`        | Combat initiated       |

***

## Related Documentation

* [Combat System](/wiki/game-systems/combat)
* [NPC Data Structure](/wiki/data/npcs)
* [Death & Respawn](/wiki/game-systems/death)
* [Constants Reference](/wiki/reference/constants)
