Skip to content

ECS Architecture

This document covers the Entity Component System (ECS) architecture for the RentEarth game server, leveraging bevy_ecs for component storage, system scheduling, and change detection.

Featurebevy_ecsCustom Implementation
MaturityBattle-tested, widely usedNew, needs testing
Change DetectionBuilt-in Changed<T>, Added<T>Manual dirty tracking
QueriesErgonomic, type-safe, filteredBuild from scratch
Parallel SystemsAutomatic with dependency analysisManual threading
ArchetypesCache-friendly storageDashMap (scattered)
ResourcesFirst-class supportManual singleton pattern
MaintenanceActive communityYou maintain it
┌─────────────────────────────────────────────────────────────────┐
│ BEVY_ECS ARCHITECTURE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ WORLD │ │
│ │ bevy_ecs::world::World - owns all data │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ├── Entities (u64 IDs) │
│ │ │
│ ├── Components (stored in Archetypes) │
│ │ ├── Position, Rotation, Velocity │
│ │ ├── Health, Stats, Inventory │
│ │ ├── NpcState, NpcNavigation, NpcCombat │
│ │ ├── PlayerData, PlayerInput │
│ │ └── NetworkId (maps to Uuid) │
│ │ │
│ ├── Resources (singletons) │
│ │ ├── GameTime { delta: f32, elapsed: f64 } │
│ │ ├── SpatialIndex (chunk-based lookups) │
│ │ ├── EventChannel (broadcast::Sender) │
│ │ └── WaypointGraph (NPC navigation) │
│ │ │
│ └── Schedule (ordered system execution) │
│ ├── InputStage: PlayerInputSystem │
│ ├── UpdateStage: NpcTickSystem, CombatSystem │
│ └── SyncStage: SnapshotSystem │
│ │
└─────────────────────────────────────────────────────────────────┘
ComponentStatusDescription
Add bevy_ecs + glam🔲 Step 1Add to Cargo.toml
Define components🔲 Step 2Position, Health, NpcState, etc.
Define resources🔲 Step 3GameTime, SpatialIndex, NetworkIdLookup
Create systems🔲 Step 4NPC tick, combat, regen, snapshot
Replace WorldRuntime🔲 Step 5New GameServer with bevy World
Update command handlers🔲 Step 6WebSocket handlers use ECS
Remove legacy code🔲 Step 7Delete EntityState, EntityStateManager
CurrentReplaced By
EntityState structMultiple focused components
EntityStateManagerbevy_ecs::World
DashMap<Uuid, EntityState>Archetype storage + NetworkIdLookup
Manual dirty trackingChanged<T> queries
NpcController::tick()npc_tick_system
NpcInstance structNPC component bundle
InputProcessorplayer_input_system
ActionManagerharvest_system
process_tick()Schedule::run()

This section maps every field from the current codebase to ECS components.

The monolithic EntityState struct becomes multiple focused components:

// BEFORE: entity_state.rs
pub struct EntityState {
pub entity_id: Uuid,
pub entity_type: EntityType,
pub username: String,
pub position: Position,
pub rotation: Rotation,
pub health: f32,
pub is_alive: bool,
pub inventory: Inventory,
pub animation_state: i32,
pub character_type: CharacterType,
pub last_update: i64,
pub last_jump_time: Option<Instant>,
pub jump_count: u32,
}
// AFTER: Multiple ECS Components
EntityState FieldECS ComponentNotes
entity_id: UuidNetworkId(Uuid)Maps bevy Entity to network UUID
entity_type: EntityTypePlayer / Npc / Enemy / Boss markerZero-sized marker components for filtering
username: StringPlayerData { username, character_type }Player-specific data
position: PositionPosition(glam::Vec3)Transform component
rotation: RotationRotation(glam::Quat)Transform component
health: f32Health { current, max, regen }With helper methods
is_alive: boolDerived from Healthhealth.is_dead() method
inventory: InventoryInventory { items, max_slots }Same structure
animation_state: i32AnimationState { state, blend }Animation blending support
character_typePart of PlayerDataKnight, Barbarian, Mage, etc.
last_update: i64LastUpdate(i64)For stale detection
last_jump_timeJumpState { last_time, count }Anti-cheat tracking
jump_count: u32Part of JumpStateAir jump tracking

The NpcInstance struct becomes an NPC entity bundle:

// BEFORE: npc/controller.rs
pub struct NpcInstance {
pub entity_id: Uuid,
pub state: NpcState,
pub home_position: Position,
pub home_waypoint_id: Option<WaypointId>,
pub patrol_waypoint_ids: Vec<WaypointId>,
pub current_path: Vec<WaypointId>,
pub path_index: usize,
pub target_id: Option<Uuid>,
pub last_target_position: Option<Position>,
pub ticks_since_target_seen: u32,
pub attack_damage: f32,
pub attack_cooldown: u8,
pub harvest_target_id: Option<u64>,
pub collected_resources: Vec<(String, u32)>,
pub speed_multiplier: f32,
pub drift_direction: f32,
}
NpcInstance FieldECS ComponentNotes
entity_idNetworkId(Uuid)Same as player
state: NpcStateNpcState { emotional, combat, behavior, social }Bitflags preserved
home_positionHomePosition(Vec3)Return-to position
home_waypoint_idPart of NpcNavigationNavigation data
patrol_waypoint_idsPatrolRoute(Vec<WaypointId>)Patrol path
current_pathPart of NpcNavigationActive path
path_indexPart of NpcNavigationCurrent waypoint index
target_idCombatTarget(Option<Entity>)Uses Entity not Uuid
last_target_positionPart of TargetTrackingFor pursuit
ticks_since_target_seenPart of TargetTrackingTarget timeout
attack_damageAttackStats { damage, range, cooldown }Combat stats
attack_cooldownPart of AttackStatsCooldown timer
harvest_target_idHarvestTarget(Option<u64>)NPC harvesting
collected_resourcesInventoryReuse player inventory
speed_multiplierMovementModifier { speed, drift }Movement variation
drift_directionPart of MovementModifierPatrol drift

The NpcState struct contains 4 bitflag fields (each i64):

// npc/state.rs - Keep as single component with bitflags
#[derive(Component, Clone, Copy, Default)]
pub struct NpcState {
pub emotional: i64, // Emotional flags + intensity + duration
pub combat: i64, // Combat state + cooldowns + threat
pub behavior: i64, // Behavior + modifiers + priority
pub social: i64, // Faction + role + disposition
}

Emotional State Bits:

