Skip to content

Combat System

This document covers the combat and stats system for RentEarth, including health, mana, energy pools, movement modifiers, and combat mechanics.

┌─────────────────────────────────────────────────────────────────┐
│ ENTITY STATS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ❤️ HP (Health Points) │
│ • Determines survival │
│ • Depleted by damage │
│ • Regenerates slowly over time │
│ • Death at 0 HP │
│ │
│ 💙 MP (Mana Points) │
│ • Powers magical abilities │
│ • Consumed by spells/skills │
│ • Regenerates over time │
│ • Can use potions to restore │
│ │
│ ⚡ EP (Energy Points) │
│ • Powers physical actions │
│ • Consumed by sprint, dodge, attacks │
│ • Fast regeneration when idle │
│ • Slow regeneration during combat │
│ │
└─────────────────────────────────────────────────────────────────┘
ComponentStatusDescription
Base Movement✅ CompleteServer-authoritative 5.0 units/sec
Diagonal Normalization✅ CompleteConsistent speed in all directions
Input Queue✅ CompleteAnti-spam protection (max 10)
Protobuf Transport✅ CompleteBinary network format
Sprint Modifier🔲 PlannedHold shift for 1.5x speed
Per-Entity Speed🔲 PlannedSpeed powerups/debuffs
HP System🔲 PlannedHealth points with regen
MP System🔲 PlannedMana for abilities
EP System🔲 PlannedEnergy for physical actions
Client-Side Prediction🔲 AdvancedSub-frame responsiveness

Location: website/axum/src/game/input_processor.rs

const MOVE_SPEED: f32 = 5.0; // Units per second
const TICK_DURATION_SECS: f32 = 0.1; // 10 Hz tick rate (100ms per tick)
// Result: 0.5 units per tick
  • Server-authoritative - All movement validated server-side
  • Diagonal normalization - Consistent speed in all directions
  • Delta-time based - MOVE_SPEED * TICK_DURATION_SECS
  • Anti-spam - Max 10 queued inputs

Server (entity_state.rs):

/// Core stats for all entities (players, NPCs, enemies)
pub struct EntityStats {
// Health
pub hp_current: u32,
pub hp_max: u32,
pub hp_regen_per_sec: f32,
// Mana
pub mp_current: u32,
pub mp_max: u32,
pub mp_regen_per_sec: f32,
// Energy
pub ep_current: u32,
pub ep_max: u32,
pub ep_regen_per_sec: f32,
// Movement
pub move_speed_base: f32, // Default: 5.0
pub move_speed_multiplier: f32, // Default: 1.0
}
impl Default for EntityStats {
fn default() -> Self {
Self {
hp_current: 100,
hp_max: 100,
hp_regen_per_sec: 1.0,
mp_current: 50,
mp_max: 50,
mp_regen_per_sec: 2.0,
ep_current: 100,
ep_max: 100,
ep_regen_per_sec: 10.0, // Fast regen when idle
move_speed_base: 5.0,
move_speed_multiplier: 1.0,
}
}
}

Client (EntityStats.cs):

using System;
using UnityEngine;
using R3;
namespace RentEarth.Entity
{
/// <summary>
/// Reactive entity stats - UI automatically updates when values change.
/// Uses R3 ReactiveProperty for efficient UI binding.
/// </summary>
[Serializable]
public class EntityStats
{
// Health
public ReactiveProperty<int> HP { get; } = new(100);
public ReactiveProperty<int> MaxHP { get; } = new(100);
public float HPRegenPerSec = 1.0f;
// Mana
public ReactiveProperty<int> MP { get; } = new(50);
public ReactiveProperty<int> MaxMP { get; } = new(50);
public float MPRegenPerSec = 2.0f;
// Energy
public ReactiveProperty<int> EP { get; } = new(100);
public ReactiveProperty<int> MaxEP { get; } = new(100);
public float EPRegenPerSec = 10.0f;
// Computed properties
public float HPPercent => MaxHP.Value > 0 ? (float)HP.Value / MaxHP.Value : 0f;
public float MPPercent => MaxMP.Value > 0 ? (float)MP.Value / MaxMP.Value : 0f;
public float EPPercent => MaxEP.Value > 0 ? (float)EP.Value / MaxEP.Value : 0f;
public bool IsDead => HP.Value <= 0;
public bool HasMana(int cost) => MP.Value >= cost;
public bool HasEnergy(int cost) => EP.Value >= cost;
}
}

Proto (snapshot.proto):

message EntityStatsUpdate {
bytes entity_id = 1;
uint32 hp_current = 2;
uint32 hp_max = 3;
uint32 mp_current = 4;
uint32 mp_max = 5;
uint32 ep_current = 6;
uint32 ep_max = 7;
}

