Skip to main content

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

  // Attack speeds (in ticks)
  DEFAULT_ATTACK_SPEED_TICKS: 4,      // 2.4 seconds (standard sword)
  FAST_ATTACK_SPEED_TICKS: 3,         // 1.8 seconds (scimitar, dagger)
  SLOW_ATTACK_SPEED_TICKS: 6,         // 3.6 seconds (2H sword)

  // 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
  },

  // 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

  // Food consumption (OSRS-accurate)
  EAT_DELAY_TICKS: 3,                 // 1.8s cooldown between eating
  EAT_ATTACK_DELAY_TICKS: 3,          // Attack delay when eating during combat
  MAX_HEAL_AMOUNT: 99,                // Maximum heal per food item
};

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:
  1. Player attacks with longsword (4-tick weapon)
  2. Attack lands at tick 100, next attack at tick 104
  3. Player eats at tick 102 (while on cooldown)
  4. Eat delay adds 3 ticks: next attack now at tick 107
  5. If player eats at tick 104+ (weapon ready), no delay added

Healing Formula

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.
StyleXP DistributionBonus
AccurateAttack only+3 Attack
AggressiveStrength only+3 Strength
DefensiveDefense only+3 Defense
ControlledAll 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 },
};

Combat Style Icons

Each combat style has a unique SVG icon with active state colors:
StyleIconActive ColorDescription
AccurateTarget/bullseyeRed (#ef4444)Concentric circles
AggressiveDouble chevronsGreen (#22c55e)Power attack
DefensiveShieldBlue (#3b82f6)Protection
ControlledBalance symbolPurple (#a855f7)Balanced training

Action Bar Integration

Combat styles can be dragged from the combat panel to the action bar for quick switching:
// From packages/client/src/game/panels/CombatPanel.tsx
<DraggableCombatStyleButton
  style="accurate"
  icon={<AccurateIcon />}
  active={activeStyle === "accurate"}
/>

// Action bar slot handles combat style drops
if (dragData.type === "combatstyle") {
  setSlot(slotIndex, {
    type: "combatstyle",
    combatStyleId: dragData.combatStyleId,
  });
}

// Clicking combat style slot switches attack style
if (slot.type === "combatstyle") {
  world.network.send("changeCombatStyle", {
    style: slot.combatStyleId,
  });
}
Action Bar Features:
  • Drag combat styles from combat panel
  • Click to switch attack style
  • Visual highlight when style is active
  • Supports 4-12 customizable slots (default: 9)
  • Persistent across sessions

Damage Calculation

Damage uses the authentic OSRS formula from the wiki. Prayer bonuses are applied as multipliers to effective levels.

Maximum Hit Formula

// From CombatCalculations.ts
function calculateMaxHit(
  strengthLevel: number,
  strengthBonus: number,
  styleBonus: number,
  prayerMultiplier: number = 1.0,  // NEW: Prayer bonus
): number {
  // Effective Strength = floor((Strength Level + 8 + Style Bonus) × Prayer Multiplier)
  const effectiveStrength = Math.floor(
    (strengthLevel + 8 + styleBonus) * prayerMultiplier
  );

// Apply prayer bonuses (NEW in PR #563)
const prayerBonuses = getPrayerBonuses(attacker);
const prayerMultiplier = prayerBonuses.strength ?? 1;
const effectiveStrengthWithPrayer = Math.floor(effectiveStrength * prayerMultiplier);

// 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 + (effectiveStrengthWithPrayer * (strengthBonus + 64)) / 640);
Prayer bonuses are applied to effective levels before damage calculation. For example, Burst of Strength (+5%) multiplies effective strength by 1.05.

Accuracy Formula

// From CombatCalculations.ts
function calculateAccuracy(
  attackerAttackLevel: number,
  attackerAttackBonus: number,
  targetDefenseLevel: number,
  targetDefenseBonus: number,
  attackerStyle: CombatStyle = "accurate",
  attackerPrayerBonuses?: PrayerBonuses,  // NEW in PR #563
  defenderPrayerBonuses?: PrayerBonuses,  // NEW in PR #563
): boolean {
  // Apply prayer bonuses to effective levels
  const prayerAttackMult = attackerPrayerBonuses?.attack ?? 1;
  const prayerDefenseMult = defenderPrayerBonuses?.defense ?? 1;
  
  const effectiveAttack = Math.floor((attackerAttackLevel + 8 + styleBonus.attack) * prayerAttackMult);
  const attackRoll = effectiveAttack * (attackerAttackBonus + 64);

  const effectiveDefence = Math.floor((targetDefenseLevel + 9 + defenderStyleBonus.defense) * prayerDefenseMult);
  const defenceRoll = effectiveDefence * (targetDefenseBonus + 64);

  let hitChance: number;
  if (attackRoll > defenceRoll) {
    hitChance = 1 - (defenceRoll + 2) / (2 * (attackRoll + 1));
  } else {
    hitChance = attackRoll / (2 * (defenceRoll + 1));
  }

  return random.random() < hitChance;
}
Prayer bonuses from active prayers (e.g., Clarity of Thought +5% attack, Thick Skin +5% defense) are applied to effective levels before calculating attack and defense rolls.

Damage Roll

// If hit succeeds, roll damage from 0 to maxHit
const damage = didHit ? rng.damageRoll(maxHit) : 0;

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;
}

Ranged Combat

Ranged attacks use Chebyshev distance and require:
  • A ranged weapon (bow)
  • Ammunition (arrows)
// 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 TypeSpeed (ticks)Speed (seconds)
Scimitar31.8s
Longsword42.4s
Battleaxe53.0s
2H Sword63.6s
Shortbow31.8s
Longbow53.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:
  1. Headstone spawns at death location
  2. Items drop to headstone (kept for 15 minutes)
  3. Player respawns at starter town
  4. 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);
}

Mob Death

When a mob dies:
  1. Loot drops based on drop table
  2. XP granted to all attackers
  3. Respawn timer starts (based on mob type)
  4. Entity destroyed after death animation

XP Distribution

XP is granted based on damage dealt and combat style.
// 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);
}

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:
EventDataDescription
COMBAT_ATTACKattackerId, targetId, damage, didHitAttack executed
COMBAT_KILLattackerId, targetId, damageDealt, attackStyleKill confirmed
COMBAT_STARTEDentityId, targetIdEntity entered combat
COMBAT_ENDEDentityIdEntity left combat
ENTITY_DAMAGEDentityId, damage, sourceId, remainingHealthDamage taken
ENTITY_DEATHentityId, killerId, positionEntity died
PLAYER_HEALTH_UPDATEDplayerId, health, maxHealthHealth changed (healing, damage)


Duel Arena Integration

The combat system integrates with the Duel Arena for PvP combat:

Rule Enforcement

The DuelSystem provides APIs for rule checking:
const duelSystem = world.getSystem("duel");

// Check if player can use specific combat types
if (duelSystem && !duelSystem.canUseRanged(attackerId)) {
  return; // Block ranged attack in duel
}

if (duelSystem && !duelSystem.canUseMelee(attackerId)) {
  return; // Block melee attack in duel
}

if (duelSystem && !duelSystem.canEatFood(playerId)) {
  return; // Block food consumption in duel
}

Death Handling

Duel deaths are handled differently than normal deaths:
// From PlayerDeathSystem.ts
const duelSystem = this.world.getSystem("duel");
if (duelSystem?.isPlayerInActiveDuel(playerId)) {
  // Duel death - DuelSystem handles resolution
  // No items lost, no headstone, winner gets stakes
  return;
}

// Normal death - respawn at hospital with item loss
this.handleNormalDeath(playerId);
See the Duel Arena documentation for complete PvP mechanics.