BitsFieldValues
0-7StateNEUTRAL, ALERT, AFRAID, ANGRY, CURIOUS, HAPPY, SAD, CONFUSED, BERSERK
8-15Intensity0-255
16-31DurationTicks remaining
32-47SourceEntity hash that caused emotion

Combat State Bits:

BitsFieldValues
0-7StateIDLE, ENGAGED, PURSUING, RETREATING, FLANKING, DEFENDING, CASTING, STUNNED, DEAD
8-15Attack cooldownTicks
16-23Ability cooldownTicks
24-31Combo counterCurrent combo
32-47Target hashCurrent target
48-55Threat level0-255
56-63Damage modifier-128 to +127

Behavior State Bits:

BitsFieldValues
0-7PrimaryIDLE, PATROL, GUARD, HARVEST, FOLLOW, WANDER, FLEE, RETURN_HOME, SCRIPTED
8-15ModifiersINTERRUPTIBLE, LOOPING, URGENT, STEALTHY
16-23Waypoint indexCurrent patrol index
24-31PriorityBehavior priority
32-47Start tickWhen behavior started
48-63Target locationDestination hash

Social State Bits:

BitsFieldValues
0-7FactionNEUTRAL, PLAYER, TOWN, WILDLIFE, HOSTILE, BOSS
8-15Group IDSquad/group membership
16-23RolesLEADER, SCOUT, HEALER, TANK, DPS
24-31Disposition-128 (hostile) to +127 (friendly)
32-39CommsCALLED_HELP, RECEIVED_HELP, SHARING_TARGET
// BEFORE: actions.rs
pub struct HarvestAction {
pub object_id: u64,
pub player_id: Uuid,
pub start_position: Position,
pub start_time: Instant,
pub harvest_duration: f32,
pub last_heartbeat: Instant,
}
HarvestAction FieldECS ComponentNotes
object_idHarvestTarget(u64)On player entity
player_idEntity itselfNo need to store
start_positionPart of ActiveHarvestAnti-cheat validation
start_timePart of ActiveHarvestProgress tracking
harvest_durationPart of ActiveHarvestTotal time needed
last_heartbeatPart of ActiveHarvestTimeout detection
// environment_manager.rs objects become entities
#[derive(Bundle)]
pub struct EnvironmentObjectBundle {
pub object_id: ObjectId,
pub asset_name: AssetName,
pub position: Position,
pub rotation: Rotation,
pub scale: Scale,
pub object_type: ObjectType,
pub resource: ResourceData, // type, amount, harvest_time
pub harvestable: Harvestable, // marker component
pub respawn: Option<RespawnTimer>,
}

Resources are singleton data shared across systems:

ResourcePurposeSource
GameTimeDelta time, elapsed, tick countGame loop
GameConfigWorld seed, tick rate, chunk sizeConfig file
SpatialIndexChunk-based entity lookupsEntityStateManager
NetworkIdLookupUuid ↔ Entity mappingNew
EventChannelBroadcast sender for snapshotsWorldRuntime
WaypointGraphNPC navigation graphNpcController
CommandQueueIncoming player commandsWorldRuntime
PhysicsHandleRapier physics worker channelPhysicsWorker
#[derive(Resource)]
pub struct GameConfig {
pub world_seed: u64,
pub tick_rate_hz: u32, // 10
pub chunk_size: f32, // 50.0
pub view_distance_chunks: i32, // 6
pub stale_timeout_secs: u64, // 300
}
#[derive(Resource)]
pub struct NpcConfig {
pub patrol_speed: f32, // 3.0
pub chase_speed: f32, // 5.0
pub detection_radius: f32, // 20.0
pub attack_range: f32, // 2.0
pub attack_cooldown_ticks: u8, // 10
pub leash_distance: f32, // 50.0
}

Systems organized by execution phase:

SystemReadsWritesDescription
player_input_systemCommandQueue, NetworkIdLookupPosition, Rotation, AnimationStateProcess player movement
jump_validation_systemJumpStateJumpState, PositionAnti-cheat for jumps
SystemReadsWritesDescription
spatial_index_systemPosition (Changed)SpatialIndexUpdate chunk assignments
SystemReadsWritesDescription
npc_behavior_systemNpcState, Position, SpatialIndexNpcState, NpcNavigationBehavior state machine
npc_patrol_systemNpcState, PatrolRoute, WaypointGraphPosition, NpcNavigationPatrol movement
npc_chase_systemNpcState, CombatTargetPositionChase targets
npc_combat_systemNpcState, AttackStats, CombatTargetHealth (target)Deal damage
npc_detection_systemPosition, SpatialIndex, NpcStateNpcState, CombatTargetDetect hostiles
harvest_progress_systemActiveHarvest, PositionActiveHarvestUpdate harvest timers
health_regen_systemHealth, GameTimeHealthRegenerate health
stats_regen_systemStats, GameTimeStatsRegenerate MP/EP
SystemReadsWritesDescription
death_systemHealthCommands (despawn)Handle entity death
harvest_complete_systemActiveHarvestInventory, CommandsGrant harvest rewards
cleanup_systemLastUpdate, GameTimeCommands (despawn)Remove stale entities
SystemReadsWritesDescription
entity_spawn_systemAdded<Player>, Added<Npc>EventChannelNotify new entities
entity_despawn_systemRemovedComponents<NetworkId>EventChannelNotify removed entities
snapshot_systemChanged<Position>, Changed<Health>, etc.EventChannelDelta snapshots
physics_sync_systemPosition (Changed)PhysicsHandleSync to Rapier

Add bevy_ecs and glam to your existing dependencies:

[dependencies]
# ECS - standalone bevy_ecs (not full Bevy engine)
bevy_ecs = "0.17.3"
glam = "0.30.3" # Math library for Vec3, Quat (used by bevy)
# Enable glam<->nalgebra conversion (automatic .into() between types)
nalgebra = { version = "0.34.1", features = ["convert-glam030"] }
# Existing deps (keep these)
tokio = { version = "1.43", features = ["full", "rt-multi-thread"] }
uuid = { version = "1.10", features = ["serde", "v4"] }
dashmap = { version = "6.1.0", features = ["rayon"] } # For NetworkIdLookup
rapier3d = { version = "0.31.0", features = ["parallel", "simd-stable"] }

With the convert-glam030 feature enabled on nalgebra, you get automatic conversions via .into():