Server-side tick processing:

impl EntityStats {
/// Called every server tick to regenerate stats
pub fn tick_regen(&mut self, delta_secs: f32, is_in_combat: bool) {
// HP regeneration (slower in combat)
let hp_regen = if is_in_combat {
self.hp_regen_per_sec * 0.25 // 25% regen in combat
} else {
self.hp_regen_per_sec
};
self.hp_current = (self.hp_current as f32 + hp_regen * delta_secs)
.min(self.hp_max as f32) as u32;
// MP regeneration (constant)
self.mp_current = (self.mp_current as f32 + self.mp_regen_per_sec * delta_secs)
.min(self.mp_max as f32) as u32;
// EP regeneration (fast when idle, slow in combat)
let ep_regen = if is_in_combat {
self.ep_regen_per_sec * 0.5 // 50% regen in combat
} else {
self.ep_regen_per_sec
};
self.ep_current = (self.ep_current as f32 + ep_regen * delta_secs)
.min(self.ep_max as f32) as u32;
}
}

Sprint allows faster movement at the cost of Energy Points.

Protobuf (snapshot.proto):

message PlayerInput {
// ... existing fields ...
uint32 action_flags = 5;
}
// Action flags (bitfield)
// 0x01 = SPRINT
// 0x02 = CROUCH (future)
// 0x04 = JUMP (future)

Server (input_processor.rs):

const ACTION_FLAG_SPRINT: u32 = 0x01;
const SPRINT_SPEED_MULTIPLIER: f32 = 1.5;
const SPRINT_EP_COST_PER_SEC: f32 = 20.0;
fn process_input(&mut self, player_id: Uuid, input: &PlayerInput, stats: &mut EntityStats) {
let is_sprinting = (input.action_flags & ACTION_FLAG_SPRINT) != 0;
// Check if can sprint (has energy)
let can_sprint = is_sprinting && stats.ep_current > 0;
let speed_multiplier = if can_sprint {
// Consume energy
let ep_cost = (SPRINT_EP_COST_PER_SEC * TICK_DURATION_SECS) as u32;
stats.ep_current = stats.ep_current.saturating_sub(ep_cost);
SPRINT_SPEED_MULTIPLIER
} else {
1.0
};
let move_amount = MOVE_SPEED * TICK_DURATION_SECS * speed_multiplier * stats.move_speed_multiplier;
// Apply movement...
}

Client (InputCollector.cs):

using UnityEngine;
using RentEarth.Snapshot;
namespace RentEarth.Network
{
public class InputCollector : MonoBehaviour
{
private const uint ACTION_FLAG_SPRINT = 0x01;
private EntityStats _playerStats;
private PlayerInput CollectInput()
{
var input = new PlayerInput();
// Movement direction
input.MoveX = Input.GetAxisRaw("Horizontal");
input.MoveY = Input.GetAxisRaw("Vertical");
// Sprint modifier
if (Input.GetKey(KeyCode.LeftShift) && _playerStats.HasEnergy(1))
{
input.ActionFlags |= ACTION_FLAG_SPRINT;
}
return input;
}
}
}

Support for buffs/debuffs that affect movement speed.

Server (entity_state.rs):

pub struct EntityData {
pub position: Position,
pub rotation: Rotation,
pub stats: EntityStats,
pub status_effects: Vec<StatusEffect>,
}
pub struct StatusEffect {
pub effect_type: StatusEffectType,
pub duration_remaining: f32,
pub magnitude: f32,
}
pub enum StatusEffectType {
SpeedBoost, // +magnitude% speed
SpeedSlow, // -magnitude% speed
Stun, // Cannot move
Root, // Cannot move, can attack
}
impl EntityData {
pub fn get_effective_speed(&self) -> f32 {
let mut multiplier = self.stats.move_speed_multiplier;
for effect in &self.status_effects {
match effect.effect_type {
StatusEffectType::SpeedBoost => multiplier += effect.magnitude,
StatusEffectType::SpeedSlow => multiplier -= effect.magnitude,
StatusEffectType::Stun | StatusEffectType::Root => multiplier = 0.0,
}
}
self.stats.move_speed_base * multiplier.max(0.0)
}
}

For sub-frame responsiveness with server reconciliation.

Client (LocalPlayerMovement.cs):

