Combat System
Hyperscape implements a tick-based combat system inspired by Old School RuneScape. Combat operates on discrete 600ms ticks, with authentic damage formulas, accuracy rolls, and attack styles.
Combat code lives in packages/shared/src/systems/shared/combat/ and uses constants from packages/shared/src/constants/CombatConstants.ts.
Core Constants
From CombatConstants.ts:
export const COMBAT_CONSTANTS = {
// Tick timing
TICK_DURATION_MS: 600, // 0.6 seconds per tick (OSRS standard)
// Attack ranges
MELEE_RANGE_STANDARD: 1, // Cardinal only (N/S/E/W)
MELEE_RANGE_HALBERD: 2, // Can attack diagonally
RANGED_RANGE: 10, // Maximum ranged attack distance
MAGIC_RANGE: 10, // Maximum magic attack distance
// Attack speeds (in ticks)
DEFAULT_ATTACK_SPEED_TICKS: 4, // 2.4 seconds (standard sword)
FAST_ATTACK_SPEED_TICKS: 3, // 1.8 seconds (scimitar, dagger, shortbow)
SLOW_ATTACK_SPEED_TICKS: 6, // 3.6 seconds (2H sword)
MAGIC_ATTACK_SPEED_TICKS: 5, // 3.0 seconds (standard spells)
// Projectile launch delays (ms)
SPELL_LAUNCH_DELAY_MS: 600, // Delay before spell projectile spawns (cast animation)
ARROW_LAUNCH_DELAY_MS: 400, // Delay before arrow projectile spawns (draw animation)
// Hit delay formulas (OSRS-accurate)
HIT_DELAY: {
MELEE: 1,
RANGED_BASE: 1,
RANGED_DISTANCE_OFFSET: 3,
RANGED_DISTANCE_DIVISOR: 6, // Formula: 1 + floor((3 + distance) / 6)
MAGIC_BASE: 1,
MAGIC_DISTANCE_OFFSET: 1,
MAGIC_DISTANCE_DIVISOR: 3, // Formula: 1 + floor((1 + distance) / 3)
},
// Damage
MIN_DAMAGE: 0,
MAX_DAMAGE: 200,
// XP rates (per damage dealt)
XP: {
COMBAT_XP_PER_DAMAGE: 4, // 4 XP per damage for main skill
HITPOINTS_XP_PER_DAMAGE: 1.33, // 1.33 XP for Constitution
CONTROLLED_XP_PER_DAMAGE: 1.33, // Split across all combat skills
MAGIC_BASE_XP: 5.5, // Base XP for casting (varies by spell)
MAGIC_DAMAGE_XP_MULTIPLIER: 2, // 2 XP per damage for Magic
},
// Food consumption (OSRS-accurate)
EAT_DELAY_TICKS: 3, // 1.8 seconds between foods
EAT_ATTACK_DELAY_TICKS: 3, // Added to attack cooldown when eating mid-combat
MAX_HEAL_AMOUNT: 99, // Security cap on healing
// Combat timeout
COMBAT_TIMEOUT_TICKS: 16, // 9.6 seconds out of combat
};
Mob Combat
Mobs can use any of the three attack types: melee, ranged, or magic. This is configured in the NPC manifest and fully integrated with the combat system.
Mob Attack Types
Configure mob attack types via NPC manifest JSON:
{
"id": "dark_wizard",
"name": "Dark Wizard",
"stats": {
"level": 20,
"attack": 1,
"strength": 1,
"defense": 10,
"health": 40,
"magic": 25
},
"combat": {
"attackType": "magic",
"spellId": "wind_strike",
"combatRange": 10,
"attackSpeedTicks": 5,
"attackable": true,
"aggressive": true,
"retaliates": true
},
"appearance": {
"modelPath": "wizard/wizard_rigged.glb",
"heldWeaponModel": "asset://weapons/staff.glb"
}
}
Attack Type Configuration:
attackType: "melee" (default), "ranged", or "magic"
spellId: Required for magic mobs (e.g., "wind_strike", "fire_bolt")
arrowId: Required for ranged mobs (e.g., "bronze_arrow", "iron_arrow")
heldWeaponModel: Optional visual weapon GLB (bow, staff, etc.)
combatRange: Attack range in tiles (1 for melee, 7-10 for ranged/magic)
attackSpeedTicks: Ticks between attacks (4-5 typical)
Mob Combat Mechanics
Mobs use the same combat handlers as players but with simplified resource management:
Mob Magic Attacks:
- Use mob’s
magic stat for damage calculation
- No rune consumption (infinite resources)
- Emit spell projectiles with correct visual effects (element-based colors)
- Play
SPELL_CAST animation
- Spell launch delay: 600ms (allows cast animation wind-up)
- Hit delay formula:
1 + floor((1 + distance) / 3) ticks
Mob Ranged Attacks:
- Use mob’s
ranged stat for damage calculation
- No arrow consumption (infinite resources)
- Emit arrow projectiles with correct visuals (metal-tipped arrows)
- Play
RANGE animation
- Arrow launch delay: 400ms (allows draw animation wind-up)
- Hit delay formula:
1 + floor((3 + distance) / 6) ticks
Mob Melee Attacks:
- Use mob’s
attack and strength stats
- Standard melee range (1 tile) or custom
combatRange
- Play
COMBAT or SWORD_SWING animation
- Immediate hit (0 tick delay)
Held Weapon Visuals
Mobs can display held weapons (bows, staves) using the same attachment system as player equipment:
// From MobVisualManager.ts
// Weapons are attached to VRM hand bones using Asset Forge metadata
// Supports both V1 (direct attachment) and V2 (pre-baked matrix) formats
// Weapons are cached and shared across mobs of the same type
// Static weapon cache with deduplication
private static _weaponCache = new Map<string, THREE.Object3D>();
private static _pendingLoads = new Map<string, Promise<THREE.Object3D>>();
// Attach weapon to mob's hand bone
private attachHeldWeapon(): void {
const weaponUrl = weaponModel.replace("asset://", `${assetsUrl}/`);
// Load from cache or fetch
const scene = await loadWeapon(weaponUrl);
const weaponMesh = scene.clone(true); // Shares geometry/materials
// Attach to VRM hand bone
targetBone.add(weaponMesh);
this._heldWeapon = weaponMesh;
}
Weapon Cache System:
- Static
_weaponCache shares loaded GLB scenes across mob instances
_pendingLoads deduplicates concurrent fetches for the same URL
clone(true) creates per-mob instances with shared geometry/materials
- Cache cleared on world teardown via
MobNPCSpawnerSystem.destroy()
- Prevents duplicate network requests and GPU memory waste
- Proper cleanup: weapons removed from parent on mob destroy (geometry shared, not disposed)
Attachment Metadata:
Weapons use Asset Forge export metadata for bone attachment:
vrmBoneName: Target bone (default: "rightHand")
version: Metadata format version (1 or 2)
relativeMatrix: Pre-baked 4×4 transform matrix (V2 format)
Supported Formats:
- V1: Direct attachment to bone (simple position/rotation)
- V2: Pre-baked matrix with
EquipmentWrapper group (advanced positioning)
Mob Damage Calculation
Mobs use simplified damage formulas without equipment bonuses:
// Magic damage for mobs
const magicLevel = npcData.stats.magic ?? 1;
const maxHit = spell.baseMaxHit; // No equipment modifiers
const damage = calculateMagicDamage({
magicLevel,
magicAttackBonus: 0, // Mobs don't have equipment
spellBaseMaxHit: maxHit,
targetMagicDefenseBonus: targetStats.magicDefense,
targetPrayerBonuses: defenderPrayer, // Players can use prayer defense
// ... accuracy calculation
});
// Ranged damage for mobs
const rangedLevel = npcData.stats.ranged ?? 1;
const arrowStrength = ammunitionService.getArrowData(arrowId)?.rangedStrength ?? 7;
const damage = calculateRangedDamage({
rangedLevel,
rangedAttackBonus: 0, // Mobs don't have equipment
rangedStrengthBonus: arrowStrength,
targetRangedDefenseBonus: targetStats.defenseRanged,
targetPrayerBonuses: defenderPrayer, // Players can use prayer defense
// ... accuracy calculation
});
// Melee damage for mobs
const attackLevel = npcData.stats.attack;
const strengthLevel = npcData.stats.strength;
const damage = calculateMeleeDamage({
attackLevel,
strengthLevel,
attackBonus: 0, // Mobs don't have equipment
strengthBonus: 0,
targetDefenseBonus: targetStats.defense,
targetPrayerBonuses: defenderPrayer,
// ... accuracy calculation
});
Key Differences from Player Combat:
- Mobs have zero equipment bonuses (no attack/strength/defense from gear)
- Mobs have infinite resources (no rune/arrow consumption)
- Mobs use stats from NPC manifest (not dynamic equipment)
- Players can still use prayer bonuses to defend against mob attacks
- Damage formulas are otherwise identical to player formulas (OSRS-accurate)
Projectile Emission
Both magic and ranged attacks emit projectile events for client-side visuals:
// From MagicAttackHandler.ts and RangedAttackHandler.ts
// Shared projectile emission methods (used by both player and mob paths)
private emitMagicProjectile(
attackerId: string,
targetId: string,
spell: Spell,
attackerPos: Position3D,
targetPos: Position3D,
distance: number,
): void {
const magicHitDelayTicks = Math.min(
HIT_DELAY.MAX_HIT_DELAY,
HIT_DELAY.MAGIC_BASE + Math.floor((HIT_DELAY.MAGIC_DISTANCE_OFFSET + distance) / HIT_DELAY.MAGIC_DISTANCE_DIVISOR),
);
const spellLaunchDelayMs = COMBAT_CONSTANTS.SPELL_LAUNCH_DELAY_MS; // 600ms
const travelDurationMs = Math.max(200, magicHitDelayTicks * TICK_DURATION_MS - spellLaunchDelayMs);
this.ctx.emitTypedEvent(EventType.COMBAT_PROJECTILE_LAUNCHED, {
attackerId,
targetId,
projectileType: spell.element, // "air", "water", "earth", "fire"
sourcePosition: attackerPos,
targetPosition: targetPos,
spellId: spell.id,
delayMs: spellLaunchDelayMs,
travelDurationMs,
});
}
private emitRangedProjectile(
attackerId: string,
targetId: string,
arrowId: string | undefined,
attackerPos: Position3D,
targetPos: Position3D,
distance: number,
): void {
const rangedHitDelayTicks = Math.min(
HIT_DELAY.MAX_HIT_DELAY,
HIT_DELAY.RANGED_BASE + Math.floor((HIT_DELAY.RANGED_DISTANCE_OFFSET + distance) / HIT_DELAY.RANGED_DISTANCE_DIVISOR),
);
const arrowLaunchDelayMs = COMBAT_CONSTANTS.ARROW_LAUNCH_DELAY_MS; // 400ms
const travelDurationMs = Math.max(200, rangedHitDelayTicks * TICK_DURATION_MS - arrowLaunchDelayMs);
this.ctx.emitTypedEvent(EventType.COMBAT_PROJECTILE_LAUNCHED, {
attackerId,
targetId,
projectileType: "arrow",
sourcePosition: attackerPos,
targetPosition: targetPos,
arrowId,
delayMs: arrowLaunchDelayMs,
travelDurationMs,
});
}
Projectile Timing:
delayMs: Time after attack start before projectile appears (animation wind-up)
travelDurationMs: How long the projectile flies (derived from hit delay formula)
- Visual arrival coincides with server-side damage splat
Launch Delays:
- Spell projectiles: 600ms delay (allows staff raise and cast gesture)
- Arrow projectiles: 400ms delay (allows bow draw animation)
Mob Retaliation
When a player attacks a ranged or magic mob, the mob retaliates with the correct attack type:
// From CombatSystem.ts
// Resolve retaliator's weapon type so auto-attack tick path uses correct handler
let retaliatorWeaponType: AttackType = AttackType.MELEE;
if (targetType === "mob" && targetEntity) {
const mobAttackType = getMobAttackType(targetEntity);
if (mobAttackType === "ranged") {
retaliatorWeaponType = AttackType.RANGED;
} else if (mobAttackType === "magic") {
retaliatorWeaponType = AttackType.MAGIC;
}
}
// Store weaponType in combat state for auto-attack ticks
this.stateService.createRetaliatorState(
targetId,
attackerId,
targetType,
attackerType,
currentTick,
retaliationDelay,
targetAttackSpeedTicks,
retaliatorWeaponType, // Ensures correct handler routing
);
Attack Handler Routing
The combat system routes mob attacks through specialized handlers:
// From CombatSystem.ts
private handleMobAttack(data: {
mobId: string;
targetId: string;
attackType?: "melee" | "ranged" | "magic";
spellId?: string;
arrowId?: string;
}): void {
switch (data.attackType) {
case AttackType.MAGIC:
this.magicHandler.handle(attackData);
break;
case AttackType.RANGED:
this.rangedHandler.handle(attackData);
break;
case AttackType.MELEE:
default:
// Intentional fallthrough: undefined/missing attackType defaults to melee
this.meleeHandler.handle(attackData);
break;
}
}
Dual Routing Paths:
-
Initial attack (first hit when entering combat):
MobEntity.performAttackAction() emits COMBAT_MOB_NPC_ATTACK event
- Event carries
attackType, spellId, arrowId from mob config
CombatSystem.handleMobAttack() routes to appropriate handler
- Handler validates, calculates damage, emits projectile
- Calls
enterCombat() with weaponType to store attack type
-
Auto-attack ticks (subsequent attacks while in combat):
CombatTickProcessor.processAutoAttackOnTick() reads combatState.weaponType
- Routes through
CombatSystem.handleAttack() to appropriate handler
- Handler resolves
spellId/arrowId from NPC data via getNPCById()
- No event data needed—attack type persisted in combat state
Both paths converge on the same handlers (MagicAttackHandler, RangedAttackHandler, MeleeAttackHandler), ensuring consistent behavior.
Shared Attack Preparation
The prepareMobAttack() utility in AttackContext.ts handles common validation for mob projectile attacks:
// Shared preparation for magic and ranged mob attacks
// Eliminates ~150 lines of duplicated boilerplate between handlers
export function prepareMobAttack(
ctx: CombatAttackContext,
data: {
attackerId: string;
targetId: string;
attackerType: "mob";
targetType: "player" | "mob"
},
combatRange: number,
animationType: "melee" | "ranged" | "magic",
fallbackAttackSpeed: number,
preResolved?: { attacker: MobEntity; npcData: NPCData },
): MobAttackContext | null;
Validation Steps:
- Entity Resolution: Resolve attacker and target entities (or use
preResolved to avoid double lookup)
- Alive Check: Verify both entities are alive
- NPC Data Lookup: Get mob configuration from
getNPCById(mobData.type)
- Range Validation: Check distance using
checkProjectileRange() with mob’s combatRange
- Position Validation: Get entity positions via
getEntityPosition()
- Cooldown Check: Verify attack is not on cooldown
- Cooldown Claim: Set
nextAttackTicks to prevent rapid-fire attacks
- Face Target: Rotate mob to face target via
rotationManager
- Play Animation: Trigger attack animation via
animationManager
Return Value:
- Returns
MobAttackContext with all validated state if all checks pass
- Returns
null if any check fails (attack aborted, no error thrown)
Performance Optimization:
The preResolved parameter allows handlers to pass already-resolved mob entity and NPC data, avoiding redundant entityResolver.resolve() and getNPCById() calls when the handler needs this data for spell/arrow validation before calling prepareMobAttack().
Session Interruption
Combat Closes Bank/Store/Dialogue
When a player is attacked, all interaction sessions are automatically closed (OSRS-accurate behavior):
// From InteractionSessionManager.ts
// OSRS-accurate: Being attacked (even a splash/miss) interrupts banking
world.on(EventType.COMBAT_DAMAGE_DEALT, (event) => {
if (event.targetType === "player" && this.sessions.has(event.targetId)) {
this.closeSession(event.targetId, "combat");
}
});
Session Close Reasons:
user_action — Player explicitly closed UI
distance — Player moved too far from target
disconnect — Player disconnected
new_session — Replaced by new session
target_gone — Target entity no longer exists
combat — Player was attacked (OSRS-style)
OSRS-Accurate: Even a splash attack (0 damage) closes the bank/store/dialogue. Being in combat matters, not just taking damage.
Food Consumption & Combat
Eat Delay Mechanics
Food consumption integrates with the combat system using OSRS-accurate timing:
// From EatDelayManager.ts
export class EatDelayManager {
canEat(playerId: string, currentTick: number): boolean;
recordEat(playerId: string, currentTick: number): void;
getRemainingCooldown(playerId: string, currentTick: number): number;
}
OSRS Rules:
- 3-tick delay between eating (1.8 seconds)
- Food consumed even at full health
- Attack delay only added if already on cooldown
- If weapon is ready to attack, eating does NOT add delay
Attack Delay Integration
When eating during combat, the system checks if the player is on attack cooldown:
// From CombatSystem.ts
public isPlayerOnAttackCooldown(playerId: string, currentTick: number): boolean {
const nextAllowedTick = this.nextAttackTicks.get(playerId) ?? 0;
return currentTick < nextAllowedTick;
}
public addAttackDelay(playerId: string, delayTicks: number): void {
const currentNext = this.nextAttackTicks.get(playerId);
if (currentNext !== undefined) {
// Add delay to existing cooldown
this.nextAttackTicks.set(playerId, currentNext + delayTicks);
}
// If no cooldown, do nothing (OSRS-accurate)
}
Example Scenario:
- Player attacks with longsword (4-tick weapon)
- Attack lands at tick 100, next attack at tick 104
- Player eats at tick 102 (while on cooldown)
- Eat delay adds 3 ticks: next attack now at tick 107
- If player eats at tick 104+ (weapon ready), no delay added
Healing is capped and validated server-side:
// From PlayerSystem.ts
const healAmount = Math.min(
Math.max(0, Math.floor(itemData.healAmount)),
COMBAT_CONSTANTS.MAX_HEAL_AMOUNT // 99 max
);
this.healPlayer(playerId, healAmount);
Combat Styles
Combat styles determine which skill gains XP and provide stat bonuses.
Melee Styles
| Style | XP Distribution | Bonus |
|---|
| Accurate | Attack only | +3 Attack |
| Aggressive | Strength only | +3 Strength |
| Defensive | Defense only | +3 Defense |
| Controlled | All four skills equally | +1 to each |
// From CombatCalculations.ts
const STYLE_BONUSES: Record<CombatStyle, StyleBonus> = {
accurate: { attack: 3, strength: 0, defense: 0 },
aggressive: { attack: 0, strength: 3, defense: 0 },
defensive: { attack: 0, strength: 0, defense: 3 },
controlled: { attack: 1, strength: 1, defense: 1 },
};
Ranged Styles
| Style | Speed | Accuracy | Range | XP Distribution |
|---|
| Accurate | Base | +3 Ranged | Normal | Ranged + Constitution |
| Rapid | -1 tick | Normal | Normal | Ranged + Constitution |
| Longrange | Base | Normal | +2 tiles | Ranged + Defense + Constitution |
// From WeaponStyleConfig.ts
export const RANGED_STYLE_BONUSES = {
accurate: { attackBonus: 3, speedModifier: 0, rangeModifier: 0, xpSplit: "ranged" },
rapid: { attackBonus: 0, speedModifier: -1, rangeModifier: 0, xpSplit: "ranged" },
longrange: { attackBonus: 0, speedModifier: 0, rangeModifier: 2, xpSplit: "ranged_defence" },
};
Magic Styles
OSRS-Accurate: Staves and wands have both melee and magic combat styles. When used without a spell selected, they function as crush weapons and grant melee XP.
Staff/Wand Melee Styles (no spell selected):
| Style | XP Distribution | Bonus |
|---|
| Accurate (Bash) | Attack only | +3 Attack |
| Aggressive (Pound) | Strength only | +3 Strength |
| Defensive (Focus) | Defense only | +3 Defense |
Staff/Wand Magic Styles (with spell selected):
| Style | Accuracy | Range | XP Distribution |
|---|
| Autocast | Normal | Normal | Magic + Constitution |
// From WeaponStyleConfig.ts
// Staves/wands have both melee and magic styles:
// Melee (crush): Bash=accurate, Pound=aggressive, Focus=defensive
// Magic: Spell=autocast (defensive autocast is a toggle, not a separate style)
// @see https://oldschool.runescape.wiki/w/Staff
[WeaponType.STAFF]: ["accurate", "aggressive", "defensive", "autocast"],
[WeaponType.WAND]: ["accurate", "aggressive", "defensive", "autocast"],
XP Grant Logic:
// From MobEntity.ts
// Check if player has a spell selected (needed for magic detection)
const selectedSpell = playerEntity?.data?.selectedSpell;
if ((weaponType === "staff" || weaponType === "wand") && selectedSpell) {
// Magic weapon WITH active spell - use "magic" style for Magic XP
// OSRS-accurate: staffs used for melee (no spell) grant melee XP
attackStyle = "magic";
} else {
// Melee attack (or staff/wand without a spell) - use player's selected attack style
// but only if it's a valid melee style; non-melee styles (longrange, autocast, rapid)
// would grant wrong XP type
const MELEE_STYLES = new Set(["accurate", "aggressive", "defensive", "controlled"]);
const playerStyle = attackStyleData?.id;
attackStyle = playerStyle && MELEE_STYLES.has(playerStyle)
? playerStyle
: "aggressive";
}
Autocast Panel Behavior:
When selecting the “autocast” style, the Spells panel automatically opens for spell selection:
// From CombatPanel.tsx
// OSRS-accurate: selecting autocast opens the spells panel for spell selection
if (next === "autocast") {
// Find or create spells window
const existing = windows.find(w => w.tabs.some(t => t.content === "spells"));
if (existing) {
// Activate spells tab and bring to front
store.updateWindow(existing.id, {
activeTabIndex: tabIndex,
visible: true,
});
store.bringToFront(existing.id);
} else {
// Create new spells window
store.createWindow({
id: `panel-spells-${Date.now()}`,
position: { x: centerX, y: centerY },
size: { width: 400, height: 350 },
tabs: [{ id: "spells", label: "Spells", content: "spells" }],
});
}
}
Damage Calculation
Damage uses the authentic OSRS formula from the wiki.
Per-Style Combat Bonuses
The armor system implements OSRS-accurate per-style attack and defense bonuses:
Melee Attack Styles:
- Stab: Daggers, spears (piercing attacks)
- Slash: Swords, scimitars, axes (slicing attacks)
- Crush: Maces, unarmed (blunt attacks)
Weapon Default Styles:
| Weapon Type | Default Style |
|---|
| Sword | Slash |
| Scimitar | Slash |
| Axe | Slash |
| Mace | Crush |
| Dagger | Stab |
| Spear | Stab |
| Halberd | Slash |
| Unarmed | Crush |
Armor Defense Bonuses:
Each armor piece provides separate defense values for each attack style:
// Example: Rune platebody bonuses
{
defenseStab: 82,
defenseSlash: 80,
defenseCrush: 72,
defenseRanged: 80,
magicDefense: -6,
attackMagic: -30,
attackRanged: -15
}
Combat Triangle:
- Melee Armor: High stab/slash/crush/ranged defense, negative magic bonuses
- Ranged Armor: Positive ranged/magic defense, lower melee defense
- Magic Armor: Positive magic attack/defense, minimal physical defense
The combat system automatically selects the appropriate attack/defense bonus based on weapon type. For example, a sword attack uses attackSlash vs. the defender’s defenseSlash.
// Get prayer bonuses (if any prayers active)
const prayerBonuses = prayerSystem.getCombinedBonuses(attackerId);
// Apply prayer multipliers to base levels
const prayeredStrength = strengthLevel × (prayerBonuses.strengthMultiplier ?? 1);
// Effective Strength = Prayered Strength + 8 + Style Bonus
const effectiveStrength = prayeredStrength + 8 + styleBonus.strength;
// Strength Bonus from equipment
const strengthBonus = equipmentStats?.strength || 0;
// Max Hit = floor(0.5 + (Effective Strength × (Strength Bonus + 64)) / 640)
const maxHit = Math.floor(0.5 + (effectiveStrength * (strengthBonus + 64)) / 640);
Prayer bonuses are applied before the effective level calculation, matching OSRS mechanics.
// From DamageCalculator.ts
function calculateMeleeDamage(
attacker: Entity,
target: Entity,
style: CombatStyle,
attackerPrayerBonuses?: PrayerCombatBonuses,
defenderPrayerBonuses?: PrayerCombatBonuses,
meleeAttackStyle?: MeleeAttackStyle, // stab/slash/crush
): number {
// Get per-style attack bonus based on weapon type
const attackBonus = getAttackBonusForStyle(equipmentStats, meleeAttackStyle);
// Get per-style defense bonus based on weapon type
const defenseBonus = getDefenseBonusForStyle(targetStats, meleeAttackStyle);
// Apply prayer multipliers
const prayeredAttack = attackLevel × (attackerPrayerBonuses.attackMultiplier ?? 1);
const prayeredDefense = defenseLevel × (defenderPrayerBonuses.defenseMultiplier ?? 1);
const effectiveAttack = prayeredAttack + 8 + styleBonus.attack;
const attackRoll = effectiveAttack * (attackBonus + 64);
const effectiveDefence = prayeredDefense + 9 + defenderStyleBonus.defense;
const defenceRoll = effectiveDefence * (defenseBonus + 64);
let hitChance: number;
if (attackRoll > defenceRoll) {
hitChance = 1 - (defenceRoll + 2) / (2 * (attackRoll + 1));
} else {
hitChance = attackRoll / (2 * (defenceRoll + 1));
}
return random.random() < hitChance;
}
Per-Style Bonus Helpers:
// Get attack bonus for specific melee style
function getAttackBonusForStyle(
stats: EquipmentStats,
attackStyle: MeleeAttackStyle,
): number {
switch (attackStyle) {
case "stab":
return stats.attackStab ?? stats.attack;
case "slash":
return stats.attackSlash ?? stats.attack;
case "crush":
return stats.attackCrush ?? stats.attack;
}
}
// Get defense bonus for specific melee style
function getDefenseBonusForStyle(
stats: EquipmentStats,
attackStyle: MeleeAttackStyle,
): number {
switch (attackStyle) {
case "stab":
return stats.defenseStab ?? stats.defense;
case "slash":
return stats.defenseSlash ?? stats.defense;
case "crush":
return stats.defenseCrush ?? stats.defense;
}
}
Prayer bonuses apply to both attacker and defender, affecting accuracy and defense rolls. Per-style bonuses fall back to generic bonuses for backward compatibility.
Damage Roll
// If hit succeeds, roll damage from 0 to maxHit
const damage = didHit ? rng.damageRoll(maxHit) : 0;
Ranged Combat
Ranged combat uses bows and arrows with OSRS-accurate mechanics.
Requirements
- Bow equipped in weapon slot
- Arrows equipped in ammo slot
- Sufficient Ranged level for bow
Available Bows (F2P)
| Bow | Level | Attack Bonus | Speed |
|---|
| Shortbow | 1 | +8 | 4 ticks (2.4s) |
| Oak Shortbow | 5 | +14 | 4 ticks |
| Willow Shortbow | 20 | +20 | 4 ticks |
| Maple Shortbow | 30 | +29 | 4 ticks |
Available Arrows (F2P)
| Arrow | Strength Bonus | Required Bow Tier |
|---|
| Bronze | +7 | Any |
| Iron | +10 | Any |
| Steel | +16 | Oak+ |
| Mithril | +22 | Willow+ |
| Adamant | +31 | Maple+ |
// Effective Ranged Level
const prayeredRanged = floor(rangedLevel * prayerMultiplier);
const effectiveRanged = prayeredRanged + styleBonus + 8;
// Attack Roll
const attackRoll = effectiveRanged * (rangedAttackBonus + 64);
// Defense Roll
const defenseRoll = (defenseLevel + 9) * (rangedDefenseBonus + 64);
// Hit Chance
if (attackRoll > defenseRoll) {
hitChance = 1 - (defenseRoll + 2) / (2 * (attackRoll + 1));
} else {
hitChance = attackRoll / (2 * (defenseRoll + 1));
}
// Max Hit
const maxHit = floor(0.5 + effectiveRanged * (arrowStrength + 64) / 640);
// Damage (if hit succeeds)
const damage = didHit ? random(0, maxHit) : 0;
Projectile System
Ranged attacks create projectiles with OSRS-accurate hit delays:
// Hit delay formula
const hitDelayTicks = 1 + Math.floor((3 + distance) / 6);
// Examples:
// Distance 0-2: 1 tick delay
// Distance 3-8: 2 tick delay
// Distance 9-14: 3 tick delay
Projectile Rendering:
- 3D arrow meshes with metal-colored tips
- Arc trajectory (straight line, no gravity arc in F2P)
- Rotates to face travel direction
- Delayed hit based on distance
Arrow Spawn Position:
Arrow projectiles spawn offset from the attacker’s center to the bow position for natural appearance:
// From ProjectileService.ts
// Offset arrow spawn 1.2 units forward toward target and adjust height to upper torso
const direction = new THREE.Vector3()
.subVectors(targetPosition, attackerPosition)
.normalize();
const spawnOffset = direction.multiplyScalar(1.2);
const adjustedSpawnPos = {
x: attackerPosition.x + spawnOffset.x,
y: attackerPosition.y + 1.4, // Upper torso height (bow position)
z: attackerPosition.z + spawnOffset.z,
};
Before Fix: Arrows spawned at center of attacker model (appeared to come from inside player’s head)
After Fix: Arrows spawn 1.2 units forward and at upper torso level for natural bow-firing appearance
Ammunition Consumption
Arrows are consumed on every shot:
// 100% consumption rate (no Ava's device in F2P)
const consumed = 1;
const remaining = currentQuantity - consumed;
Arrows are NOT recoverable. Each shot consumes 1 arrow permanently.
Ranged XP
// XP per damage dealt
const rangedXP = damage * 4;
const constitutionXP = damage * 1.33;
// Longrange style splits XP
if (style === "longrange") {
grantXP(playerId, "ranged", damage * 2);
grantXP(playerId, "defense", damage * 2);
grantXP(playerId, "constitution", damage * 1.33);
}
Magic Combat
Magic combat allows spellcasting with or without a staff.
Requirements
- Magic level sufficient for the spell
- Runes in inventory (or infinite from elemental staff)
- Spell selected for autocast (optional)
OSRS-Accurate: You can cast spells without a staff. The staff provides magic attack bonus and infinite runes for its element.
Available Spells (F2P)
Strike Tier (Levels 1-13):
| Spell | Level | Max Hit | XP | Runes |
|---|
| Wind Strike | 1 | 2 | 5.5 | 1 Air, 1 Mind |
| Water Strike | 5 | 4 | 7.5 | 1 Air, 1 Water, 1 Mind |
| Earth Strike | 9 | 6 | 9.5 | 1 Air, 2 Earth, 1 Mind |
| Fire Strike | 13 | 8 | 11.5 | 2 Air, 3 Fire, 1 Mind |
Bolt Tier (Levels 17-35):
| Spell | Level | Max Hit | XP | Runes |
|---|
| Wind Bolt | 17 | 9 | 13.5 | 2 Air, 1 Chaos |
| Water Bolt | 23 | 10 | 16.5 | 2 Air, 2 Water, 1 Chaos |
| Earth Bolt | 29 | 11 | 19.5 | 2 Air, 3 Earth, 1 Chaos |
| Fire Bolt | 35 | 12 | 22.5 | 3 Air, 4 Fire, 1 Chaos |
Elemental Staves
Staves provide infinite runes for their element:
| Staff | Infinite Runes | Magic Attack Bonus |
|---|
| Staff | - | +4 |
| Magic Staff | - | +10 |
| Staff of Air | Air | +10 |
| Staff of Water | Water | +10 |
| Staff of Earth | Earth | +10 |
| Staff of Fire | Fire | +10 |
// Effective Magic Level
const prayeredMagic = floor(magicLevel * prayerMultiplier);
const effectiveMagic = prayeredMagic + styleBonus + 8;
// Attack Roll
const attackRoll = effectiveMagic * (magicAttackBonus + 64);
// Defense Roll (Players)
const effectiveDefense = floor(0.7 * magicLevel + 0.3 * defenseLevel) + 9;
const defenseRoll = effectiveDefense * (magicDefenseBonus + 64);
// Defense Roll (NPCs)
const defenseRoll = (magicLevel + 9) * (magicDefenseBonus + 64);
// Hit Chance (same formula as melee/ranged)
if (attackRoll > defenseRoll) {
hitChance = 1 - (defenseRoll + 2) / (2 * (attackRoll + 1));
} else {
hitChance = attackRoll / (2 * (defenseRoll + 1));
}
// Max Hit = spell base damage (no equipment modifiers in F2P)
const maxHit = spellBaseMaxHit;
// Damage (if hit succeeds)
const damage = didHit ? random(0, maxHit) : 0;
Key Difference: Magic defense for players uses 70% Magic level + 30% Defense level. NPCs only use Magic level.
Rune Consumption
Runes are consumed on each cast:
// Check required runes
const hasRunes = runeService.hasRequiredRunes(playerId, spell.runes);
// Consume runes (accounting for elemental staff)
const infiniteRunes = getInfiniteRunesFromStaff(playerId);
const runesToConsume = spell.runes.filter(r => !infiniteRunes.includes(r.runeId));
runeService.consumeRunes(playerId, runesToConsume);
Autocast
Players can select a spell for autocast:
// Set autocast spell
world.network.send("setAutocast", { spellId: "fire_strike" });
// Clear autocast
world.network.send("setAutocast", { spellId: null });
// Autocast is cleared on weapon swap
Autocast Behavior:
- Selected spell automatically casts when attacking
- Spell selection persists across sessions (saved in database)
- Cleared when weapon is changed
- Shown in Spells panel with checkmark
Spell Projectiles
Magic spells render as multi-layer billboard meshes with WebGPU-compatible DataTextures:
// From spell-visuals.ts
// Strike spells (Level 1-13)
const STRIKE_BASE: Partial<SpellVisualConfig> = {
size: 0.5, // Increased from 0.25 for better visibility
glowIntensity: 0.7, // Increased from 0.3-0.5
trailLength: 4,
trailFade: 0.5,
pulseSpeed: 0, // No pulsing for strike tier
pulseAmount: 0,
};
// Bolt spells (Level 17-35) - larger with orbiting sparks
const BOLT_BASE: Partial<SpellVisualConfig> = {
size: 0.7, // Increased from 0.35
glowIntensity: 0.8, // Increased from 0.5
trailLength: 5,
trailFade: 0.4,
pulseSpeed: 5, // Pulsing outer glow
pulseAmount: 0.2, // 20% scale variation
};
Multi-Layer Projectile Structure:
Each spell projectile consists of multiple billboard meshes:
- Outer Glow (Layer 1): Soft, semi-transparent, 2.5× size
- Core Orb (Layer 2): Bright center, sharp glow
- Orbiting Sparks (Layer 3, bolt-tier only): 2 tiny particles that orbit the core
// From ProjectileRenderer.ts
// Layer 1: Outer glow — soft, larger, semi-transparent
const outerMat = this.createGlowMaterial(palette.mid, 1.5, 0.6);
const outerMesh = new THREE.Mesh(geom, outerMat);
outerMesh.scale.set(config.size * 2.5, config.size * 2.5, config.size * 2.5);
// Layer 2: Core orb — bright center, sharp
const coreMat = this.createGlowMaterial(palette.core, 3.0, 0.9);
const coreMesh = new THREE.Mesh(geom, coreMat);
coreMesh.scale.set(config.size, config.size, config.size);
// Layer 3: Orbiting sparks (bolt-tier only)
if (config.pulseSpeed > 0) {
for (let i = 0; i < 2; i++) {
const sparkMat = this.createGlowMaterial(palette.core, 4.0, 0.8);
const sparkMesh = new THREE.Mesh(geom, sparkMat);
sparkMesh.scale.set(config.size * 0.3, config.size * 0.3, config.size * 0.3);
}
}
Impact Burst Particles:
When a spell hits its target, 4-6 particles burst outward:
// Spawn impact burst on hit
private spawnImpactBurst(proj: ActiveProjectile): void {
const count = 4 + Math.floor(Math.random() * 3); // 4-6 particles
for (let i = 0; i < count; i++) {
// Random outward velocity in XZ + upward drift
const angle = Math.random() * Math.PI * 2;
const speed = 1.5 + Math.random() * 2.5;
const velocity = new THREE.Vector3(
Math.cos(angle) * speed,
1.0 + Math.random() * 1.5,
Math.sin(angle) * speed,
);
const maxLife = 0.3 + Math.random() * 0.2; // 0.3-0.5s lifetime
}
}
WebGPU Compatibility:
The projectile system uses DataTextures with color baked directly into pixels (not via material.color tinting) for reliable rendering in WebGPU:
// Create color-baked radial glow DataTexture
private createColoredGlowTexture(
colorHex: number,
size: number,
sharpness: number,
): THREE.DataTexture {
const r = (colorHex >> 16) & 0xff;
const g = (colorHex >> 8) & 0xff;
const b = colorHex & 0xff;
// Bake color directly into RGBA pixels
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const dist = Math.sqrt(dx * dx + dy * dy);
const falloff = Math.max(0, 1 - dist);
const strength = Math.pow(falloff, sharpness);
data[idx] = Math.round(r * strength);
data[idx + 1] = Math.round(g * strength);
data[idx + 2] = Math.round(b * strength);
data[idx + 3] = Math.round(255 * strength);
}
}
}
Textures are cached and shared across projectiles for memory efficiency.
Hit Delay:
// Magic hit delay formula
const hitDelayTicks = 1 + Math.floor((1 + distance) / 3);
// Examples:
// Distance 0-1: 1 tick delay
// Distance 2-4: 2 tick delay
// Distance 5-7: 3 tick delay
Magic XP
// XP formula: Base spell XP + (2 × damage dealt)
const magicXP = spellBaseXP + (damage * 2);
const constitutionXP = damage * 1.33;
// Example: Fire Strike (11.5 base XP) dealing 6 damage
// Magic XP: 11.5 + (6 * 2) = 23.5
// Constitution XP: 6 * 1.33 = 8
Attack Type Detection
The combat system automatically detects attack type from equipped weapon or selected spell:
// From ServerNetwork/index.ts
getPlayerAttackType(playerId): AttackType {
// Check if player has a spell selected - if so, use magic regardless of weapon
const selectedSpell = playerEntity.data.selectedSpell;
if (selectedSpell) {
return AttackType.MAGIC;
}
// Check equipped weapon
const weapon = equipmentSystem.getPlayerEquipment(playerId).weapon;
if (weapon?.attackType) {
return weapon.attackType; // MELEE, RANGED, or MAGIC
}
// Check weapon type for legacy compatibility
if (weapon?.weaponType === WeaponType.BOW) {
return AttackType.RANGED;
}
if (weapon?.weaponType === WeaponType.STAFF || weapon?.weaponType === WeaponType.WAND) {
return AttackType.MAGIC;
}
return AttackType.MELEE;
}
Spell Priority: If a spell is selected for autocast, the attack type is MAGIC regardless of equipped weapon. This allows staffless casting (OSRS-accurate).
Attack Range System
Melee Range
OSRS Accuracy: Standard melee (range 1) can only attack in cardinal directions (N/S/E/W). Diagonal attacks require range 2+ weapons like halberds.
// From TileSystem.ts
export function tilesWithinMeleeRange(
attacker: TileCoord,
target: TileCoord,
meleeRange: number,
): boolean {
const dx = Math.abs(attacker.x - target.x);
const dz = Math.abs(attacker.z - target.z);
// Range 1 (standard melee): CARDINAL ONLY
if (meleeRange === 1) {
return (dx === 1 && dz === 0) || (dx === 0 && dz === 1);
}
// Range 2+ (halberd): Allow diagonal attacks
const chebyshevDistance = Math.max(dx, dz);
return chebyshevDistance <= meleeRange && chebyshevDistance > 0;
}
Combat Pathfinding
Combat movement uses multi-destination BFS with line-of-sight validation for OSRS-accurate pathfinding:
// From tile-movement.ts
// Generate ALL valid attack tiles
let validTiles: TileCoord[];
if (attackType === AttackType.RANGED || attackType === AttackType.MAGIC) {
validTiles = getValidRangedTiles(
targetTile,
attackRange,
(tile) => this.isTileWalkable(tile),
(x, z) => this.world.collision.hasFlags(x, z, CollisionMask.BLOCKS_RANGED),
);
} else {
validTiles = getValidMeleeTiles(
targetTile,
attackRange,
(tile) => this.isTileWalkable(tile),
);
}
// Multi-destination BFS: finds shortest path to ANY valid tile
path = this.pathfinder.findPathToAny(
state.currentTile,
validTiles,
(tile) => this.isTileWalkable(tile),
);
Key Features:
- BFS as primary pathfinder: Player movement uses BFS (“smartpathing”) instead of naive diagonal-first
- Multi-destination search: Generates all valid attack tiles and finds shortest path to any of them
- Line of sight: Ranged/magic attacks verify LoS via Bresenham line trace against
BLOCKS_RANGED collision flags
- Optimal pathing: Naturally selects the closest reachable attack position
- Obstacle handling: Automatically routes around walls and blocked tiles
Line of Sight Check:
/**
* OSRS-accurate Line of Sight check for ranged/magic combat.
* Traces a line between two tiles using Bresenham's algorithm.
*/
export function hasLineOfSight(
from: TileCoord,
to: TileCoord,
hasBlockingFlags: (x: number, z: number) => boolean,
): boolean;
// Usage in combat
const hasLoS = hasLineOfSight(
attackerTile,
targetTile,
(x, z) => world.collision.hasFlags(x, z, CollisionMask.BLOCKS_RANGED),
);
Collision Masks:
// From CollisionFlags.ts
export const CollisionMask = {
BLOCK_LOS: 0x02000000, // Walls, solid objects
BLOCKED: 0x00200000, // Trees, rocks, stations
BLOCKS_RANGED: BLOCK_LOS | BLOCKED, // Combined mask for LoS checks
};
Breaking Change (PR #886): Ranged and magic attacks now require line of sight. You cannot attack through walls even if the target is within Chebyshev range.
Benefits:
- Eliminates visible diagonal zigzag when walking to attack targets
- Prevents attacking through walls when in Chebyshev range
- Finds optimal path to closest reachable attack position
- Matches OSRS smartpathing behavior for players
NPC Chase Pathfinding:
NPCs still use naive diagonal pathing (not BFS) to enable safespotting:
// From ChasePathfinding.ts
// NPCs use "dumb" diagonal-first pathing (OSRS-accurate for safespotting)
const path = pathfinder.findNaivePath(mobTile, targetTile, isWalkable);
This intentional difference allows players to safespot mobs by positioning themselves where naive pathing cannot reach, matching OSRS mechanics.
Combat Follow System
OSRS-Accurate: Players continuously follow their combat target while in range, not just when out of range. This prevents the stutter pattern where players stand still until the target moves away.
The combat system tracks target movement and maintains pursuit even when in attack range:
// From CombatSystem.ts
// Track last known target tile per attacker for persistent combat follow
private lastCombatTargetTile = new Map<string, { x: number; z: number }>();
// Detect target movement
const lastKnown = this.lastCombatTargetTile.get(attackerId);
const targetMoved = !lastKnown ||
lastKnown.x !== targetTile.x ||
lastKnown.z !== targetTile.z;
if (targetMoved) {
// Update last known target tile
this.lastCombatTargetTile.set(attackerId, {
x: targetTile.x,
z: targetTile.z,
});
}
// Pre-compute follow path when target moves (even if in range)
// This ensures zero-delay pursuit if target steps out of range next tick
if (targetMoved) {
this.emitTypedEvent(EventType.COMBAT_FOLLOW_TARGET, {
playerId: attackerId,
targetId: targetId,
targetPosition: targetPos,
attackRange: combatRangeTiles,
attackType: attackType,
});
}
Benefits:
- Smooth pursuit of moving targets
- Zero-delay response when target leaves range
- Pre-computed pathfinding for responsive gameplay
- Matches OSRS behavior where players “stick” to their target
Cleanup:
The lastCombatTargetTile map is automatically cleaned up when:
- Combat ends naturally (timeout)
- Player disengages from combat
- Combat is forcibly stopped
Combat Rotation System
Players automatically face their combat target during ranged and magic attacks:
// From CombatSystem.ts
// Emit COMBAT_FACE_TARGET for the attacker so the local player client
// rotates toward the target. Essential for magic/ranged attacks where
// the player is stationary (no movement to naturally rotate them).
if (attackerType === "player") {
this.emitTypedEvent(EventType.COMBAT_FACE_TARGET, {
playerId: String(attackerId),
targetId: String(targetId),
});
}
Rotation Clearing:
Rotation tracking is cleared when:
- Combat target dies
- Player starts moving (movement takes priority)
- Combat ends or is disengaged
// From PlayerLocal.ts
// Clear stored combat rotation when player starts moving
if (isMoving) {
this._lastCombatRotation = null;
this._serverFaceTargetId = null;
}
This prevents players from continuing to face dead targets or old combat directions.
Ranged Combat
Ranged attacks use Chebyshev distance and require:
- A ranged weapon (bow)
- Ammunition (arrows)
Arrow Visuals:
Arrows are rendered as 3D meshes with metal-colored tips and wooden shafts:
// From spell-visuals.ts
export const ARROW_VISUALS = {
default: {
shaftColor: 0x8b4513, // Brown wood
headColor: 0xa0a0a0, // Gray metal
fletchingColor: 0xffffff, // White feathers
length: 0.35, // Reduced from 0.6 for better scale
width: 0.08, // Reduced from 0.15
rotateToDirection: true,
arcHeight: 0, // Straight trajectory
},
bronze_arrow: {
headColor: 0xcd7f32, // Bronze
length: 0.35,
width: 0.08,
},
iron_arrow: {
headColor: 0x71797e, // Iron gray
length: 0.35,
width: 0.08,
},
// ... steel, mithril, adamant arrows
};
Arrow Size Reduction:
Arrow projectiles were reduced in size (length: 0.6 → 0.35, width: 0.15 → 0.08) to better match the scale of player models and improve visual clarity during combat.
Bow Models:
All bow items now display 3D bow models when equipped:
- Shortbow, Oak Shortbow, Willow Shortbow, Maple Shortbow
- Models are properly rigged to the player’s hand bones
- Bows are visible during idle, walking, and combat animations
// Ranged range check
export function isInAttackRange(
attackerPos: Position3D,
targetPos: Position3D,
attackType: AttackType,
): boolean {
const attackerTile = worldToTile(attackerPos.x, attackerPos.z);
const targetTile = worldToTile(targetPos.x, targetPos.z);
if (attackType === AttackType.MELEE) {
return tilesWithinMeleeRange(attackerTile, targetTile, 1);
} else {
const tileDistance = tileChebyshevDistance(attackerTile, targetTile);
return tileDistance <= COMBAT_CONSTANTS.RANGED_RANGE && tileDistance > 0;
}
}
Attack Speed & Cooldowns
Attacks occur on tick boundaries with weapon-specific speeds.
// Convert weapon attack speed to ticks
export function attackSpeedMsToTicks(ms: number): number {
return Math.max(1, Math.round(ms / COMBAT_CONSTANTS.TICK_DURATION_MS));
}
// Check if attack is on cooldown
export function isAttackOnCooldownTicks(
currentTick: number,
nextAttackTick: number,
): boolean {
return currentTick < nextAttackTick;
}
// Auto-retaliate delay: ceil(weapon_speed / 2) + 1 ticks
export function calculateRetaliationDelay(attackSpeedTicks: number): number {
return Math.ceil(attackSpeedTicks / 2) + 1;
}
Weapon Speed Examples
| Weapon Type | Speed (ticks) | Speed (seconds) |
|---|
| Scimitar | 3 | 1.8s |
| Longsword | 4 | 2.4s |
| Battleaxe | 5 | 3.0s |
| 2H Sword | 6 | 3.6s |
| Shortbow | 3 | 1.8s |
| Longbow | 5 | 3.0s |
Aggro System
NPCs have configurable aggression behaviors.
Aggro Types
// From types/core/core.ts
export 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 Constants
export const AGGRO_CONSTANTS = {
CHECK_INTERVAL_MS: 600, // Check every tick
PASSIVE_AGGRO_RANGE: 0, // No aggro range
STANDARD_AGGRO_RANGE: 4, // 4 tiles
BOSS_AGGRO_RANGE: 8, // 8 tiles for bosses
EXTENDED_AGGRO_RANGE: 6, // 6 tiles for always_aggressive
};
export const LEVEL_CONSTANTS = {
DOUBLE_LEVEL_MULTIPLIER: 2, // Aggro stops when player is 2x NPC level
};
Aggro Logic
// Simplified from AggroSystem.ts
function shouldAggroPlayer(mob: MobEntity, player: PlayerEntity): boolean {
const mobData = mob.getMobData();
const distance = tileChebyshevDistance(mob.tile, player.tile);
switch (mobData.aggression.type) {
case "passive":
return false;
case "aggressive":
// Only aggro if player level < 2 × mob level
if (player.combatLevel >= mobData.stats.level * 2) return false;
return distance <= AGGRO_CONSTANTS.STANDARD_AGGRO_RANGE;
case "always_aggressive":
return distance <= AGGRO_CONSTANTS.EXTENDED_AGGRO_RANGE;
case "level_gated":
if (player.combatLevel > mobData.aggression.maxLevel) return false;
return distance <= AGGRO_CONSTANTS.STANDARD_AGGRO_RANGE;
}
}
Death Mechanics
Player Death
When a player dies:
- Headstone spawns at death location
- Items drop to headstone (kept for 15 minutes)
- Player respawns at starter town
- 3 most valuable items are kept (Protect Item prayer adds 1)
// From DeathSystem.ts
handlePlayerDeath(playerId: string, deathPosition: Position3D): void {
// Create headstone entity
const headstone = new HeadstoneEntity(this.world, {
position: deathPosition,
ownerId: playerId,
items: droppedItems,
expiresAt: Date.now() + 15 * 60 * 1000, // 15 minutes
});
// Respawn player at starter town
this.respawnPlayer(playerId, STARTER_TOWN_POSITION);
}
Duel Death Handling
Duel deaths use individual try/catch blocks to prevent one player’s failure from affecting the other:
// From DuelCombatResolver.ts
// Restore health — wrapped individually so one player failing
// doesn't prevent the other from being restored
try {
this.restorePlayerHealth(winnerId, LOBBY_SPAWN_WINNER);
} catch (err) {
Logger.error("DuelCombatResolver", "Winner health restoration failed", err);
}
try {
this.restorePlayerHealth(loserId, LOBBY_SPAWN_LOSER);
} catch (err) {
Logger.error("DuelCombatResolver", "Loser health restoration failed", err);
}
Critical Fix (PR #875):
Both restorePlayerHealth calls were previously in a single try/catch. If the winner’s restore triggered an exception in any PLAYER_RESPAWNED handler, the loser’s restore was skipped—leaving them with:
- Frozen physics (
isDying=true)
- No
playerRespawned/playerSetDead packets sent
- Client unable to act on
playerTeleport (which still arrived)
- Player stuck in arena with death animation
Now each restore is individually wrapped, matching the pattern already used for teleports. This ensures both players are properly restored even if one fails.
Mob Death
When a mob dies:
- Loot drops based on drop table
- XP granted to all attackers
- Respawn timer starts (based on mob type)
- Entity destroyed after death animation
XP Distribution
XP is granted based on damage dealt and combat style.
Melee XP
// From SkillsSystem.ts
handleCombatKill(data: CombatKillData): void {
const totalDamage = data.damageDealt;
// Combat skill XP: 4 per damage
const combatXP = totalDamage * COMBAT_CONSTANTS.XP.COMBAT_XP_PER_DAMAGE;
// Constitution XP: 1.33 per damage (always)
const hpXP = totalDamage * COMBAT_CONSTANTS.XP.HITPOINTS_XP_PER_DAMAGE;
switch (data.attackStyle) {
case "accurate":
this.grantXP(attackerId, "attack", combatXP);
break;
case "aggressive":
this.grantXP(attackerId, "strength", combatXP);
break;
case "defensive":
this.grantXP(attackerId, "defense", combatXP);
break;
case "controlled":
// Split evenly across all 4 skills
const splitXP = totalDamage * COMBAT_CONSTANTS.XP.CONTROLLED_XP_PER_DAMAGE;
this.grantXP(attackerId, "attack", splitXP);
this.grantXP(attackerId, "strength", splitXP);
this.grantXP(attackerId, "defense", splitXP);
this.grantXP(attackerId, "constitution", splitXP);
return; // HP included above
}
// Grant Constitution XP
this.grantXP(attackerId, "constitution", hpXP);
}
Ranged XP
// Ranged XP: 4 per damage
const rangedXP = damage * 4;
const constitutionXP = damage * 1.33;
// Longrange style splits XP
if (style === "longrange") {
grantXP(playerId, "ranged", damage * 2);
grantXP(playerId, "defense", damage * 2);
grantXP(playerId, "constitution", damage * 1.33);
} else {
grantXP(playerId, "ranged", damage * 4);
grantXP(playerId, "constitution", damage * 1.33);
}
Magic XP
// Magic XP: Base spell XP + (2 × damage dealt)
const magicXP = spellBaseXP + (damage * 2);
const constitutionXP = damage * 1.33;
// Example: Fire Strike (11.5 base XP) dealing 6 damage
// Magic XP: 11.5 + (6 * 2) = 23.5
// Constitution XP: 6 * 1.33 = 8
// Longrange style splits XP
if (style === "longrange") {
grantXP(playerId, "magic", spellBaseXP + damage);
grantXP(playerId, "defense", damage);
grantXP(playerId, "constitution", damage * 1.33);
}
Magic grants XP even on a splash (0 damage). You still get the base spell XP, just no damage bonus.
PvP XP Calculation
In player-versus-player combat (duels), XP is granted based on the actual weapon type used, not the player’s selected attack style:
// From PlayerDeathSystem.ts
// Detect weapon type for PvP kills to grant correct XP
const weapon = equipmentSystem.getPlayerEquipment(killerId)?.weapon?.item;
const weaponType = weapon?.weaponType?.toLowerCase();
let attackStyle: string;
if ((weaponType === "bow" || weaponType === "crossbow") && weapon) {
// Ranged weapon - grant Ranged XP
attackStyle = "ranged";
} else if ((weaponType === "staff" || weaponType === "wand") && selectedSpell) {
// Magic weapon WITH active spell - grant Magic XP
attackStyle = "magic";
} else {
// Melee attack (or staff/wand without spell) - use player's attack style
const MELEE_STYLES = new Set(["accurate", "aggressive", "defensive", "controlled"]);
const playerStyle = attackStyleData?.id;
attackStyle = playerStyle && MELEE_STYLES.has(playerStyle)
? playerStyle
: "aggressive";
}
Fixed in PR #875: Previously, PvP kills always used the player’s melee attack style, causing ranged attacks to incorrectly grant Strength XP. Now the system inspects the actual weapon type, matching the logic used for mob kills.
Food & Combat Interaction
Eating During Combat
When a player eats food while in combat, OSRS-accurate timing rules apply:
// From PlayerSystem.ts
// OSRS Rule: Foods only add to EXISTING attack delay
// If weapon is ready to attack, eating does NOT add delay
const isOnCooldown = combatSystem.isPlayerOnAttackCooldown(playerId, currentTick);
if (isOnCooldown) {
// Add 3 ticks to attack cooldown
combatSystem.addAttackDelay(playerId, COMBAT_CONSTANTS.EAT_ATTACK_DELAY_TICKS);
}
// If weapon is ready (cooldown expired), eating does NOT add delay
Eat Delay Mechanics
Players cannot eat again until the eat delay expires:
// 3-tick (1.8 second) cooldown between foods
const canEat = eatDelayManager.canEat(playerId, currentTick);
if (!canEat) {
// Show "You are already eating." message
return;
}
// Record eat action
eatDelayManager.recordEat(playerId, currentTick);
OSRS-Accurate: Food is consumed even at full health. The eat delay and attack delay apply regardless of current HP.
Attack Delay API
The CombatSystem provides methods for eat delay integration:
// Check if player is on attack cooldown
isPlayerOnAttackCooldown(playerId: string, currentTick: number): boolean;
// Add delay ticks to player's next attack
addAttackDelay(playerId: string, delayTicks: number): void;
Combat Events
The combat system emits events for UI and logging:
| Event | Data | Description |
|---|
COMBAT_ATTACK | attackerId, targetId, damage, didHit | Attack executed |
COMBAT_KILL | attackerId, targetId, damageDealt, attackStyle | Kill confirmed |
COMBAT_STARTED | entityId, targetId | Entity entered combat |
COMBAT_ENDED | entityId | Entity left combat |
ENTITY_DAMAGED | entityId, damage, sourceId, remainingHealth | Damage taken |
ENTITY_DEATH | entityId, killerId, position | Entity died |
PLAYER_HEALTH_UPDATED | playerId, health, maxHealth | Health changed (healing, damage) |
Prayer Bonuses in Combat
Active prayers provide multipliers to combat stats. See Prayer System for complete details.
Example Prayer Effects:
- Clarity of Thought (Level 7): +5% Attack (1.05× multiplier)
- Burst of Strength (Level 4): +5% Strength (1.05× multiplier)
- Thick Skin (Level 1): +5% Defense (1.05× multiplier)
Bonus Application:
// Prayer bonuses are applied BEFORE effective level calculation
const prayeredAttack = baseAttack × attackMultiplier;
const effectiveAttack = prayeredAttack + 8 + styleBonus;
Multiple prayers of the same type do NOT stack. The system uses the highest multiplier for each stat.
Combat Visual Synchronization
Mob Combat Rotation
Mobs properly clear their combat rotation flag when movement starts to prevent stuck facing:
// From TileInterpolator.ts (PR #884)
// Clear inCombatRotation flag on movement start
// Was never cleared, causing mobs to keep combat-facing after combat ended
if (this.inCombatRotation) {
this.inCombatRotation = false;
}
Before Fix: Mobs kept combat rotation after combat ended, appearing stuck facing old target direction
After Fix: Combat rotation cleared on movement start, mobs return to normal AI-driven rotation
Damage Splat Positioning
Damage splats now use entity visual position instead of server position for accurate placement:
// From DamageSplatSystem.ts
// Prefer entity visual position over server position
// so damage splats appear where the mob visually is
const visualPos = entity.node?.position || entity.position;
This prevents damage splats from appearing at the mob’s server position when the client-side visual is interpolating to a different location.
Movement Packet Optimization
The server sends emote on tileMovementEnd instead of redundant entityModified (PR #884):
// Replaces redundant entityModified with emote on tileMovementEnd packet
this.sendToNearby(position, "tileMovementEnd", {
entityId: playerId,
position: finalPosition,
emote: "idle",
});
Movement Cancellation:
cancelMovement now sends tileMovementEnd so client TileInterpolator properly stops interpolating:
// Send tileMovementEnd so client TileInterpolator stops interpolating old path
this.sendToNearby(position, "tileMovementEnd", {
entityId: playerId,
position: currentPosition,
emote: state.isRunning ? "run" : "walk",
});
Before Fix: entityModified sent on cancel, client kept interpolating old path
After Fix: tileMovementEnd sent, client properly stops interpolation
Duel Arena Combat
AI Combat Timing
The DuelCombatAI system manages automated agent duels with simplified attack logic (commit 51453da):
// From DuelCombatAI.ts
// The combat system's auto-attack loop (processPlayerCombatTick →
// processAutoAttackOnTick) drives the actual attack cadence once combat is
// established. The AI only needs to (re-)engage when combat has dropped
// or the target has changed.
private async tryAttack(
state: EmbeddedGameState,
_phase: CombatPhase,
): Promise<void> {
const needsEngagement =
!state.inCombat || state.currentTarget !== this.opponentId;
if (needsEngagement) {
try {
await this.service.executeAttack(this.opponentId);
this.attacksLanded++;
} catch (err) {
console.debug(`[DuelCombatAI] Attack failed:`, errMsg(err));
}
}
}
Key Changes (commit 51453da):
- Removed redundant attack-speed tracking: The combat system’s auto-attack loop already drives attack cadence
- Simplified AI logic: AI only re-engages when combat drops or target changes
- Fixed 2H sword attacks: Previously, manual attack-speed tracking competed with the combat system’s auto-attack loop, causing attacks to be silently dropped (especially for slow weapons like 2H swords)
- Added TWO_HAND_SWORD default style: Missing default attack style for two-handed swords added to
WeaponStyleConfig.ts
Why This Works:
- Combat system’s
processAutoAttackOnTick handles all attack timing once combat is established
- Calling
executeAttack on every cooldown cycle creates a redundant second driver
- Both drivers compete for the same cooldown slot, silently dropping attacks
- AI now only calls
executeAttack when combat needs to be (re-)established
Attack Style Configuration:
All weapon types now have complete default attack style mappings in WeaponStyleConfig.ts:
// From WeaponStyleConfig.ts
export const DEFAULT_ATTACK_STYLES: Record<WeaponType, CombatStyle> = {
[WeaponType.SWORD]: \"slash\",
[WeaponType.SCIMITAR]: \"slash\",
[WeaponType.AXE]: \"slash\",
[WeaponType.MACE]: \"crush\",
[WeaponType.DAGGER]: \"stab\",
[WeaponType.SPEAR]: \"stab\",
[WeaponType.HALBERD]: \"slash\",
[WeaponType.TWO_HAND_SWORD]: \"slash\", // Added in commit 51453da
[WeaponType.UNARMED]: \"crush\",
[WeaponType.BOW]: \"accurate\",
[WeaponType.CROSSBOW]: \"accurate\",
[WeaponType.STAFF]: \"accurate\",
[WeaponType.WAND]: \"accurate\",
};
This ensures all weapon types have a valid default style, preventing undefined behavior when AI agents or players use weapons without explicit style selection.
Teleport Suppression
Duel arena teleports can be suppressed to prevent visual effects during fight cleanup:
// From DuelCombatResolver.ts
// Suppress teleport effects for proximity corrections and cleanup teleports
this.teleportPlayer(playerId, position, {
suppressEffect: true // No visual effect for cleanup teleports
});
Use Cases:
- Fight-start HP sync:
restoreHealth() with quiet param skips PLAYER_RESPAWNED/PLAYER_SET_DEAD events
- Proximity corrections: Teleports to fix position without visual disruption
- Cleanup teleports: Return players to lobby without teleport animation
Cycle Cleanup Chaining
The duel scheduler chains cleanup → delay → new cycle via .finally() to prevent stale avatars:
// From StreamingDuelScheduler.ts
private async endCycle(): Promise<void> {
await this.cleanup()
.finally(async () => {
await this.sleep(INTER_CYCLE_DELAY_MS);
await this.startNewCycle();
});
}
Benefits:
- Cleanup always teleports both agents (even if errors occur)
- Delay ensures clean state before next cycle
- Prevents stale avatars from previous fights
Countdown Overlay
The countdown overlay stays mounted 2.5s into FIGHTING phase with fade-out animation:
// From CountdownOverlay.tsx
// Stay mounted 2.5s into FIGHTING phase with fade-out
const shouldShow = phase === 'COUNTDOWN' ||
(phase === 'FIGHTING' && timeSinceFightStart < 2500);
// Fade-out animation (opacity + scale)
<div style={{
opacity: phase === 'FIGHTING' ? 0 : 1,
transform: phase === 'FIGHTING' ? 'scale(1.2)' : 'scale(1)',
transition: 'opacity 0.5s, transform 0.5s',
}}>
FIGHT!
</div>
Arena Visuals
Fence Design:
- Replaced solid walls with fence posts + rails for better visibility
- Allows spectators to see into the arena
- Maintains collision boundaries
Floor Textures:
- Procedural sandstone tile pattern for OSRS medieval aesthetic
- Each arena gets unique randomized texture with grout lines, color variation, and speckle noise
- Canvas-generated at runtime (no texture files needed)
Lighting:
- Lit torches with fire particles at all 4 corners of each arena
- PointLights with flicker animation
- “torch” glow preset (6 riseSpread particles per torch, tight 0.08 spread)
Health Bar Synchronization
Health bars are synchronized inline in handleEntityDamaged before every broadcast:
// From DuelCombatResolver.ts
// Inline HP sync before broadcast
private handleEntityDamaged(event: EntityDamagedEvent): void {
this.updateContestantHp(event.entityId, event.remainingHealth);
// Broadcast to spectators
this.broadcastToSpectators({
type: 'ENTITY_DAMAGED',
data: event,
});
}
Benefits:
- Health bars always reflect current HP
- No race conditions between damage and HP updates
- Spectators see accurate health in real-time
AI Agent Trash Talk System
The DuelCombatAI system includes an integrated trash talk feature that allows AI agents to taunt opponents during combat:
Trash Talk Triggers
Health Threshold Taunts: Triggered when HP crosses specific milestones (75%, 50%, 25%, 10%):
// From DuelCombatAI.ts
const TRASH_TALK_THRESHOLDS = [75, 50, 25, 10] as const;
// Check if own or opponent health crossed a threshold
private checkHealthMilestones(
healthPct: number,
prevHealthPct: number,
opponentData: OpponentData | null,
prevOpponentHealthPct: number,
): void {
// Own health thresholds
for (const threshold of TRASH_TALK_THRESHOLDS) {
if (
healthPct <= threshold &&
prevHealthPct > threshold &&
!this.firedOwnThresholds.has(threshold)
) {
this.firedOwnThresholds.add(threshold);
this.fireTrashTalk("own_low", situation, healthPct, opponentData);
return; // One per tick maximum
}
}
// Opponent health thresholds
// ... similar logic for opponent HP
}
Ambient Periodic Taunts: Random taunts every 15-25 ticks to add personality:
const AMBIENT_TAUNT_MIN_TICKS = 15;
const AMBIENT_TAUNT_MAX_TICKS = 25;
// Schedule next ambient taunt
this.nextAmbientTauntTick =
this.tickCount +
AMBIENT_TAUNT_MIN_TICKS +
Math.floor(Math.random() * (AMBIENT_TAUNT_MAX_TICKS - AMBIENT_TAUNT_MIN_TICKS));
LLM-Generated Taunts
When an Eliza runtime is available, trash talk uses the agent’s character personality:
// Pull character bio/personality from Eliza agent runtime
const character = this.runtime.character;
const bioText = character?.bio
? Array.isArray(character.bio)
? character.bio.slice(0, 3).join(" ")
: String(character.bio).slice(0, 200)
: "";
const styleHints = character?.style?.all?.slice(0, 3).join(", ") || "";
const prompt = [
`You are ${this.agentName} in a PvP duel${this.opponentName ? ` against ${this.opponentName}` : ""}.`,
bioText ? `Your personality: ${bioText}` : "",
styleHints ? `Your communication style: ${styleHints}` : "",
`Your HP: ${healthPct.toFixed(0)}%. Opponent HP: ${oppPctStr}.`,
`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.`,
].filter(Boolean).join("\\n");
// Use TEXT_SMALL model with 30-token limit, temperature 0.9
const response = await this.runtime.useModel(ModelType.TEXT_SMALL, {
prompt,
maxTokens: 30,
temperature: 0.9,
});
LLM Timeout Handling: 3-second timeout with scripted fallback:
const LLM_TIMEOUT_MS = 3000;
// Race LLM call against timeout
Promise.race([llmPromise, timeoutPromise])
.then((response) => {
const text = (typeof response === "string" ? response : "")
.trim()
.replace(/^[\"']|[\"']$/g, "");
if (text && text.length <= 60) {
sendChat(text);
}
})
.catch(() => {
// On failure, use scripted fallback
const msg = pool[Math.floor(Math.random() * pool.length)];
sendChat(msg);
});
Scripted Fallback Taunts
When no LLM runtime is available or LLM calls fail, the system uses pre-written taunt pools:
const FALLBACK_TAUNTS_OWN_LOW = [
"Not even close!",
"I've had worse",
"Is that all?",
"Still standing",
"Come on then!",
"You call that damage?",
];
const FALLBACK_TAUNTS_OPPONENT_LOW = [
"GG soon",
"You're done!",
"Sit down",
"One more hit...",
"Almost there!",
"Easy money",
];
const FALLBACK_TAUNTS_AMBIENT = [
"Let's go!",
"Fight me!",
"Too slow",
"Bring it",
"Nice try lol",
"*yawns*",
"Is this PvP?",
"Warming up",
];
Fire-and-Forget Architecture
All trash talk calls are background/non-blocking to prevent combat tick delays:
const TRASH_TALK_COOLDOWN_MS = 8_000; // 8 seconds between messages
// Check cooldown and in-flight status
if (now - this.lastTrashTalkTime < TRASH_TALK_COOLDOWN_MS) return;
if (this._trashTalkInFlight) return;
// Mark in-flight and update timestamp
this._trashTalkInFlight = true;
this.lastTrashTalkTime = Date.now();
// Fire LLM call in background (never blocks tick)
Promise.race([llmPromise, timeoutPromise])
.then(/* ... */)
.finally(() => {
this._trashTalkInFlight = false;
});
Key Features:
- Never blocks combat tick loop
- 8-second cooldown prevents spam
- In-flight flag prevents overlapping LLM calls
- Failures are silent (trash talk is optional flavor)
Combat Role System
The duel system supports three combat roles with automatic gear provisioning and weighted random selection (added in PR #933, Feb 2026):
Combat Roles:
- Melee (50% weight): Bronze weapons (longsword, scimitar, 2h sword)
- Ranged (25% weight): Shortbow + bronze arrows (500 qty), uses “rapid” attack style
- Mage (25% weight): Staff of air + wind strike autocast + runes (500 mind, 500 air)
Role Selection:
// From DuelOrchestrator.ts
const DUEL_COMBAT_ROLE_WEIGHTS: Record<DuelCombatRole, number> = {
melee: 50,
ranged: 25,
mage: 25,
};
// Weighted random selection
pickCombatRole(): DuelCombatRole {
const totalWeight = 100;
let roll = Math.random() * totalWeight;
for (const [role, weight] of Object.entries(DUEL_COMBAT_ROLE_WEIGHTS)) {
roll -= weight;
if (roll <= 0) return role;
}
return "melee";
}
DuelCombatAI Adaptation:
- Melee: Uses existing phase-based style switching (aggressive/controlled/defensive)
- Ranged: Forces “rapid” style for faster attack speed (-1 tick), skips melee style switching
- Mage: Skips style switching entirely (magic auto-casts via selectedSpell)
// From DuelCombatAI.ts
// Mage agents skip style switching — magic auto-casts via selectedSpell
if (this.config.combatRole === "mage") return;
// Ranged agents use "rapid" for faster attack speed
const desiredStyle =
this.config.combatRole === "ranged"
? "rapid"
: this.strategy.attackStyle || "aggressive";
Gear Lifecycle:
- Pre-duel: Role selected → gear equipped → food filled → health restored
- During duel: Combat AI adapts behavior based on assigned role
- Post-duel: Gear removed → runes removed → food removed → health restored → teleport back
Gear Provisioning Methods:
equipMeleeWeapon(): Random bronze weapon from pool (longsword, scimitar, 2h sword)
equipRangedGear(): Shortbow + bronze arrows (500 qty)
equipMageGear(): Staff of air + wind strike autocast + runes (500 mind, 500 air)
cleanupAgentCombatSetup(): Unequips all combat gear, clears autocast, removes leftover runes
Weapon Pool Filtering:
Only weapons with new models in swords/ directory are eligible for duel arenas:
const DUEL_WEAPON_TYPES = new Set(["LONGSWORD", "SCIMITAR", "TWO_HAND_SWORD"]);
const DUEL_BRONZE_WEAPON_IDS = [
"bronze_longsword",
"bronze_scimitar",
"bronze_2h_sword",
];
Weapon Type Propagation (PR #934, commit 029456255, Feb 25, 2026)
The combat system now propagates weapon type through DuelOrchestrator into startCombat so correct attack speeds are used:
// Resolve weapon type from combat role
const roleToWeaponType = (role: DuelCombatRole): AttackType => {
switch (role) {
case "mage":
return AttackType.MAGIC;
case "ranged":
return AttackType.RANGED;
default:
return AttackType.MELEE;
}
};
// Pass weaponType to startCombat
combatSystem.startCombat(agent1Id, agent2Id, {
attackerType: "player",
targetType: "player",
weaponType: weaponType1, // Ensures correct attack speed
});
Critical Fixes (PR #934, commit 029456255, Feb 25, 2026):
1. Keep-Alive Re-Engagement (2H Sword Fix):
- Problem: Entity data flags (
inCombat, combatTarget) can be stale when CombatSystem’s internal state has timed out. Agents would stand idle instead of attacking.
- Fix: DuelCombatAI now periodically re-engages every 5 ticks (~3s) as a keep-alive, even when entity flags show combat is active
- Impact: 2H sword attacks now fire reliably, agents no longer idle during combat
2. Rune Inventory Readiness Polling:
- Problem: Adding runes before inventory loaded from DB caused silent rune loss.
getOrCreateInventory returned a disposable placeholder (not stored in Map).
- Fix: Wait up to 2 seconds for
inventorySystem.isInventoryReady(playerId) before adding runes
- Impact: Mage agents now reliably receive runes, magic attacks work consistently
3. Combat Timeout Refresh:
- Problem: Ranged/magic attacks didn’t refresh combat timeout, causing combat to expire after 16 ticks even during active fighting
- Fix: Both
CombatSystem and CombatTickProcessor now refresh combatEndTick after ranged/magic attacks
- Impact: Combat stays active during ranged/magic fights, no premature timeout
4. PvP Zone Bypass for Streaming Duels:
- Problem: Streaming duel agents couldn’t fight in safe zones due to PvP zone checks
- Fix: Bypass PvP zone checks when
entity.data.inStreamingDuel === true
- Impact: Streaming duels work in any zone, not just wilderness
5. Safe Zone Aggro Block:
- Problem: Hostile mobs would aggro and chase players in safe zones
- Fix:
AggroSystem now checks ZoneDetectionSystem.isSafeZone() before aggroing or chasing
- Impact: Safe zones are truly safe, mobs won’t attack players there
Combat State Starvation Guard (PR #934)
The system now guards against state starvation from repeated startCombat resets on slow weapons:
// Don't replace existing combat state if agent already has valid state
const hasValidState = (attackerId: string, targetId: string): boolean => {
if (!combatSystem.getCombatData || !combatSystem.isInCombat) return false;
if (!combatSystem.isInCombat(attackerId)) return false;
const state = combatSystem.getCombatData(attackerId);
return !!(state?.inCombat && String(state.targetId) === targetId);
};
if (!hasValidState(agent1Id, agent2Id)) {
// Only call startCombat if no valid state exists
combatSystem.startCombat(agent1Id, agent2Id, { weaponType: weaponType1 });
}
Why This Matters:
createAttackerState replaces the state Map entry which resets nextAttackTick
- For slow weapons (2H swords, attackSpeed 7), repeated re-engagement keeps pushing
nextAttackTick forward
- Auto-attack loop never reaches
nextAttackTick (starvation pattern)
- Guard prevents replacing valid combat state, allowing auto-attacks to fire
Critical Bug Fixes
Combat State Key Mismatch (Fixed in PR #933):
- Issue: CombatStateService syncs abbreviated keys (
data.c/data.ct) but getGameState() only read full keys (data.inCombat/data.combatTarget)
- Impact: DuelCombatAI always saw
inCombat=false and flooded executeAttack every tick instead of letting auto-attacks drive combat
- Fix: EmbeddedHyperscapeService now reads both abbreviated and full keys:
inCombat: !!(data.inCombat || data.combatTarget || data.c || data.ct),
currentTarget: (data.combatTarget as string) || (data.ct as string) || null,
- File:
packages/server/src/eliza/EmbeddedHyperscapeService.ts
- Commit: 82ff784 (Feb 25, 2026)
Magic Attack TOCTOU Race (Fixed in PR #933):
- Issue: Cooldown was checked early but claimed after async
consumeRunesForSpell call. With the combat state bug flooding attacks, two concurrent invocations could both pass the cooldown check before either claimed it
- Impact: Duplicate magic projectiles, double rune consumption
- Fix: Moved cooldown claim and
enterCombat before async rune consumption to close the race window
- File:
packages/shared/src/systems/shared/combat/handlers/MagicAttackHandler.ts
- Commit: 82ff784 (Feb 25, 2026)
Duel Arena Terrain Sinking (Fixed in PR #911):
- Issue: Players/agents were sinking ~0.4m into duel arena floors because flat zones were removed from the terrain system, causing
getHeightAt() to return raw procedural terrain height instead of floor-level height
- Impact: Players appeared to sink through arena floors, grass grew through floor surfaces
- Fix: DuelArenaVisualsSystem now registers flat zones programmatically for all 8 floor areas (6 arenas + lobby + hospital) so terrain height queries return correct floor-level values
- File:
packages/shared/src/systems/client/DuelArenaVisualsSystem.ts
- Commit: 7a60135 (Feb 25, 2026)
Duel Arena Click Targeting (Fixed in commit 24354238):
- Issue: Click targets were going underground in duel arenas due to building footprint validation rejecting arena floor raycast hits
- Impact: Players couldn’t click to move within duel arenas
- Fix: RaycastService now skips building footprint validation for arena-floor raycast hits
- File:
packages/shared/src/systems/client/interaction/services/RaycastService.ts
- Commit: 2435423 (Feb 24, 2026)
Duel Arena Minimap Rendering (Fixed in commit 24354238):
- Issue: Minimap showed duel arenas as black holes because arena/lobby/hospital floor meshes had layer 0 disabled
- Impact: Minimap was unusable in duel arena area
- Fix: Enabled layer 0 on arena/lobby/hospital floor meshes so minimap camera can render them
- File:
packages/shared/src/systems/client/DuelArenaVisualsSystem.ts
- Commit: 2435423 (Feb 24, 2026)
Wall Sconce Cleanup (commit 24354238):
- Issue: 96 dead wall sconce meshes across 6 arenas with no lights attached
- Impact: Unnecessary geometry in scene, no visual benefit
- Fix: Removed non-functional wall sconce geometry from arena fences
- File:
packages/shared/src/systems/client/DuelArenaVisualsSystem.ts
- Commit: 2435423 (Feb 24, 2026)
Integration with DuelOrchestrator
The trash talk system is wired into the combat AI via a callback:
// From DuelOrchestrator.ts
const sendChatMessage = (text: string) => {
try {
this.agentService.executeChat(agentId, text);
} catch (err) {
// Swallow — chat failure must not break combat
}
};
// Pass callback to DuelCombatAI
const combatAI = new DuelCombatAI(
service,
opponentId,
config,
runtime,
sendChatMessage, // Trash talk callback
);
Social System Update: CHAT_MESSAGE action now allowed during combat (previously blocked).