use glam::Vec3;
use nalgebra::{Vector3, Point3, Isometry3};
// glam -> nalgebra (for Rapier physics)
let glam_pos: Vec3 = Vec3::new(1.0, 2.0, 3.0);
let na_vec: Vector3<f32> = glam_pos.into();
let na_point: Point3<f32> = glam_pos.into();
// nalgebra -> glam (for ECS components)
let na_pos: Point3<f32> = Point3::new(1.0, 2.0, 3.0);
let glam_vec: Vec3 = na_pos.into();
// Isometry conversions for transforms
let glam_transform: (Vec3, glam::Quat) = isometry.into();
let na_isometry: Isometry3<f32> = (glam_pos, glam_quat).into();

This means ECS systems can work with glam::Vec3 and Rapier physics can work with nalgebra::Point3 - just use .into() at the boundary.

bevy_ecs systems are synchronous, which works perfectly inside an async tick loop:

use bevy_ecs::prelude::*;
use tokio::time::{interval, Duration};
pub struct GameServer {
world: World,
schedule: Schedule,
}
impl GameServer {
pub fn new() -> Self {
let mut world = World::new();
let mut schedule = Schedule::default();
// Register resources
world.insert_resource(GameTime::default());
world.insert_resource(SpatialIndex::new());
// Add systems to schedule
schedule.add_systems((
player_input_system,
npc_tick_system,
combat_system,
health_regen_system,
snapshot_system,
).chain()); // Run in order
Self { world, schedule }
}
pub async fn run(&mut self) {
let mut tick_interval = interval(Duration::from_millis(100)); // 10 Hz
loop {
tick_interval.tick().await;
// Update game time resource
self.world.resource_mut::<GameTime>().tick(0.1);
// Run all systems (synchronous, but fast)
self.schedule.run(&mut self.world);
}
}
}
#[derive(Resource, Default)]
pub struct GameTime {
pub delta: f32,
pub elapsed: f64,
}
impl GameTime {
pub fn tick(&mut self, delta: f32) {
self.delta = delta;
self.elapsed += delta as f64;
}
}

In bevy_ecs, entities are lightweight 64-bit identifiers. We add a NetworkId component to map to UUIDs for network sync.

use bevy_ecs::prelude::*;
use uuid::Uuid;
/// Maps bevy Entity to network UUID
#[derive(Component, Clone, Copy)]
pub struct NetworkId(pub Uuid);
/// Entity type marker components (use as filters)
#[derive(Component)]
pub struct Player;
#[derive(Component)]
pub struct Npc;
#[derive(Component)]
pub struct Enemy;
#[derive(Component)]
pub struct Boss;
// Spawn a player entity
fn spawn_player(commands: &mut Commands, uuid: Uuid, username: String) -> Entity {
commands.spawn((
NetworkId(uuid),
Player,
Position::default(),
Rotation::default(),
Health::default(),
Stats::default(),
PlayerData { username, character_type: CharacterType::Knight },
Inventory::default(),
)).id()
}
// Spawn an NPC entity
fn spawn_npc(commands: &mut Commands, uuid: Uuid, home: Position) -> Entity {
commands.spawn((
NetworkId(uuid),
Npc,
Position(home.0),
Rotation::default(),
Health::default(),
NpcState::default(),
NpcNavigation { home_position: home, ..Default::default() },
NpcCombat::default(),
)).id()
}

Components are plain structs with the #[derive(Component)] attribute.

use bevy_ecs::prelude::*;
/// 3D position in world space
#[derive(Component, Clone, Copy, Default, Debug)]
pub struct Position(pub glam::Vec3);
impl Position {
pub fn new(x: f32, y: f32, z: f32) -> Self {
Self(glam::Vec3::new(x, y, z))
}
pub fn distance_to(&self, other: &Position) -> f32 {
self.0.distance(other.0)
}
}
/// Rotation as quaternion
#[derive(Component, Clone, Copy, Debug)]
pub struct Rotation(pub glam::Quat);
impl Default for Rotation {
fn default() -> Self {
Self(glam::Quat::IDENTITY)
}
}
/// Velocity for physics
#[derive(Component, Clone, Copy, Default, Debug)]
pub struct Velocity(pub glam::Vec3);
/// Health points
#[derive(Component, Clone, Copy, Debug)]
pub struct Health {
pub current: f32,
pub max: f32,
pub regen_per_sec: f32,
}
impl Default for Health {
fn default() -> Self {
Self { current: 100.0, max: 100.0, regen_per_sec: 1.0 }
}
}
impl Health {
pub fn take_damage(&mut self, amount: f32) {
self.current = (self.current - amount).max(0.0);
}
pub fn heal(&mut self, amount: f32) {
self.current = (self.current + amount).min(self.max);
}
pub fn is_dead(&self) -> bool {
self.current <= 0.0
}
pub fn percent(&self) -> f32 {
self.current / self.max
}
}
/// Entity stats (MP/EP)
#[derive(Component, Clone, Copy, Debug)]
pub struct Stats {
pub mp_current: f32,
pub mp_max: f32,
pub mp_regen: f32,
pub ep_current: f32,
pub ep_max: f32,
pub ep_regen: f32,
pub move_speed: f32,
}
impl Default for Stats {
fn default() -> Self {
Self {
mp_current: 50.0,
mp_max: 50.0,
mp_regen: 2.0,
ep_current: 100.0,
ep_max: 100.0,
ep_regen: 10.0,
move_speed: 5.0,
}
}
}
/// Animation state for rendering
#[derive(Component, Clone, Copy, Default, Debug)]
pub struct AnimationState {
pub state: i32, // 0=Idle, 1=Walk, 2=Run, etc.
pub blend: f32, // Blend weight 0.0-1.0
}
/// Player-specific data
#[derive(Component, Clone, Debug)]
pub struct PlayerData {
pub username: String,
pub character_type: CharacterType,
}
#[derive(Clone, Copy, Debug, Default)]
pub enum CharacterType {
#[default]
Knight,
Barbarian,
Mage,
Rogue,
RogueHooded,
}
/// Inventory container
#[derive(Component, Clone, Default, Debug)]
pub struct Inventory {
pub items: Vec<InventoryItem>,
pub max_slots: u32,
}
#[derive(Clone, Debug)]
pub struct InventoryItem {
pub item_id: String,
pub quantity: u32,
pub metadata: Option<String>,
}
/// NPC behavioral state (bitwise flags)
#[derive(Component, Clone, Copy, Default, Debug)]
pub struct NpcState {
pub emotional: u32, // HAPPY, ANGRY, SCARED, etc.
pub combat: u32, // IN_COMBAT, COOLDOWN, WOUNDED
pub behavior: u32, // PATROL, GUARD, CHASE, FLEE
pub social: u32, // FRIENDLY, HOSTILE, NEUTRAL
}
// Behavior flags
pub mod behavior {
pub const IDLE: u32 = 1 << 0;
pub const PATROL: u32 = 1 << 1;
pub const GUARD: u32 = 1 << 2;
pub const CHASE: u32 = 1 << 3;
pub const FLEE: u32 = 1 << 4;
pub const HARVEST: u32 = 1 << 5;
pub const RETURN: u32 = 1 << 6;
pub const WANDER: u32 = 1 << 7;
}
/// NPC navigation data
#[derive(Component, Clone, Default, Debug)]
pub struct NpcNavigation {
pub home_position: Position,
pub patrol_waypoints: Vec<u32>, // WaypointId
pub current_path: Vec<u32>,
pub path_index: usize,
pub speed_multiplier: f32,
}
/// NPC combat data
#[derive(Component, Clone, Copy, Default, Debug)]
pub struct NpcCombat {
pub target: Option<Entity>,
pub attack_damage: f32,
pub attack_range: f32,
pub attack_cooldown: f32,
pub last_attack_time: f64,
}
/// NPC harvesting behavior
#[derive(Component, Clone, Default, Debug)]
pub struct NpcHarvester {
pub harvest_target_id: Option<u64>,
pub collected_resources: Vec<(String, u32)>,
pub harvest_speed: f32,
}