using UnityEngine;
using System.Collections.Generic;
namespace RentEarth.Network
{
/// <summary>
/// Client-side prediction with server reconciliation.
/// Applies input locally for instant feedback, corrects with server state.
/// </summary>
public class LocalPlayerMovement : MonoBehaviour
{
[SerializeField] private float moveSpeed = 5.0f;
[SerializeField] private float correctionThreshold = 0.5f;
[SerializeField] private float correctionLerpSpeed = 10f;
private struct InputSnapshot
{
public uint Sequence;
public Vector3 Direction;
public float DeltaTime;
public Vector3 PredictedPosition;
}
private Queue<InputSnapshot> _pendingInputs = new();
private uint _inputSequence = 0;
private Vector3 _serverPosition;
void Update()
{
// Collect input
Vector3 moveDir = new Vector3(
Input.GetAxisRaw("Horizontal"),
0,
Input.GetAxisRaw("Vertical")
).normalized;
if (moveDir != Vector3.zero)
{
// Apply local prediction immediately
Vector3 movement = moveDir * moveSpeed * Time.deltaTime;
transform.position += movement;
// Store for reconciliation
_pendingInputs.Enqueue(new InputSnapshot
{
Sequence = ++_inputSequence,
Direction = moveDir,
DeltaTime = Time.deltaTime,
PredictedPosition = transform.position
});
// Send to server
SendMovementInput(moveDir, _inputSequence);
}
}
/// <summary>
/// Called when server snapshot arrives.
/// Reconciles local prediction with authoritative server state.
/// </summary>
public void OnServerSnapshot(Vector3 serverPosition, uint lastProcessedInput)
{
_serverPosition = serverPosition;
// Remove acknowledged inputs
while (_pendingInputs.Count > 0 && _pendingInputs.Peek().Sequence <= lastProcessedInput)
{
_pendingInputs.Dequeue();
}
// Check for prediction error
float error = Vector3.Distance(transform.position, serverPosition);
if (error > correctionThreshold)
{
// Significant error - snap to server position and replay unacknowledged inputs
transform.position = serverPosition;
// Replay pending inputs
foreach (var input in _pendingInputs)
{
transform.position += input.Direction * moveSpeed * input.DeltaTime;
}
}
else if (error > 0.01f)
{
// Minor error - smooth correction
transform.position = Vector3.Lerp(
transform.position,
serverPosition,
correctionLerpSpeed * Time.deltaTime
);
}
}
}
}

pub struct DamageEvent {
pub source: Uuid,
pub target: Uuid,
pub base_damage: u32,
pub damage_type: DamageType,
}
pub enum DamageType {
Physical,
Magical,
True, // Ignores armor/resistance
}
impl DamageEvent {
pub fn calculate_final_damage(&self, target_stats: &EntityStats) -> u32 {
match self.damage_type {
DamageType::Physical => {
// Reduced by armor
let reduction = target_stats.armor as f32 / (target_stats.armor as f32 + 100.0);
(self.base_damage as f32 * (1.0 - reduction)) as u32
}
DamageType::Magical => {
// Reduced by magic resistance
let reduction = target_stats.magic_resist as f32 / (target_stats.magic_resist as f32 + 100.0);
(self.base_damage as f32 * (1.0 - reduction)) as u32
}
DamageType::True => self.base_damage,
}
}
}
pub struct Ability {
pub id: u32,
pub name: String,
pub mp_cost: u32,
pub ep_cost: u32,
pub cooldown_secs: f32,
pub cast_time_secs: f32,
pub effect: AbilityEffect,
}
pub enum AbilityEffect {
Damage { base: u32, scaling: f32, damage_type: DamageType },
Heal { base: u32, scaling: f32 },
Buff { effect: StatusEffectType, duration: f32, magnitude: f32 },
Debuff { effect: StatusEffectType, duration: f32, magnitude: f32 },
}

Client (StatsBarUI.cs):

using UnityEngine;
using UnityEngine.UIElements;
using R3;
using System;
namespace RentEarth.UI
{
/// <summary>
/// Reactive stats bar UI - automatically updates when stats change.
/// </summary>
public class StatsBarUI : MonoBehaviour
{
private VisualElement _hpBar;
private VisualElement _mpBar;
private VisualElement _epBar;
private Label _hpLabel;
private Label _mpLabel;
private Label _epLabel;
private IDisposable _subscription;
public void Bind(EntityStats stats)
{
// Subscribe to stat changes with R3
_subscription = Observable.CombineLatest(
stats.HP,
stats.MaxHP,
stats.MP,
stats.MaxMP,
stats.EP,
stats.MaxEP,
(hp, maxHp, mp, maxMp, ep, maxEp) => (hp, maxHp, mp, maxMp, ep, maxEp)
).Subscribe(tuple =>
{
UpdateBar(_hpBar, _hpLabel, tuple.hp, tuple.maxHp, "HP");
UpdateBar(_mpBar, _mpLabel, tuple.mp, tuple.maxMp, "MP");
UpdateBar(_epBar, _epLabel, tuple.ep, tuple.maxEp, "EP");
});
}
private void UpdateBar(VisualElement bar, Label label, int current, int max, string prefix)
{
float percent = max > 0 ? (float)current / max * 100f : 0f;
bar.style.width = new Length(percent, LengthUnit.Percent);
label.text = $"{prefix}: {current}/{max}";
}
private void OnDestroy()
{
_subscription?.Dispose();
}
}
}

