Combat System
Combat System
Section titled “Combat System”This document covers the combat and stats system for RentEarth, including health, mana, energy pools, movement modifiers, and combat mechanics.
Stats Overview
Section titled “Stats Overview”┌─────────────────────────────────────────────────────────────────┐│ 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 ││ │└─────────────────────────────────────────────────────────────────┘Implementation Status
Section titled “Implementation Status”| Component | Status | Description |
|---|---|---|
| Base Movement | ✅ Complete | Server-authoritative 5.0 units/sec |
| Diagonal Normalization | ✅ Complete | Consistent speed in all directions |
| Input Queue | ✅ Complete | Anti-spam protection (max 10) |
| Protobuf Transport | ✅ Complete | Binary network format |
| Sprint Modifier | 🔲 Planned | Hold shift for 1.5x speed |
| Per-Entity Speed | 🔲 Planned | Speed powerups/debuffs |
| HP System | 🔲 Planned | Health points with regen |
| MP System | 🔲 Planned | Mana for abilities |
| EP System | 🔲 Planned | Energy for physical actions |
| Client-Side Prediction | 🔲 Advanced | Sub-frame responsiveness |
Current Movement System
Section titled “Current Movement System”Location: website/axum/src/game/input_processor.rs
Server Constants
Section titled “Server Constants”const MOVE_SPEED: f32 = 5.0; // Units per secondconst TICK_DURATION_SECS: f32 = 0.1; // 10 Hz tick rate (100ms per tick)// Result: 0.5 units per tickCurrent Features
Section titled “Current Features”- 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
Planned: Stats System
Section titled “Planned: Stats System”1. Entity Stats Structure
Section titled “1. Entity Stats Structure”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; }}2. Protobuf Sync Message
Section titled “2. Protobuf Sync Message”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;}3. Stats Regeneration
Section titled “3. Stats Regeneration”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; }}Planned: Movement Modifiers
Section titled “Planned: Movement Modifiers”1. Sprint System
Section titled “1. Sprint System”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; } }}2. Per-Entity Speed Modifiers
Section titled “2. Per-Entity Speed Modifiers”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) }}3. Client-Side Prediction (Advanced)
Section titled “3. Client-Side Prediction (Advanced)”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 ); } } }}Planned: Combat Mechanics
Section titled “Planned: Combat Mechanics”Damage Calculation
Section titled “Damage Calculation”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, } }}Ability System
Section titled “Ability System”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 },}UI Integration
Section titled “UI Integration”Stats Bar Component
Section titled “Stats Bar Component”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(); } }}Files Reference
Section titled “Files Reference”| File | Purpose |
|---|---|
website/axum/src/game/input_processor.rs | Server movement & input processing |
website/axum/src/game/world_runtime.rs | World tick loop |
unity/.../Network/InputCollector.cs | Client input collection |
unity/.../Network/SnapshotInterpolator.cs | Client-side interpolation |
proto/rentearth/snapshot.proto | Protobuf message definitions |
Implementation Phases
Section titled “Implementation Phases”Phase 1: Stats Foundation
Section titled “Phase 1: Stats Foundation”- Add
EntityStatsstruct to server - Add
EntityStatsclass to client (R3 reactive) - Sync stats via protobuf
- Basic HP/MP/EP UI bars
Phase 2: Sprint System
Section titled “Phase 2: Sprint System”- Add
action_flagstoPlayerInputprotobuf - Implement sprint on server (EP consumption)
- Add shift-to-sprint on client
- Visual feedback (animation state)
Phase 3: Status Effects
Section titled “Phase 3: Status Effects”- Implement
StatusEffectsystem - Speed modifiers (boost/slow)
- Buff/debuff duration tracking
- UI indicators for active effects
Phase 4: Damage System
Section titled “Phase 4: Damage System”- Damage events and calculation
- Armor/resistance stats
- Death handling
- Respawn system
Phase 5: Abilities
Section titled “Phase 5: Abilities”- 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
Testing Checklist
Section titled “Testing Checklist”Movement
Section titled “Movement”- 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
Combat
Section titled “Combat”- Damage reduces HP
- Armor reduces physical damage
- Magic resistance reduces magical damage
- True damage ignores defenses
- Death occurs at 0 HP
Entity State Systems
Section titled “Entity State Systems”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
Section titled “Emotional State”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
Section titled “Behavior State”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 logicpub 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
Section titled “Combat State”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 timerspub 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 Flow
Section titled “State Interaction Flow”┌─────────────────────────────────────────────────────────────────┐│ 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 ││ │└─────────────────────────────────────────────────────────────────┘Protobuf Sync
Section titled “Protobuf Sync”Proto (snapshot.proto):
// Entity state sync - included in WorldSnapshot for each entitymessage 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;}Tick Rate Considerations
Section titled “Tick Rate Considerations”Current: 10 Hz (100ms per tick)
| Tick Rate | Latency | Bandwidth | CPU | Use Case |
|---|---|---|---|---|
| 10 Hz | 100ms | Low | Low | Current (works with interpolation) |
| 20 Hz | 50ms | Medium | Medium | Smoother combat |
| 30 Hz | 33ms | High | High | Fast-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