Resources are singleton data accessible to all systems.

use bevy_ecs::prelude::*;
use dashmap::DashMap;
use std::sync::Arc;
use tokio::sync::broadcast;
/// Game time resource
#[derive(Resource, Default)]
pub struct GameTime {
pub delta: f32,
pub elapsed: f64,
pub tick_count: u64,
}
/// Spatial index for proximity queries
#[derive(Resource)]
pub struct SpatialIndex {
// Chunk coord -> entities in chunk
chunks: DashMap<(i32, i32), Vec<Entity>>,
// Entity -> chunk coord
entity_chunks: DashMap<Entity, (i32, i32)>,
}
impl SpatialIndex {
pub fn new() -> Self {
Self {
chunks: DashMap::new(),
entity_chunks: DashMap::new(),
}
}
pub fn update_entity(&self, entity: Entity, position: &Position) {
let chunk = Self::pos_to_chunk(position);
// Remove from old chunk
if let Some(old_chunk) = self.entity_chunks.get(&entity) {
if *old_chunk != chunk {
if let Some(mut entities) = self.chunks.get_mut(&*old_chunk) {
entities.retain(|&e| e != entity);
}
}
}
// Add to new chunk
self.chunks.entry(chunk).or_default().push(entity);
self.entity_chunks.insert(entity, chunk);
}
pub fn get_nearby(&self, position: &Position, radius: f32) -> Vec<Entity> {
let chunk = Self::pos_to_chunk(position);
let chunk_radius = (radius / 50.0).ceil() as i32;
let mut result = Vec::new();
for dx in -chunk_radius..=chunk_radius {
for dz in -chunk_radius..=chunk_radius {
if let Some(entities) = self.chunks.get(&(chunk.0 + dx, chunk.1 + dz)) {
result.extend(entities.iter().copied());
}
}
}
result
}
fn pos_to_chunk(pos: &Position) -> (i32, i32) {
((pos.0.x / 50.0) as i32, (pos.0.z / 50.0) as i32)
}
}
/// Network ID lookup (Uuid -> Entity)
#[derive(Resource, Default)]
pub struct NetworkIdLookup {
uuid_to_entity: DashMap<Uuid, Entity>,
entity_to_uuid: DashMap<Entity, Uuid>,
}
impl NetworkIdLookup {
pub fn register(&self, uuid: Uuid, entity: Entity) {
self.uuid_to_entity.insert(uuid, entity);
self.entity_to_uuid.insert(entity, uuid);
}
pub fn unregister(&self, entity: Entity) {
if let Some((_, uuid)) = self.entity_to_uuid.remove(&entity) {
self.uuid_to_entity.remove(&uuid);
}
}
pub fn get_entity(&self, uuid: &Uuid) -> Option<Entity> {
self.uuid_to_entity.get(uuid).map(|r| *r)
}
pub fn get_uuid(&self, entity: &Entity) -> Option<Uuid> {
self.entity_to_uuid.get(entity).map(|r| *r)
}
}
/// Event sender for network broadcasts
#[derive(Resource)]
pub struct EventChannel {
pub sender: broadcast::Sender<GameEvent>,
}
/// Waypoint graph for NPC navigation
#[derive(Resource)]
pub struct WaypointGraph {
// ... existing waypoint graph implementation
}

Systems are functions that operate on components via queries.

use bevy_ecs::prelude::*;
/// System to regenerate health for all entities
fn health_regen_system(
time: Res<GameTime>,
mut query: Query<&mut Health>,
) {
for mut health in &mut query {
if health.current < health.max {
health.current = (health.current + health.regen_per_sec * time.delta)
.min(health.max);
}
}
}
/// System to regenerate MP and EP
fn stats_regen_system(
time: Res<GameTime>,
mut query: Query<&mut Stats>,
) {
for mut stats in &mut query {
stats.mp_current = (stats.mp_current + stats.mp_regen * time.delta)
.min(stats.mp_max);
stats.ep_current = (stats.ep_current + stats.ep_regen * time.delta)
.min(stats.ep_max);
}
}
/// System to update spatial index when positions change
fn spatial_index_system(
spatial: Res<SpatialIndex>,
query: Query<(Entity, &Position), Changed<Position>>,
) {
for (entity, position) in &query {
spatial.update_entity(entity, position);
}
}