FilePurpose
website/axum/src/game/input_processor.rsServer movement & input processing
website/axum/src/game/world_runtime.rsWorld tick loop
unity/.../Network/InputCollector.csClient input collection
unity/.../Network/SnapshotInterpolator.csClient-side interpolation
proto/rentearth/snapshot.protoProtobuf message definitions

  • Add EntityStats struct to server
  • Add EntityStats class to client (R3 reactive)
  • Sync stats via protobuf
  • Basic HP/MP/EP UI bars
  • Add action_flags to PlayerInput protobuf
  • Implement sprint on server (EP consumption)
  • Add shift-to-sprint on client
  • Visual feedback (animation state)
  • Implement StatusEffect system
  • Speed modifiers (boost/slow)
  • Buff/debuff duration tracking
  • UI indicators for active effects
  • Damage events and calculation
  • Armor/resistance stats
  • Death handling
  • Respawn system
  • Ability definitions
  • MP/EP costs
  • Cooldowns
  • Cast times

Phase 6: Client-Side Prediction (Advanced)

Section titled “Phase 6: Client-Side Prediction (Advanced)”
  • Input buffering with sequence numbers
  • Server reconciliation
  • Prediction error correction
  • Smooth interpolation

  • Sprint increases speed by 1.5x
  • Sprint consumes EP correctly
  • Cannot sprint with 0 EP
  • Speed buffs/debuffs apply correctly
  • Stun/root prevents movement
  • HP regenerates over time
  • MP regenerates over time
  • EP regenerates (fast idle, slow combat)
  • Stats sync correctly to client
  • UI updates reactively
  • Damage reduces HP
  • Armor reduces physical damage
  • Magic resistance reduces magical damage
  • True damage ignores defenses
  • Death occurs at 0 HP

Entities (players, NPCs, enemies) have three interconnected state machines that drive behavior, visuals, and combat decisions.

┌─────────────────────────────────────────────────────────────────┐
│ ENTITY STATE HIERARCHY │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ │
│ │ EMOTIONAL STATE │ How the entity "feels" │
│ │ ────────────────│ • Affects behavior priorities │
│ │ Calm │ • Influences dialogue/reactions │
│ │ Alert │ • Modifies stat regeneration │
│ │ Angry │ • Changes visual appearance │
│ │ Afraid │ │
│ │ Happy │ │
│ └────────┬─────────┘ │
│ │ influences │
│ ▼ │
│ ┌──────────────────┐ │
│ │ BEHAVIOR STATE │ What the entity is doing │
│ │ ────────────────│ • Drives AI decision making │
│ │ Idle │ • Controls movement patterns │
│ │ Patrol │ • Determines target selection │
│ │ Chase │ • Affects animation state │
│ │ Flee │ │
│ │ Interact │ │
│ │ Return │ │
│ └────────┬─────────┘ │
│ │ triggers │
│ ▼ │
│ ┌──────────────────┐ │
│ │ COMBAT STATE │ Combat engagement status │
│ │ ────────────────│ • Determines valid actions │
│ │ OutOfCombat │ • Controls targeting │
│ │ Engaging │ • Modifies stat regen rates │
│ │ InCombat │ • Affects ability availability │
│ │ Disengaging │ │
│ │ Dead │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘

Emotional state represents the entity’s current mood and affects how they perceive and react to the world.

Server (entity_state.rs):

