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:
- 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.
| 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 },
};
Combat Style Icons
Each combat style has a unique SVG icon with active state colors:
| Style | Icon | Active Color | Description |
|---|
| Accurate | Target/bullseye | Red (#ef4444) | Concentric circles |
| Aggressive | Double chevrons | Green (#22c55e) | Power attack |
| Defensive | Shield | Blue (#3b82f6) | Protection |
| Controlled | Balance symbol | Purple (#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.
// 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.
// 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 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);
}
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.
// 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:
| 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) |
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);