/// Main NPC behavior tick system
fn npc_tick_system(
time: Res<GameTime>,
spatial: Res<SpatialIndex>,
waypoints: Res<WaypointGraph>,
mut npc_query: Query<(
Entity,
&mut Position,
&mut NpcState,
&mut NpcNavigation,
Option<&mut NpcCombat>,
), With<Npc>>,
player_query: Query<(Entity, &Position), With<Player>>,
) {
for (entity, mut pos, mut state, mut nav, combat) in &mut npc_query {
// Tick state machine
tick_npc_state(&mut state);
// Execute behavior based on state
let primary_behavior = get_primary_behavior(state.behavior);
match primary_behavior {
behavior::PATROL => {
tick_patrol(&mut pos, &mut nav, &waypoints, time.delta);
}
behavior::GUARD => {
// Check for nearby players
if let Some(target) = find_nearest_hostile(&pos, &spatial, &player_query) {
state.behavior = behavior::CHASE;
if let Some(mut combat) = combat {
combat.target = Some(target);
}
}
}
behavior::CHASE => {
if let Some(combat) = &combat {
if let Some(target) = combat.target {
if let Ok((_, target_pos)) = player_query.get(target) {
tick_chase(&mut pos, target_pos, &nav, time.delta);
}
}
}
}
behavior::FLEE => {
tick_flee(&mut pos, &mut nav, &spatial, &player_query, time.delta);
}
behavior::RETURN => {
tick_return_home(&mut pos, &mut state, &nav, time.delta);
}
_ => {}
}
}
}
fn tick_patrol(
pos: &mut Position,
nav: &mut NpcNavigation,
waypoints: &WaypointGraph,
delta: f32,
) {
if nav.path_index >= nav.current_path.len() {
nav.path_index = 0; // Loop patrol
return;
}
let waypoint_id = nav.current_path[nav.path_index];
if let Some(waypoint_pos) = waypoints.get_position(waypoint_id) {
let direction = (waypoint_pos - pos.0).normalize_or_zero();
let speed = 3.0 * nav.speed_multiplier * delta; // Patrol speed
pos.0 += direction * speed;
// Check if reached waypoint
if pos.0.distance(waypoint_pos) < 0.5 {
nav.path_index += 1;
}
}
}
fn tick_chase(pos: &mut Position, target_pos: &Position, nav: &NpcNavigation, delta: f32) {
let direction = (target_pos.0 - pos.0).normalize_or_zero();
let speed = 5.0 * nav.speed_multiplier * delta; // Chase speed (faster)
pos.0 += direction * speed;
}
fn find_nearest_hostile(
pos: &Position,
spatial: &SpatialIndex,
players: &Query<(Entity, &Position), With<Player>>,
) -> Option<Entity> {
let detection_range = 20.0;
let nearby = spatial.get_nearby(pos, detection_range);
nearby.iter()
.filter_map(|&entity| players.get(entity).ok())
.min_by(|(_, a), (_, b)| {
let dist_a = pos.distance_to(a);
let dist_b = pos.distance_to(b);
dist_a.partial_cmp(&dist_b).unwrap()
})
.map(|(entity, _)| entity)
}
/// Combat system for NPC attacks
fn npc_combat_system(
time: Res<GameTime>,
mut npc_query: Query<(&Position, &mut NpcCombat, &NpcState), With<Npc>>,
mut player_query: Query<(Entity, &Position, &mut Health), With<Player>>,
) {
for (npc_pos, mut combat, state) in &mut npc_query {
// Only attack if in combat state
if state.behavior != behavior::CHASE {
continue;
}
// Check cooldown
if time.elapsed - combat.last_attack_time < combat.attack_cooldown as f64 {
continue;
}
// Check if target in range
if let Some(target) = combat.target {
if let Ok((_, target_pos, mut health)) = player_query.get_mut(target) {
let distance = npc_pos.distance_to(target_pos);
if distance <= combat.attack_range {
// Deal damage
health.take_damage(combat.attack_damage);
combat.last_attack_time = time.elapsed;
// TODO: Send damage event for client feedback
}
}
}
}
}

bevy_ecs provides built-in change detection via Changed<T> and Added<T> filters.

use bevy_ecs::prelude::*;
/// Snapshot system using bevy's change detection
fn snapshot_system(
time: Res<GameTime>,
event_channel: Res<EventChannel>,
network_lookup: Res<NetworkIdLookup>,
// Query entities with Position that changed this tick
changed_positions: Query<
(Entity, &NetworkId, &Position),
Changed<Position>
>,
// Query entities with Rotation that changed
changed_rotations: Query<
(Entity, &NetworkId, &Rotation),
Changed<Rotation>
>,
// Query entities with Health that changed
changed_health: Query<
(Entity, &NetworkId, &Health),
Changed<Health>
>,
// Query entities with AnimationState that changed
changed_animations: Query<
(Entity, &NetworkId, &AnimationState),
Changed<AnimationState>
>,
) {
// Collect all changed entities
let mut entity_updates: HashMap<Uuid, EntitySnapshot> = HashMap::new();
// Position changes
for (entity, network_id, position) in &changed_positions {
let snapshot = entity_updates.entry(network_id.0).or_default();
snapshot.position = Some(*position);
snapshot.presence_mask |= MASK_POSITION;
}
// Rotation changes
for (entity, network_id, rotation) in &changed_rotations {
let snapshot = entity_updates.entry(network_id.0).or_default();
snapshot.rotation = Some(*rotation);
snapshot.presence_mask |= MASK_ROTATION;
}
// Health changes
for (entity, network_id, health) in &changed_health {
let snapshot = entity_updates.entry(network_id.0).or_default();
snapshot.health = Some(health.current);
snapshot.presence_mask |= MASK_HEALTH;
}
// Animation changes
for (entity, network_id, anim) in &changed_animations {
let snapshot = entity_updates.entry(network_id.0).or_default();
snapshot.animation_state = Some(anim.state);
snapshot.presence_mask |= MASK_ANIMATION;
}
// Broadcast to connected clients
if !entity_updates.is_empty() {
let _ = event_channel.sender.send(GameEvent::WorldSnapshot {
server_time: time.elapsed,
entities: entity_updates,
});
}
}
// Presence mask bits
const MASK_POSITION: u32 = 1 << 0;
const MASK_ROTATION: u32 = 1 << 1;
const MASK_HEALTH: u32 = 1 << 2;
const MASK_ANIMATION: u32 = 1 << 3;
#[derive(Default)]
struct EntitySnapshot {
presence_mask: u32,
position: Option<Position>,
rotation: Option<Rotation>,
health: Option<f32>,
animation_state: Option<i32>,
}
/// Detect newly spawned entities
fn entity_spawn_system(
event_channel: Res<EventChannel>,
added_players: Query<(&NetworkId, &Position, &PlayerData), Added<Player>>,
added_npcs: Query<(&NetworkId, &Position), Added<Npc>>,
) {
for (network_id, position, player_data) in &added_players {
let _ = event_channel.sender.send(GameEvent::EntitySpawned {
entity_id: network_id.0,
entity_type: EntityType::Player,
position: *position,
username: Some(player_data.username.clone()),
});
}
for (network_id, position) in &added_npcs {
let _ = event_channel.sender.send(GameEvent::EntitySpawned {
entity_id: network_id.0,
entity_type: EntityType::Npc,
position: *position,
username: None,
});
}
}
/// Detect despawned entities (using RemovedComponents)
fn entity_despawn_system(
event_channel: Res<EventChannel>,
mut removed: RemovedComponents<NetworkId>,
network_lookup: Res<NetworkIdLookup>,
) {
for entity in removed.read() {
if let Some(uuid) = network_lookup.get_uuid(&entity) {
let _ = event_channel.sender.send(GameEvent::EntityDespawned {
entity_id: uuid,
});
network_lookup.unregister(entity);
}
}
}