/// Emotional state affects behavior priorities and NPC reactions
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EmotionalState {
#[default]
Calm, // Normal state - standard behavior
Alert, // Heightened awareness - faster reactions
Angry, // Aggressive - prioritize attack, reduced flee threshold
Afraid, // Defensive - prioritize flee, avoid combat
Happy, // Friendly - bonus to positive interactions
Sad, // Withdrawn - reduced interaction, slower movement
Confused, // Disoriented - random behavior, reduced accuracy
}
impl EmotionalState {
/// Get behavior modifiers for this emotional state
pub fn get_modifiers(&self) -> EmotionalModifiers {
match self {
Self::Calm => EmotionalModifiers::default(),
Self::Alert => EmotionalModifiers {
detection_range_mult: 1.5,
reaction_time_mult: 0.7, // Faster reactions
..Default::default()
},
Self::Angry => EmotionalModifiers {
damage_mult: 1.2,
defense_mult: 0.9,
flee_threshold: 0.1, // Won't flee until 10% HP
aggro_range_mult: 1.3,
..Default::default()
},
Self::Afraid => EmotionalModifiers {
damage_mult: 0.8,
defense_mult: 1.1,
flee_threshold: 0.5, // Flee at 50% HP
movement_speed_mult: 1.2, // Run faster when scared
..Default::default()
},
Self::Happy => EmotionalModifiers {
interaction_bonus: 0.2,
hp_regen_mult: 1.2,
..Default::default()
},
Self::Sad => EmotionalModifiers {
movement_speed_mult: 0.8,
interaction_bonus: -0.2,
hp_regen_mult: 0.8,
..Default::default()
},
Self::Confused => EmotionalModifiers {
accuracy_mult: 0.7,
detection_range_mult: 0.6,
reaction_time_mult: 1.5, // Slower reactions
..Default::default()
},
}
}
}
#[derive(Debug, Clone, Default)]
pub struct EmotionalModifiers {
pub damage_mult: f32,
pub defense_mult: f32,
pub movement_speed_mult: f32,
pub detection_range_mult: f32,
pub reaction_time_mult: f32,
pub flee_threshold: f32,
pub aggro_range_mult: f32,
pub accuracy_mult: f32,
pub interaction_bonus: f32,
pub hp_regen_mult: f32,
}
impl Default for EmotionalModifiers {
fn default() -> Self {
Self {
damage_mult: 1.0,
defense_mult: 1.0,
movement_speed_mult: 1.0,
detection_range_mult: 1.0,
reaction_time_mult: 1.0,
flee_threshold: 0.25, // Default flee at 25% HP
aggro_range_mult: 1.0,
accuracy_mult: 1.0,
interaction_bonus: 0.0,
hp_regen_mult: 1.0,
}
}
}

Client (EmotionalState.cs):

namespace RentEarth.Entity
{
/// <summary>
/// Emotional state affects NPC behavior and visual appearance.
/// Synced from server, drives client-side visual feedback.
/// </summary>
public enum EmotionalState
{
Calm, // Default - neutral expression
Alert, // ! indicator, widened eyes
Angry, // Red tint, aggressive posture
Afraid, // Shaking, cowering
Happy, // Smile, bouncy movement
Sad, // Droopy, slow movement
Confused // ? indicator, swirly eyes
}
public static class EmotionalStateExtensions
{
/// <summary>
/// Get the visual indicator icon for this emotional state.
/// </summary>
public static string GetIndicatorIcon(this EmotionalState state) => state switch
{
EmotionalState.Alert => "!",
EmotionalState.Angry => "!!",
EmotionalState.Afraid => "...",
EmotionalState.Happy => "<3",
EmotionalState.Sad => ":'(",
EmotionalState.Confused => "?",
_ => ""
};
/// <summary>
/// Get the color tint for this emotional state.
/// </summary>
public static UnityEngine.Color GetTintColor(this EmotionalState state) => state switch
{
EmotionalState.Angry => new UnityEngine.Color(1f, 0.7f, 0.7f), // Red tint
EmotionalState.Afraid => new UnityEngine.Color(0.8f, 0.8f, 1f), // Blue tint
EmotionalState.Happy => new UnityEngine.Color(1f, 1f, 0.8f), // Yellow tint
EmotionalState.Sad => new UnityEngine.Color(0.7f, 0.7f, 0.8f), // Gray-blue tint
EmotionalState.Confused => new UnityEngine.Color(0.9f, 0.8f, 1f), // Purple tint
_ => UnityEngine.Color.white
};
}
}

Behavior state determines what the entity is currently doing and drives the AI decision loop.

Server (entity_state.rs):

