Skip to main content

Overview

DuelCombatAI is a specialized tick-based combat controller that takes over an AI agent’s behavior during arena duels. It uses the EmbeddedHyperscapeService to execute game actions and makes priority-based combat decisions: heal, attack, or switch style.
DuelCombatAI operates at the same 600ms tick rate as the game’s combat system, ensuring synchronized attack timing.

Architecture

Lifecycle

// From packages/server/src/arena/DuelCombatAI.ts
const combatAI = new DuelCombatAI(
  service,           // EmbeddedHyperscapeService
  opponentId,        // Target character ID
  config,            // Combat configuration
  runtime,           // AgentRuntime for LLM calls (optional)
  sendChat           // Callback for trash talk (optional)
);

// Start combat AI
combatAI.start();

// Tick externally (called by StreamingDuelScheduler)
await combatAI.externalTick();

// Stop combat AI
combatAI.stop();
Key Design Decision (commit 51453dae): The combat system’s auto-attack loop (processPlayerCombatTickprocessAutoAttackOnTick) drives the actual attack cadence once combat is established. The AI only needs to re-engage when:
  • Combat has dropped
  • Target has changed
Calling executeAttack on every cooldown cycle creates a redundant second driver that competes for the same cooldown slot, silently dropping attacks (especially for slow weapons like 2h swords).

Combat Phases

DuelCombatAI operates in four distinct phases:
PhaseTriggerBehavior
OpeningFirst 5 ticksActivate offensive prayers, use buff potions
TradingNormal combatBalanced approach, maintain pressure
FinishingOpponent < 25% HPAggressive style, maximize damage
DesperateOwn HP < 30%Defensive style, activate protection prayers

Decision Priority

Each tick, the AI evaluates actions in this order:
  1. Heal - If HP below threshold, eat food
  2. Buff - If opening phase, use buff potions
  3. Strategy - Apply LLM-planned tactics (if enabled)
  4. Attack - Re-engage if combat dropped or target changed

Configuration

interface DuelCombatConfig {
  healThresholdPct: number;        // HP% to start healing (default: 40)
  aggressiveThresholdPct: number;  // HP% to use aggressive style (default: 70)
  defensiveThresholdPct: number;   // HP% to switch defensive (default: 30)
  maxTicksWithoutAttack: number;   // Unused (default: 5)
  useLlmTactics: boolean;          // Enable LLM strategy planning (default: false)
}

Default Configuration

const DEFAULT_CONFIG: DuelCombatConfig = {
  healThresholdPct: 40,
  aggressiveThresholdPct: 70,
  defensiveThresholdPct: 30,
  maxTicksWithoutAttack: 5,
  useLlmTactics: false,
};

LLM Strategy Planning

When useLlmTactics: true, the AI uses LLM to generate combat strategies:

Strategy Object

interface CombatStrategy {
  approach: "aggressive" | "defensive" | "balanced" | "outlast";
  attackStyle: string;
  prayer: string | null;
  protectionPrayer: string | null;
  foodThreshold: number;        // HP% to eat at (20-60)
  switchDefensiveAt: number;    // HP% to go defensive (20-40)
  reasoning: string;
}

Planning Triggers

Strategy replanning occurs when:
  • First tick (initial strategy)
  • HP changes by more than 20%
  • Opponent drops below 25% HP
  • Entering desperate phase
Replanning Cooldown: 8 seconds minimum between LLM calls

LLM Prompt

const prompt = `
You are ${agentName} in a PvP duel arena. Plan your combat strategy.

YOUR STATE: HP ${healthPct}%, ${foodCount} food, tick ${tickCount}
OPPONENT: HP ${oppHpPct}%, combat level ${opponentCombatLevel}
DAMAGE SO FAR: dealt ${totalDamageDealt}, received ${totalDamageReceived}

Available prayers: ultimate_strength (+15% str), steel_skin (+15% def), rock_skin (+10% def)
Available styles: aggressive (max damage), defensive (less damage taken), controlled (balanced), accurate (hit more often)

Respond with a JSON object:
{
  "approach": "aggressive" | "defensive" | "balanced" | "outlast",
  "attackStyle": "aggressive" | "defensive" | "controlled" | "accurate",
  "prayer": "ultimate_strength" | "steel_skin" | null,
  "foodThreshold": 20-60 (HP% to eat at, lower = riskier),
  "switchDefensiveAt": 20-40 (HP% to go defensive),
  "reasoning": "brief explanation"
}
`;

Background Planning

Critical: Strategy planning runs in the background and NEVER blocks the tick loop:
// Fire-and-forget LLM call
this._llmPlanningInFlight = true;
this.planStrategy(state, healthPct, opponentData)
  .then(() => {
    this.lastReplanTime = Date.now();
    this.strategyPlanned = true;
  })
  .finally(() => {
    this._llmPlanningInFlight = false;
  });
LLM Timeout: 3 seconds maximum wait for LLM response before giving up

Trash Talk System

AI agents taunt opponents during combat using health-triggered and ambient messages.

Health Threshold Taunts

Triggered when HP crosses these milestones:
const TRASH_TALK_THRESHOLDS = [90, 80, 70, 60, 50, 40, 30, 20, 10];
Own HP Low (agent taking damage):
  • “Not even close!”
  • “I’ve had worse”
  • “Is that all?”
  • “Still standing”
Opponent HP Low (agent winning):
  • “GG soon”
  • “You’re done!”
  • “Sit down”
  • “One more hit…”

Ambient Taunts

Random taunts every 5-12 ticks with no specific trigger:
  • “Let’s go!”
  • “Fight me!”
  • “Too slow”
  • “Bring it”
  • “Nice try lol”

Opening Taunts

Fired immediately when the duel starts:
  • “You’re going down”
  • “Let’s dance”
  • “Ready to lose?”
  • “This won’t take long”