Configure system execution order with bevy’s scheduling:

use bevy_ecs::prelude::*;
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
enum GameSet {
Input,
PreUpdate,
Update,
PostUpdate,
Sync,
}
fn configure_schedule(schedule: &mut Schedule) {
schedule.configure_sets((
GameSet::Input,
GameSet::PreUpdate,
GameSet::Update,
GameSet::PostUpdate,
GameSet::Sync,
).chain());
// Input processing
schedule.add_systems(
player_input_system.in_set(GameSet::Input)
);
// Pre-update (prepare for tick)
schedule.add_systems(
spatial_index_system.in_set(GameSet::PreUpdate)
);
// Main update
schedule.add_systems((
npc_tick_system,
npc_combat_system,
health_regen_system,
stats_regen_system,
).in_set(GameSet::Update));
// Post-update (cleanup)
schedule.add_systems(
death_system.in_set(GameSet::PostUpdate)
);
// Sync to clients
schedule.add_systems((
entity_spawn_system,
entity_despawn_system,
snapshot_system,
).chain().in_set(GameSet::Sync));
}

A clean rewrite is simpler than running dual systems. We replace the game loop entirely.

Terminal window
# Add to Cargo.toml
bevy_ecs = "0.15"
glam = "0.29"
src/game/
├── components/
│ ├── mod.rs # Component exports
│ ├── transform.rs # Position, Rotation, Velocity
│ ├── stats.rs # Health, Stats
│ ├── player.rs # Player, PlayerData, Inventory
│ ├── npc.rs # Npc, NpcState, NpcNavigation, NpcCombat
│ └── network.rs # NetworkId
├── resources/
│ ├── mod.rs # Resource exports
│ ├── time.rs # GameTime
│ ├── spatial.rs # SpatialIndex
│ ├── network.rs # NetworkIdLookup, EventChannel
│ └── waypoints.rs # WaypointGraph
├── systems/
│ ├── mod.rs # System exports
│ ├── input.rs # player_input_system
│ ├── npc.rs # npc_tick_system, npc_combat_system
│ ├── combat.rs # damage_system, death_system
│ ├── regen.rs # health_regen_system, stats_regen_system
│ ├── spatial.rs # spatial_index_system
│ └── snapshot.rs # snapshot_system, spawn/despawn detection
├── server.rs # NEW: GameServer with bevy World + Schedule
├── commands.rs # UPDATE: WebSocket handlers use ECS
├── entity_state.rs # DELETE after migration
├── world_runtime.rs # DELETE after migration
└── npc/controller.rs # DELETE after migration

Start with the data layer - define all components before systems:

// components/mod.rs
mod transform;
mod stats;
mod player;
mod npc;
mod network;
pub use transform::*;
pub use stats::*;
pub use player::*;
pub use npc::*;
pub use network::*;

Resources for shared state that systems need:

// resources/mod.rs
mod time;
mod spatial;
mod network;
mod waypoints;
pub use time::*;
pub use spatial::*;
pub use network::*;
pub use waypoints::*;

Each system is a standalone function - easy to test:

// systems/mod.rs
mod input;
mod npc;
mod combat;
mod regen;
mod spatial;
mod snapshot;
pub use input::*;
pub use npc::*;
pub use combat::*;
pub use regen::*;
pub use spatial::*;
pub use snapshot::*;

Replace WorldRuntime with new GameServer:

// server.rs
use bevy_ecs::prelude::*;
use crate::game::{components::*, resources::*, systems::*};
pub struct GameServer {
world: World,
schedule: Schedule,
}
impl GameServer {
pub fn new(event_tx: broadcast::Sender<GameEvent>) -> Self {
let mut world = World::new();
// Insert resources
world.insert_resource(GameTime::default());
world.insert_resource(SpatialIndex::new());
world.insert_resource(NetworkIdLookup::default());
world.insert_resource(EventChannel { sender: event_tx });
world.insert_resource(WaypointGraph::load());
// Configure schedule
let mut schedule = Schedule::default();
configure_schedule(&mut schedule);
Self { world, schedule }
}
pub async fn run(&mut self) {
let mut interval = tokio::time::interval(Duration::from_millis(100));
loop {
interval.tick().await;
// Update time
self.world.resource_mut::<GameTime>().tick(0.1);
// Run all systems
self.schedule.run(&mut self.world);
// Clear change detection for next tick
self.world.clear_trackers();
}
}
// Command handlers call these methods
pub fn spawn_player(&mut self, uuid: Uuid, username: String) -> Entity { ... }
pub fn despawn_entity(&mut self, uuid: &Uuid) { ... }
pub fn handle_player_input(&mut self, uuid: &Uuid, input: PlayerInput) { ... }
}

WebSocket command handlers now interact with GameServer:

// commands.rs
impl GameServer {
pub fn handle_command(&mut self, cmd: GameCommand) {
match cmd {
GameCommand::PlayerJoin { uuid, username } => {
self.spawn_player(uuid, username);
}
GameCommand::PlayerInput { uuid, input } => {
self.handle_player_input(&uuid, input);
}
GameCommand::PlayerLeave { uuid } => {
self.despawn_entity(&uuid);
}
// ... other commands
}
}
}

Once everything works, delete:

  • entity_state.rs
  • world_runtime.rs
  • npc/controller.rs
  • npc/instance.rs
  • Any other files replaced by ECS

src/game/
├── ecs/
│ ├── mod.rs # ECS exports
│ └── schedule.rs # Schedule configuration
├── components/
│ ├── mod.rs # Component exports
│ ├── transform.rs # Position, Rotation, Velocity
│ ├── stats.rs # Health, Stats
│ ├── player.rs # Player, PlayerData, Inventory
│ ├── npc.rs # Npc, NpcState, NpcNavigation, NpcCombat
│ └── network.rs # NetworkId
├── resources/
│ ├── mod.rs # Resource exports
│ ├── time.rs # GameTime
│ ├── spatial.rs # SpatialIndex
│ ├── network.rs # NetworkIdLookup, EventChannel
│ └── waypoints.rs # WaypointGraph
├── systems/
│ ├── mod.rs # System exports
│ ├── input.rs # player_input_system
│ ├── npc.rs # npc_tick_system, npc_combat_system
│ ├── combat.rs # damage_system, death_system
│ ├── regen.rs # health_regen_system, stats_regen_system
│ ├── spatial.rs # spatial_index_system
│ └── snapshot.rs # snapshot_system, spawn/despawn detection
├── server.rs # GameServer struct with World and Schedule
└── commands.rs # WebSocket command handling