/// Behavior state drives NPC/enemy AI decisions
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BehaviorState {
#[default]
Idle, // Standing still, looking around
Patrol, // Following patrol path
Chase, // Pursuing a target
Flee, // Running away from threat
Interact, // Engaging with object/NPC
Return, // Returning to home position
Wander, // Random movement within area
Guard, // Stationary, watching for threats
Follow, // Following another entity
Attack, // Executing attack action
}
impl BehaviorState {
/// Get the movement speed multiplier for this behavior
pub fn get_speed_multiplier(&self) -> f32 {
match self {
Self::Idle | Self::Guard => 0.0,
Self::Patrol | Self::Wander => 0.6,
Self::Return | Self::Follow => 0.8,
Self::Interact => 0.0,
Self::Chase => 1.2,
Self::Flee => 1.5,
Self::Attack => 0.3,
}
}
/// Can this behavior be interrupted by combat?
pub fn can_interrupt_for_combat(&self) -> bool {
match self {
Self::Idle | Self::Patrol | Self::Wander | Self::Return => true,
Self::Guard => true, // Guards should respond to threats
Self::Follow => true,
Self::Interact => false, // Don't interrupt mid-interaction
Self::Chase | Self::Flee | Self::Attack => false, // Already in combat-related state
}
}
/// Get the animation state index for this behavior
pub fn to_animation_state(&self) -> i32 {
match self {
Self::Idle | Self::Guard => 0, // Idle animation
Self::Patrol | Self::Wander | Self::Return | Self::Follow => 1, // Walk
Self::Chase | Self::Flee => 2, // Run
Self::Interact => 3, // Interact
Self::Attack => 4, // Attack
}
}
}
/// Behavior state machine with transition logic
pub struct BehaviorStateMachine {
pub current_state: BehaviorState,
pub previous_state: BehaviorState,
pub state_enter_time: f64,
pub home_position: Position,
pub patrol_path: Option<Vec<Position>>,
pub patrol_index: usize,
pub current_target: Option<Uuid>,
pub threat_target: Option<Uuid>,
}
impl BehaviorStateMachine {
/// Attempt to transition to a new state
pub fn try_transition(&mut self, new_state: BehaviorState, current_time: f64) -> bool {
if self.can_transition_to(new_state) {
self.previous_state = self.current_state;
self.current_state = new_state;
self.state_enter_time = current_time;
true
} else {
false
}
}
fn can_transition_to(&self, new_state: BehaviorState) -> bool {
// Define valid transitions
match (self.current_state, new_state) {
// Can always go to Idle
(_, BehaviorState::Idle) => true,
// Can always flee if not dead
(_, BehaviorState::Flee) => true,
// Patrol can be interrupted
(BehaviorState::Patrol, _) => true,
// Idle can transition to anything
(BehaviorState::Idle, _) => true,
// Chase can transition to Attack or Flee
(BehaviorState::Chase, BehaviorState::Attack) => true,
(BehaviorState::Chase, BehaviorState::Flee) => true,
(BehaviorState::Chase, BehaviorState::Return) => true,
// Attack can transition to Chase, Flee, or Idle
(BehaviorState::Attack, BehaviorState::Chase) => true,
(BehaviorState::Attack, BehaviorState::Flee) => true,
(BehaviorState::Attack, BehaviorState::Idle) => true,
// Return goes to Idle or Patrol
(BehaviorState::Return, BehaviorState::Idle) => true,
(BehaviorState::Return, BehaviorState::Patrol) => true,
// Default: allow transition
_ => true,
}
}
}

Client (BehaviorState.cs):

namespace RentEarth.Entity
{
/// <summary>
/// Behavior state synced from server.
/// Client uses this to drive animations and visual feedback.
/// </summary>
public enum BehaviorState
{
Idle, // Standing still
Patrol, // Walking patrol route
Chase, // Running toward target
Flee, // Running away
Interact, // Interacting with something
Return, // Returning to home
Wander, // Random movement
Guard, // Stationary guard
Follow, // Following entity
Attack // Attacking
}
public static class BehaviorStateExtensions
{
/// <summary>
/// Get the animator parameter value for this behavior.
/// </summary>
public static int GetAnimatorState(this BehaviorState state) => state switch
{
BehaviorState.Idle or BehaviorState.Guard => 0,
BehaviorState.Patrol or BehaviorState.Wander or BehaviorState.Return or BehaviorState.Follow => 1,
BehaviorState.Chase or BehaviorState.Flee => 2,
BehaviorState.Interact => 3,
BehaviorState.Attack => 4,
_ => 0
};
/// <summary>
/// Should this behavior show footstep particles?
/// </summary>
public static bool ShowsFootsteps(this BehaviorState state) => state switch
{
BehaviorState.Patrol or BehaviorState.Chase or BehaviorState.Flee or
BehaviorState.Wander or BehaviorState.Return or BehaviorState.Follow => true,
_ => false
};
}
}

Combat state tracks the entity’s engagement in combat and affects stat regeneration, ability availability, and targeting.

Server (entity_state.rs):