LLM-Generated Trash Talk

When an AgentRuntime is provided, trash talk uses the agent’s character bio and communication style:
const prompt = `
You are ${agentName} in a PvP duel against ${opponentName}.
Your personality: ${bioText}
Your communication style: ${styleHints}
Your HP: ${healthPct}%. Opponent HP: ${oppPct}.
Situation: ${situation}

Generate a SHORT trash talk message (under 40 characters) for the overhead chat bubble.
Stay in character. Be creative, funny, competitive. No quotes. Just the message.
`;
LLM Settings:
  • Model: TEXT_SMALL
  • Max tokens: 30
  • Temperature: 0.9 (high creativity)
  • Timeout: 3 seconds
Cooldown: 8 seconds between trash talk messages (configurable via TRASH_TALK_COOLDOWN_MS)

Fallback Behavior

If LLM fails or times out, the system uses scripted fallback taunts to ensure trash talk always works.

Attack Timing Fix (commit 51453dae)

Problem

Previously, DuelCombatAI manually tracked attack speed and called executeAttack on every cooldown cycle:
// ❌ OLD CODE (removed)
if (this.ticksSinceLastAttack >= attackSpeed) {
  await this.service.executeAttack(this.opponentId);
  this.ticksSinceLastAttack = 0;
}
This created a redundant second attack driver that competed with the combat system’s built-in auto-attack loop, causing:
  • Silent attack drops for slow weapons (2h swords, halberds)
  • Desynchronization between AI and combat system
  • Missed attacks due to cooldown conflicts

Solution

The AI now only re-engages when combat has dropped or the target changed:
// ✅ NEW CODE (commit 51453dae)
const needsEngagement =
  !state.inCombat || state.currentTarget !== this.opponentId;

if (needsEngagement) {
  await this.service.executeAttack(this.opponentId);
}
The combat system’s auto-attack loop handles all subsequent attacks at the correct weapon speed.

TWO_HAND_SWORD Default Style (commit 51453dae)

Added missing default attack style for two-handed swords in WeaponStyleConfig.ts:
// From packages/shared/src/constants/WeaponStyleConfig.ts
[WeaponType.TWO_HAND_SWORD]: [
  "accurate",
  "aggressive",
  "defensive",
  "controlled",
],
Previously, two-handed swords had no configured styles, causing the combat system to fall back to “accurate” without showing available options.

Statistics Tracking

DuelCombatAI tracks combat statistics for analysis:
const stats = combatAI.getStats();
// {
//   tickCount: 150,
//   attacksLanded: 45,
//   healsUsed: 8,
//   totalDamageDealt: 234,
//   totalDamageReceived: 189
// }

Environment Variables

# Enable DuelCombatAI for streaming duels
STREAMING_DUEL_COMBAT_AI_ENABLED=true

# Enable LLM-based combat strategy planning
STREAMING_DUEL_LLM_TACTICS_ENABLED=true

# Trash talk system configuration
TRASH_TALK_COOLDOWN_MS=8000           # Cooldown between messages (default: 8s)
TRASH_TALK_LLM_TIMEOUT_MS=3000        # LLM generation timeout (default: 3s)
TRASH_TALK_ENABLED=true               # Enable trash talk (default: true)

# Combat stall fallback
STREAMING_COMBAT_STALL_NUDGE_MS=15000 # Fallback damage nudge delay

Food Detection

DuelCombatAI automatically detects food items in inventory:
const FOOD_DATA: Record<string, number> = {
  shrimp: 3,
  bread: 5,
  meat: 3,
  trout: 7,
  salmon: 9,
  tuna: 10,
  lobster: 12,
  bass: 13,
  swordfish: 14,
  monkfish: 16,
  karambwan: 18,
  shark: 20,
  manta: 22,
  anglerfish: 22,
  // ... more food items
};
The AI selects the highest-healing food available when HP drops below threshold.

Potion Detection

Buff potions are detected by name patterns:
const POTION_PATTERNS = [
  "potion",
  "brew",
  "restore",
  "prayer",
  "super",
  "ranging",
  "magic",
  "antifire",
  "antidote",
  "stamina",
];
Potions are used during the opening phase (first 2 ticks) for combat buffs.

Performance Characteristics

  • Tick Rate: 600ms (synchronized with game combat system)
  • LLM Timeout: 3 seconds maximum
  • Planning Cooldown: 8 seconds between strategy replans
  • Trash Talk Cooldown: 8 seconds between messages
  • Background Processing: All LLM calls are fire-and-forget, never block ticks

Integration Example

// From packages/server/src/systems/StreamingDuelScheduler/
import { DuelCombatAI } from "../../arena/DuelCombatAI";

// Create combat AI for each agent
const combatAI = new DuelCombatAI(
  service,
  opponentId,
  {
    useLlmTactics: true,
    healThresholdPct: 40,
  },
  runtime,
  (text) => {
    // Send chat message above agent's head
    world.emit("chat:message", {
      characterId: agentId,
      message: text,
      type: "overhead",
    });
  }
);

// Start combat
combatAI.start();

// Tick in combat loop
setInterval(async () => {
  await combatAI.externalTick();
}, 600);

// Stop when duel ends
combatAI.stop();

Debugging

Enable debug logging to see combat AI decisions:
// In DuelCombatAI.ts
console.log(`[DuelCombatAI] Strategy planned: ${this.strategy.approach}`);
console.log(`[DuelCombatAI] Heal failed (${food.itemId}):`, err);
console.log(`[DuelCombatAI] Attack failed:`, err);
Statistics are logged on stop:
[DuelCombatAI] Stopped after 150 ticks. Attacks: 45, Heals: 8, Dmg dealt: 234, Dmg received: 189