bevy_ecs stores components in archetypes (groups of entities with same components):

Archetype A: [Player, Position, Health, Inventory]
- Entity 1: [pos1, health1, inv1]
- Entity 2: [pos2, health2, inv2]
- Entity 3: [pos3, health3, inv3]
Archetype B: [Npc, Position, Health, NpcState]
- Entity 4: [pos4, health4, state4]
- Entity 5: [pos5, health5, state5]

Benefits:

  • Cache-friendly iteration (components packed together)
  • O(1) component access
  • No hash lookups during iteration

bevy can run systems in parallel when they don’t conflict:

// These can run in parallel (different components)
schedule.add_systems((
health_regen_system, // Writes Health
stats_regen_system, // Writes Stats
// No conflict - run in parallel!
));
// These must run sequentially (both write Position)
schedule.add_systems((
player_movement_system,
npc_tick_system,
).chain()); // Force sequential

Zero-cost change detection built into component storage:

// Only iterates entities where Position actually changed
for (entity, pos) in query.iter().filter(Changed<Position>) {
// ...
}

#[cfg(test)]
mod tests {
use super::*;
use bevy_ecs::prelude::*;
#[test]
fn test_health_regen() {
let mut world = World::new();
world.insert_resource(GameTime { delta: 1.0, elapsed: 0.0, tick_count: 0 });
let entity = world.spawn(Health { current: 50.0, max: 100.0, regen_per_sec: 10.0 }).id();
let mut schedule = Schedule::default();
schedule.add_systems(health_regen_system);
schedule.run(&mut world);
let health = world.get::<Health>(entity).unwrap();
assert_eq!(health.current, 60.0); // 50 + 10*1.0
}
#[test]
fn test_change_detection() {
let mut world = World::new();
let entity = world.spawn(Position::default()).id();
// First tick - position is "changed" (just added)
let mut changed_count = 0;
for _ in world.query_filtered::<&Position, Changed<Position>>().iter(&world) {
changed_count += 1;
}
assert_eq!(changed_count, 1);
// Clear change detection
world.clear_trackers();
// Second tick - position not changed
changed_count = 0;
for _ in world.query_filtered::<&Position, Changed<Position>>().iter(&world) {
changed_count += 1;
}
assert_eq!(changed_count, 0);
// Modify position
world.get_mut::<Position>(entity).unwrap().0.x = 10.0;
// Third tick - position changed
changed_count = 0;
for _ in world.query_filtered::<&Position, Changed<Position>>().iter(&world) {
changed_count += 1;
}
assert_eq!(changed_count, 1);
}
#[test]
fn test_npc_patrol() {
let mut world = World::new();
world.insert_resource(GameTime { delta: 0.1, elapsed: 0.0, tick_count: 0 });
world.insert_resource(WaypointGraph::new_test());
let npc = world.spawn((
Npc,
Position::new(0.0, 0.0, 0.0),
NpcState { behavior: behavior::PATROL, ..Default::default() },
NpcNavigation {
current_path: vec![1], // Waypoint at (10, 0, 0)
..Default::default()
},
)).id();
let mut schedule = Schedule::default();
schedule.add_systems(npc_tick_system);
schedule.run(&mut world);
let pos = world.get::<Position>(npc).unwrap();
assert!(pos.0.x > 0.0); // NPC moved toward waypoint
}
}

A prioritized checklist for the clean ECS rewrite. Complete each phase before moving to the next.

Goal: Set up bevy_ecs foundation without breaking existing code.

TaskStatusFile(s)
Add bevy_ecs, glam to Cargo.tomlCargo.toml
Enable convert-glam029 on nalgebraCargo.toml
Create src/game/ecs/mod.rs moduleNew file
Create GameTime resourceresources/time.rs
Create GameConfig resourceresources/config.rs
Verify cargo check passes-

Goal: Define all ECS components from existing structs.

TaskStatusFile(s)
Position, Rotation, Velocitycomponents/transform.rs
Health, Statscomponents/stats.rs
NetworkIdcomponents/network.rs
Player, PlayerData, Inventorycomponents/player.rs
Npc, NpcState, NpcNavigation, NpcCombatcomponents/npc.rs
AnimationState, JumpStatecomponents/player.rs
HarvestTarget, ActiveHarvestcomponents/harvest.rs
ObjectId, ResourceData, Harvestablecomponents/environment.rs
Marker components: Enemy, Bosscomponents/markers.rs

Goal: Define all singleton resources.

TaskStatusFile(s)
SpatialIndex (from EntityStateManager)resources/spatial.rs
NetworkIdLookup (Uuid↔Entity)resources/network.rs
EventChannel (broadcast sender)resources/events.rs
WaypointGraph (from NpcController)resources/waypoints.rs
CommandQueue (player inputs)resources/commands.rs
PhysicsHandle (Rapier channel)resources/physics.rs
NpcConfig (behavior constants)resources/config.rs

Goal: Create new game server that can run alongside old code (for testing).

TaskStatusFile(s)
Create GameServer struct with Worldserver.rs
Insert all resources into Worldserver.rs
Create empty Scheduleecs/schedule.rs
Implement GameServer::run() tick loopserver.rs
Wire up to tokio runtimemain.rs

Goal: Player input and state management.

TaskStatusFile(s)
player_input_systemsystems/input.rs
jump_validation_systemsystems/input.rs
Player spawn/despawn commandsserver.rs
Port InputProcessor logicsystems/input.rs

Goal: NPC behavior state machine.

TaskStatusFile(s)
npc_behavior_system (state machine)systems/npc.rs
npc_patrol_systemsystems/npc.rs
npc_chase_systemsystems/npc.rs
npc_combat_systemsystems/npc.rs
npc_detection_systemsystems/npc.rs
NPC spawn systemsystems/npc.rs
Port NpcController::tick logicsystems/npc.rs

Goal: Combat, harvesting, regeneration.

TaskStatusFile(s)
health_regen_systemsystems/regen.rs
stats_regen_systemsystems/regen.rs
harvest_progress_systemsystems/harvest.rs
harvest_complete_systemsystems/harvest.rs
death_systemsystems/combat.rs
cleanup_system (stale entities)systems/cleanup.rs

Goal: Delta snapshots and sync.