/// Combat engagement state
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CombatState {
#[default]
OutOfCombat, // Not in combat - full regen, can rest
Engaging, // Entering combat - target acquired
InCombat, // Active combat - reduced regen
Disengaging, // Leaving combat - cooldown period
Dead, // Dead - no actions, awaiting respawn
}
impl CombatState {
/// Get HP regeneration multiplier for this combat state
pub fn hp_regen_mult(&self) -> f32 {
match self {
Self::OutOfCombat => 1.0,
Self::Engaging => 0.5,
Self::InCombat => 0.25,
Self::Disengaging => 0.5,
Self::Dead => 0.0,
}
}
/// Get EP regeneration multiplier for this combat state
pub fn ep_regen_mult(&self) -> f32 {
match self {
Self::OutOfCombat => 1.0,
Self::Engaging => 0.75,
Self::InCombat => 0.5,
Self::Disengaging => 0.75,
Self::Dead => 0.0,
}
}
/// Can the entity use abilities in this state?
pub fn can_use_abilities(&self) -> bool {
matches!(self, Self::OutOfCombat | Self::Engaging | Self::InCombat)
}
/// Can the entity move in this state?
pub fn can_move(&self) -> bool {
!matches!(self, Self::Dead)
}
/// Is the entity considered "in combat" for UI purposes?
pub fn is_in_combat(&self) -> bool {
matches!(self, Self::Engaging | Self::InCombat | Self::Disengaging)
}
}
/// Combat state machine with timers
pub struct CombatStateMachine {
pub state: CombatState,
pub last_damage_taken_time: f64,
pub last_damage_dealt_time: f64,
pub combat_enter_time: f64,
pub current_target: Option<Uuid>,
pub threat_list: Vec<ThreatEntry>,
}
pub struct ThreatEntry {
pub entity_id: Uuid,
pub threat_value: f32,
pub last_action_time: f64,
}
impl CombatStateMachine {
const COMBAT_TIMEOUT_SECS: f64 = 10.0;
const DISENGAGE_DURATION_SECS: f64 = 5.0;
pub fn update(&mut self, current_time: f64) {
match self.state {
CombatState::InCombat => {
// Check if should disengage
let time_since_combat = f64::min(
current_time - self.last_damage_taken_time,
current_time - self.last_damage_dealt_time,
);
if time_since_combat > Self::COMBAT_TIMEOUT_SECS {
self.state = CombatState::Disengaging;
}
}
CombatState::Disengaging => {
// Check if disengage complete
let time_in_disengage = current_time - self.combat_enter_time;
if time_in_disengage > Self::DISENGAGE_DURATION_SECS {
self.state = CombatState::OutOfCombat;
self.threat_list.clear();
self.current_target = None;
}
}
_ => {}
}
// Clean up old threat entries
self.threat_list.retain(|entry| {
current_time - entry.last_action_time < Self::COMBAT_TIMEOUT_SECS
});
}
pub fn on_damage_taken(&mut self, attacker_id: Uuid, damage: u32, current_time: f64) {
self.last_damage_taken_time = current_time;
self.enter_combat(current_time);
self.add_threat(attacker_id, damage as f32, current_time);
}
pub fn on_damage_dealt(&mut self, target_id: Uuid, current_time: f64) {
self.last_damage_dealt_time = current_time;
self.enter_combat(current_time);
self.current_target = Some(target_id);
}
fn enter_combat(&mut self, current_time: f64) {
if self.state == CombatState::OutOfCombat {
self.state = CombatState::Engaging;
self.combat_enter_time = current_time;
} else if self.state == CombatState::Engaging || self.state == CombatState::Disengaging {
self.state = CombatState::InCombat;
}
}
fn add_threat(&mut self, entity_id: Uuid, threat: f32, current_time: f64) {
if let Some(entry) = self.threat_list.iter_mut().find(|e| e.entity_id == entity_id) {
entry.threat_value += threat;
entry.last_action_time = current_time;
} else {
self.threat_list.push(ThreatEntry {
entity_id,
threat_value: threat,
last_action_time: current_time,
});
}
}
/// Get the highest threat target
pub fn get_primary_target(&self) -> Option<Uuid> {
self.threat_list
.iter()
.max_by(|a, b| a.threat_value.partial_cmp(&b.threat_value).unwrap())
.map(|entry| entry.entity_id)
}
}

Client (CombatState.cs):