TaskStatusFile(s)
spatial_index_systemsystems/spatial.rs
snapshot_system (Changed detection)systems/snapshot.rs
entity_spawn_system (Added detection)systems/snapshot.rs
entity_despawn_system (Removed detection)systems/snapshot.rs
physics_sync_system (Rapier)systems/physics.rs

Goal: Switch production to ECS.

TaskStatusFile(s)
Wire WebSocket handlers to GameServerwebsocket.rs
Update protobuf message handlingproto/
Run integration teststests/
Load test with multiple clients-
Performance benchmarks-

Goal: Remove legacy code.

TaskStatusFile(s)
Delete entity_state.rsDELETE
Delete world_runtime.rsDELETE
Delete npc/controller.rsDELETE
Delete npc/instance.rsDELETE
Delete input_processor.rsDELETE
Delete actions.rs (ActionManager)DELETE
Update imports throughout codebaseVarious
Final cargo clippy pass-

Detailed mapping of legacy files to new ECS structure.

Legacy FileReasonReplaced By
entity_state.rsMonolithic EntityState structMultiple components
world_runtime.rsOld game loop + EntityStateManagerserver.rs + bevy World
npc/controller.rsNpcController, tick()systems/npc.rs
npc/instance.rsNpcInstance structNPC component bundle
input_processor.rsInputProcessor queuesystems/input.rs + CommandQueue
actions.rsHarvestAction, ActionManagercomponents/harvest.rs + systems
src/game/
├── ecs/
│ ├── mod.rs # ECS module root
│ └── schedule.rs # SystemSet, configure_schedule()
├── components/
│ ├── mod.rs # pub use all components
│ ├── transform.rs # Position, Rotation, Velocity
│ ├── stats.rs # Health, Stats
│ ├── network.rs # NetworkId
│ ├── player.rs # Player, PlayerData, Inventory, JumpState
│ ├── npc.rs # Npc, NpcState, NpcNavigation, NpcCombat
│ ├── harvest.rs # HarvestTarget, ActiveHarvest
│ ├── environment.rs # ObjectId, ResourceData, Harvestable
│ └── markers.rs # Enemy, Boss (zero-sized markers)
├── resources/
│ ├── mod.rs # pub use all resources
│ ├── time.rs # GameTime
│ ├── config.rs # GameConfig, NpcConfig
│ ├── spatial.rs # SpatialIndex
│ ├── network.rs # NetworkIdLookup, EventChannel
│ ├── waypoints.rs # WaypointGraph
│ ├── commands.rs # CommandQueue
│ └── physics.rs # PhysicsHandle
├── systems/
│ ├── mod.rs # pub use all systems
│ ├── input.rs # player_input_system, jump_validation_system
│ ├── npc.rs # npc_behavior/patrol/chase/combat/detection_system
│ ├── combat.rs # death_system
│ ├── regen.rs # health_regen_system, stats_regen_system
│ ├── harvest.rs # harvest_progress_system, harvest_complete_system
│ ├── spatial.rs # spatial_index_system
│ ├── snapshot.rs # snapshot_system, entity_spawn/despawn_system
│ ├── physics.rs # physics_sync_system
│ └── cleanup.rs # cleanup_system
└── server.rs # GameServer struct
FileChanges
Cargo.tomlAdd bevy_ecs = "0.15", glam = "0.29", update nalgebra features
main.rsReplace WorldRuntime with GameServer
websocket.rsUse GameServer.handle_command()
proto/*.rsKeep as-is (protobuf doesn’t change)
environment_manager.rsPort to EnvironmentObject entities (optional - can keep for now)
physics_worker.rsKeep physics worker, just update message types for glam
// BEFORE (entity_state.rs)
pub struct EntityState {
entity_id: Uuid, // → NetworkId(Uuid)
entity_type: EntityType, // → Player / Npc / Enemy marker component
username: String, // → PlayerData.username
position: Position, // → Position(glam::Vec3)
rotation: Rotation, // → Rotation(glam::Quat)
health: f32, // → Health { current, max, regen }
is_alive: bool, // → Health.is_dead() method
inventory: Inventory, // → Inventory component
animation_state: i32, // → AnimationState { state, blend }
character_type: CharacterType, // → PlayerData.character_type
last_update: i64, // → LastUpdate(i64)
last_jump_time: Option<Instant>, // → JumpState.last_time
jump_count: u32, // → JumpState.count
}
// BEFORE (npc/controller.rs)
pub struct NpcInstance {
entity_id: Uuid, // → NetworkId(Uuid)
state: NpcState, // → NpcState component (keep bitflags)
home_position: Position, // → HomePosition(Vec3) or NpcNavigation.home
home_waypoint_id: Option<WaypointId>, // → NpcNavigation.home_waypoint
patrol_waypoint_ids: Vec<WaypointId>, // → PatrolRoute(Vec<WaypointId>)
current_path: Vec<WaypointId>, // → NpcNavigation.current_path
path_index: usize, // → NpcNavigation.path_index
target_id: Option<Uuid>, // → CombatTarget(Option<Entity>) ← Entity not Uuid!
last_target_position: Option<Position>, // → TargetTracking.last_position
ticks_since_target_seen: u32, // → TargetTracking.ticks_unseen
attack_damage: f32, // → AttackStats.damage
attack_cooldown: u8, // → AttackStats.cooldown
harvest_target_id: Option<u64>, // → HarvestTarget(Option<u64>)
collected_resources: Vec<(String, u32)>, // → Inventory
speed_multiplier: f32, // → MovementModifier.speed
drift_direction: f32, // → MovementModifier.drift
}
// BEFORE (actions.rs)
pub struct HarvestAction {
object_id: u64, // → HarvestTarget(u64) on player entity
player_id: Uuid, // → Entity itself (no need to store)
start_position: Position, // → ActiveHarvest.start_position
start_time: Instant, // → ActiveHarvest.start_time
harvest_duration: f32, // → ActiveHarvest.duration
last_heartbeat: Instant, // → ActiveHarvest.last_heartbeat
}
// AFTER: Add ActiveHarvest component to player when harvesting starts
// Remove ActiveHarvest when complete or cancelled


DateDecisionRationale
TBDUse bevy_ecsBattle-tested, change detection, parallel systems
TBDKeep tokio runtimebevy_ecs is sync, fits in async tick loop
TBDUse glam for ECSbevy_ecs designed around glam types
TBDnalgebra convert-glam029Automatic .into() between glam↔nalgebra, no manual conversion
TBDNetworkId componentMap bevy Entity to network Uuid
TBDClean rewriteSimpler than dual systems, less maintenance
TBDKeep DashMap for lookupsNetworkIdLookup needs concurrent Uuid↔Entity mapping