using R3;
namespace RentEarth.Entity
{
/// <summary>
/// Combat state synced from server.
/// Drives UI elements like combat indicators and regen displays.
/// </summary>
public enum CombatState
{
OutOfCombat, // Peaceful - show full regen
Engaging, // Combat starting - show "!" indicator
InCombat, // Active combat - show combat UI
Disengaging, // Combat ending - show timer
Dead // Dead - show respawn UI
}
/// <summary>
/// Client-side combat state tracking with reactive updates.
/// </summary>
public class CombatStateTracker
{
public ReactiveProperty<CombatState> State { get; } = new(CombatState.OutOfCombat);
public ReactiveProperty<float> DisengageTimer { get; } = new(0f);
public ReactiveProperty<bool> IsInCombat { get; }
public CombatStateTracker()
{
// Derived property for UI binding
IsInCombat = State.Select(s =>
s == CombatState.Engaging ||
s == CombatState.InCombat ||
s == CombatState.Disengaging
).ToReactiveProperty();
}
public void UpdateFromServer(CombatState serverState, float disengageRemaining)
{
State.Value = serverState;
DisengageTimer.Value = disengageRemaining;
}
}
public static class CombatStateExtensions
{
/// <summary>
/// Get the UI indicator for this combat state.
/// </summary>
public static string GetIndicator(this CombatState state) => state switch
{
CombatState.Engaging => "[!]",
CombatState.InCombat => "[COMBAT]",
CombatState.Disengaging => "[...]",
CombatState.Dead => "[DEAD]",
_ => ""
};
/// <summary>
/// Should the combat music play in this state?
/// </summary>
public static bool PlaysCombatMusic(this CombatState state) => state switch
{
CombatState.Engaging or CombatState.InCombat => true,
_ => false
};
}
}
┌─────────────────────────────────────────────────────────────────┐
│ STATE INTERACTION EXAMPLE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. NPC Guard is CALM, PATROL, OUT_OF_COMBAT │
│ └─ Walking patrol route normally │
│ │
│ 2. Player approaches → NPC detects player │
│ └─ Emotional: CALM → ALERT │
│ └─ Behavior: PATROL → GUARD (stops, watches) │
│ │
│ 3. Player attacks NPC │
│ └─ Emotional: ALERT → ANGRY │
│ └─ Behavior: GUARD → CHASE │
│ └─ Combat: OUT_OF_COMBAT → ENGAGING → IN_COMBAT │
│ │
│ 4. NPC HP drops below flee threshold (modified by ANGRY) │
│ └─ Emotional: ANGRY → AFRAID │
│ └─ Behavior: CHASE → FLEE │
│ └─ Combat: stays IN_COMBAT │
│ │
│ 5. Player stops attacking, 10 seconds pass │
│ └─ Combat: IN_COMBAT → DISENGAGING │
│ │
│ 6. 5 more seconds pass │
│ └─ Emotional: AFRAID → ALERT (still wary) │
│ └─ Behavior: FLEE → RETURN │
│ └─ Combat: DISENGAGING → OUT_OF_COMBAT │
│ │
│ 7. NPC returns home, no threats │
│ └─ Emotional: ALERT → CALM │
│ └─ Behavior: RETURN → PATROL │
│ │
└─────────────────────────────────────────────────────────────────┘

Proto (snapshot.proto):

// Entity state sync - included in WorldSnapshot for each entity
message EntityStateSync {
bytes entity_id = 1;
EmotionalState emotional_state = 2;
BehaviorState behavior_state = 3;
CombatState combat_state = 4;
float disengage_timer = 5; // Seconds remaining in disengage
bytes target_id = 6; // Current target (if any)
}
enum EmotionalState {
EMOTIONAL_CALM = 0;
EMOTIONAL_ALERT = 1;
EMOTIONAL_ANGRY = 2;
EMOTIONAL_AFRAID = 3;
EMOTIONAL_HAPPY = 4;
EMOTIONAL_SAD = 5;
EMOTIONAL_CONFUSED = 6;
}
enum BehaviorState {
BEHAVIOR_IDLE = 0;
BEHAVIOR_PATROL = 1;
BEHAVIOR_CHASE = 2;
BEHAVIOR_FLEE = 3;
BEHAVIOR_INTERACT = 4;
BEHAVIOR_RETURN = 5;
BEHAVIOR_WANDER = 6;
BEHAVIOR_GUARD = 7;
BEHAVIOR_FOLLOW = 8;
BEHAVIOR_ATTACK = 9;
}
enum CombatState {
COMBAT_OUT = 0;
COMBAT_ENGAGING = 1;
COMBAT_IN = 2;
COMBAT_DISENGAGING = 3;
COMBAT_DEAD = 4;
}

Current: 10 Hz (100ms per tick)

Tick RateLatencyBandwidthCPUUse Case
10 Hz100msLowLowCurrent (works with interpolation)
20 Hz50msMediumMediumSmoother combat
30 Hz33msHighHighFast-paced action

Trade-offs:

  • Higher tick rate = smoother but more CPU/bandwidth
  • Current 10 Hz works well with snapshot interpolation
  • Consider 20 Hz if combat feels unresponsive