ECS Architecture
ECS Architecture
Section titled “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.
Why bevy_ecs?
Section titled “Why bevy_ecs?”| Feature | bevy_ecs | Custom Implementation |
|---|---|---|
| Maturity | Battle-tested, widely used | New, needs testing |
| Change Detection | Built-in Changed<T>, Added<T> | Manual dirty tracking |
| Queries | Ergonomic, type-safe, filtered | Build from scratch |
| Parallel Systems | Automatic with dependency analysis | Manual threading |
| Archetypes | Cache-friendly storage | DashMap (scattered) |
| Resources | First-class support | Manual singleton pattern |
| Maintenance | Active community | You maintain it |
Architecture Overview
Section titled “Architecture Overview”┌─────────────────────────────────────────────────────────────────┐│ 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 ││ │└─────────────────────────────────────────────────────────────────┘Implementation Status
Section titled “Implementation Status”| Component | Status | Description |
|---|---|---|
| Add bevy_ecs + glam | 🔲 Step 1 | Add to Cargo.toml |
| Define components | 🔲 Step 2 | Position, Health, NpcState, etc. |
| Define resources | 🔲 Step 3 | GameTime, SpatialIndex, NetworkIdLookup |
| Create systems | 🔲 Step 4 | NPC tick, combat, regen, snapshot |
| Replace WorldRuntime | 🔲 Step 5 | New GameServer with bevy World |
| Update command handlers | 🔲 Step 6 | WebSocket handlers use ECS |
| Remove legacy code | 🔲 Step 7 | Delete EntityState, EntityStateManager |
What Gets Replaced
Section titled “What Gets Replaced”| Current | Replaced By |
|---|---|
EntityState struct | Multiple focused components |
EntityStateManager | bevy_ecs::World |
DashMap<Uuid, EntityState> | Archetype storage + NetworkIdLookup |
| Manual dirty tracking | Changed<T> queries |
NpcController::tick() | npc_tick_system |
NpcInstance struct | NPC component bundle |
InputProcessor | player_input_system |
ActionManager | harvest_system |
process_tick() | Schedule::run() |
Complete Component Inventory
Section titled “Complete Component Inventory”This section maps every field from the current codebase to ECS components.
EntityState Decomposition
Section titled “EntityState Decomposition”The monolithic EntityState struct becomes multiple focused components:
// BEFORE: entity_state.rspub 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 Field | ECS Component | Notes |
|---|---|---|
entity_id: Uuid | NetworkId(Uuid) | Maps bevy Entity to network UUID |
entity_type: EntityType | Player / Npc / Enemy / Boss marker | Zero-sized marker components for filtering |
username: String | PlayerData { username, character_type } | Player-specific data |
position: Position | Position(glam::Vec3) | Transform component |
rotation: Rotation | Rotation(glam::Quat) | Transform component |
health: f32 | Health { current, max, regen } | With helper methods |
is_alive: bool | Derived from Health | health.is_dead() method |
inventory: Inventory | Inventory { items, max_slots } | Same structure |
animation_state: i32 | AnimationState { state, blend } | Animation blending support |
character_type | Part of PlayerData | Knight, Barbarian, Mage, etc. |
last_update: i64 | LastUpdate(i64) | For stale detection |
last_jump_time | JumpState { last_time, count } | Anti-cheat tracking |
jump_count: u32 | Part of JumpState | Air jump tracking |
NpcInstance Decomposition
Section titled “NpcInstance Decomposition”The NpcInstance struct becomes an NPC entity bundle:
// BEFORE: npc/controller.rspub 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 Field | ECS Component | Notes |
|---|---|---|
entity_id | NetworkId(Uuid) | Same as player |
state: NpcState | NpcState { emotional, combat, behavior, social } | Bitflags preserved |
home_position | HomePosition(Vec3) | Return-to position |
home_waypoint_id | Part of NpcNavigation | Navigation data |
patrol_waypoint_ids | PatrolRoute(Vec<WaypointId>) | Patrol path |
current_path | Part of NpcNavigation | Active path |
path_index | Part of NpcNavigation | Current waypoint index |
target_id | CombatTarget(Option<Entity>) | Uses Entity not Uuid |
last_target_position | Part of TargetTracking | For pursuit |
ticks_since_target_seen | Part of TargetTracking | Target timeout |
attack_damage | AttackStats { damage, range, cooldown } | Combat stats |
attack_cooldown | Part of AttackStats | Cooldown timer |
harvest_target_id | HarvestTarget(Option<u64>) | NPC harvesting |
collected_resources | Inventory | Reuse player inventory |
speed_multiplier | MovementModifier { speed, drift } | Movement variation |
drift_direction | Part of MovementModifier | Patrol drift |
NpcState Bitflags
Section titled “NpcState Bitflags”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:
| Bits | Field | Values |
|---|---|---|
| 0-7 | State | NEUTRAL, ALERT, AFRAID, ANGRY, CURIOUS, HAPPY, SAD, CONFUSED, BERSERK |
| 8-15 | Intensity | 0-255 |
| 16-31 | Duration | Ticks remaining |
| 32-47 | Source | Entity hash that caused emotion |
Combat State Bits:
| Bits | Field | Values |
|---|---|---|
| 0-7 | State | IDLE, ENGAGED, PURSUING, RETREATING, FLANKING, DEFENDING, CASTING, STUNNED, DEAD |
| 8-15 | Attack cooldown | Ticks |
| 16-23 | Ability cooldown | Ticks |
| 24-31 | Combo counter | Current combo |
| 32-47 | Target hash | Current target |
| 48-55 | Threat level | 0-255 |
| 56-63 | Damage modifier | -128 to +127 |
Behavior State Bits:
| Bits | Field | Values |
|---|---|---|
| 0-7 | Primary | IDLE, PATROL, GUARD, HARVEST, FOLLOW, WANDER, FLEE, RETURN_HOME, SCRIPTED |
| 8-15 | Modifiers | INTERRUPTIBLE, LOOPING, URGENT, STEALTHY |
| 16-23 | Waypoint index | Current patrol index |
| 24-31 | Priority | Behavior priority |
| 32-47 | Start tick | When behavior started |
| 48-63 | Target location | Destination hash |
Social State Bits:
| Bits | Field | Values |
|---|---|---|
| 0-7 | Faction | NEUTRAL, PLAYER, TOWN, WILDLIFE, HOSTILE, BOSS |
| 8-15 | Group ID | Squad/group membership |
| 16-23 | Roles | LEADER, SCOUT, HEALER, TANK, DPS |
| 24-31 | Disposition | -128 (hostile) to +127 (friendly) |
| 32-39 | Comms | CALLED_HELP, RECEIVED_HELP, SHARING_TARGET |
HarvestAction → Harvest Components
Section titled “HarvestAction → Harvest Components”// BEFORE: actions.rspub 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 Field | ECS Component | Notes |
|---|---|---|
object_id | HarvestTarget(u64) | On player entity |
player_id | Entity itself | No need to store |
start_position | Part of ActiveHarvest | Anti-cheat validation |
start_time | Part of ActiveHarvest | Progress tracking |
harvest_duration | Part of ActiveHarvest | Total time needed |
last_heartbeat | Part of ActiveHarvest | Timeout detection |
EnvironmentObject → Object Components
Section titled “EnvironmentObject → Object Components”// 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>,}Complete Resources List
Section titled “Complete Resources List”Resources are singleton data shared across systems:
| Resource | Purpose | Source |
|---|---|---|
GameTime | Delta time, elapsed, tick count | Game loop |
GameConfig | World seed, tick rate, chunk size | Config file |
SpatialIndex | Chunk-based entity lookups | EntityStateManager |
NetworkIdLookup | Uuid ↔ Entity mapping | New |
EventChannel | Broadcast sender for snapshots | WorldRuntime |
WaypointGraph | NPC navigation graph | NpcController |
CommandQueue | Incoming player commands | WorldRuntime |
PhysicsHandle | Rapier physics worker channel | PhysicsWorker |
#[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}Complete Systems List
Section titled “Complete Systems List”Systems organized by execution phase:
Input Phase
Section titled “Input Phase”| System | Reads | Writes | Description |
|---|---|---|---|
player_input_system | CommandQueue, NetworkIdLookup | Position, Rotation, AnimationState | Process player movement |
jump_validation_system | JumpState | JumpState, Position | Anti-cheat for jumps |
Pre-Update Phase
Section titled “Pre-Update Phase”| System | Reads | Writes | Description |
|---|---|---|---|
spatial_index_system | Position (Changed) | SpatialIndex | Update chunk assignments |
Update Phase
Section titled “Update Phase”| System | Reads | Writes | Description |
|---|---|---|---|
npc_behavior_system | NpcState, Position, SpatialIndex | NpcState, NpcNavigation | Behavior state machine |
npc_patrol_system | NpcState, PatrolRoute, WaypointGraph | Position, NpcNavigation | Patrol movement |
npc_chase_system | NpcState, CombatTarget | Position | Chase targets |
npc_combat_system | NpcState, AttackStats, CombatTarget | Health (target) | Deal damage |
npc_detection_system | Position, SpatialIndex, NpcState | NpcState, CombatTarget | Detect hostiles |
harvest_progress_system | ActiveHarvest, Position | ActiveHarvest | Update harvest timers |
health_regen_system | Health, GameTime | Health | Regenerate health |
stats_regen_system | Stats, GameTime | Stats | Regenerate MP/EP |
Post-Update Phase
Section titled “Post-Update Phase”| System | Reads | Writes | Description |
|---|---|---|---|
death_system | Health | Commands (despawn) | Handle entity death |
harvest_complete_system | ActiveHarvest | Inventory, Commands | Grant harvest rewards |
cleanup_system | LastUpdate, GameTime | Commands (despawn) | Remove stale entities |
Sync Phase
Section titled “Sync Phase”| System | Reads | Writes | Description |
|---|---|---|---|
entity_spawn_system | Added<Player>, Added<Npc> | EventChannel | Notify new entities |
entity_despawn_system | RemovedComponents<NetworkId> | EventChannel | Notify removed entities |
snapshot_system | Changed<Position>, Changed<Health>, etc. | EventChannel | Delta snapshots |
physics_sync_system | Position (Changed) | PhysicsHandle | Sync to Rapier |
Cargo.toml
Section titled “Cargo.toml”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 NetworkIdLookuprapier3d = { version = "0.31.0", features = ["parallel", "simd-stable"] }glam ↔ nalgebra Interoperability
Section titled “glam ↔ nalgebra Interoperability”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 transformslet 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.
Integration with Tokio
Section titled “Integration with Tokio”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; }}Core Concepts
Section titled “Core Concepts”1. Entities
Section titled “1. Entities”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 entityfn 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 entityfn 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()}2. Components
Section titled “2. Components”Components are plain structs with the #[derive(Component)] attribute.
Transform Components
Section titled “Transform Components”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);Stats Components
Section titled “Stats Components”/// 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 Components
Section titled “Player Components”/// 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 Components
Section titled “NPC Components”/// 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 flagspub 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,}3. Resources
Section titled “3. Resources”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}4. Systems
Section titled “4. Systems”Systems are functions that operate on components via queries.
use bevy_ecs::prelude::*;
/// System to regenerate health for all entitiesfn 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 EPfn 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 changefn spatial_index_system( spatial: Res<SpatialIndex>, query: Query<(Entity, &Position), Changed<Position>>,) { for (entity, position) in &query { spatial.update_entity(entity, position); }}NPC Systems
Section titled “NPC Systems”NPC Tick System
Section titled “NPC Tick System”/// Main NPC behavior tick systemfn 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)}NPC Combat System
Section titled “NPC Combat System”/// Combat system for NPC attacksfn 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 } } } }}Snapshot System with Change Detection
Section titled “Snapshot System with Change Detection”bevy_ecs provides built-in change detection via Changed<T> and Added<T> filters.
use bevy_ecs::prelude::*;
/// Snapshot system using bevy's change detectionfn 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 bitsconst 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>,}Added/Removed Detection
Section titled “Added/Removed Detection”/// Detect newly spawned entitiesfn 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); } }}Schedule Configuration
Section titled “Schedule Configuration”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));}Migration Path (Clean Rewrite)
Section titled “Migration Path (Clean Rewrite)”A clean rewrite is simpler than running dual systems. We replace the game loop entirely.
Step 1: Add Dependencies
Section titled “Step 1: Add Dependencies”# Add to Cargo.tomlbevy_ecs = "0.15"glam = "0.29"Step 2: Create New Module Structure
Section titled “Step 2: Create New Module Structure”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 migrationStep 3: Build Components First
Section titled “Step 3: Build Components First”Start with the data layer - define all components before systems:
// components/mod.rsmod transform;mod stats;mod player;mod npc;mod network;
pub use transform::*;pub use stats::*;pub use player::*;pub use npc::*;pub use network::*;Step 4: Build Resources
Section titled “Step 4: Build Resources”Resources for shared state that systems need:
// resources/mod.rsmod time;mod spatial;mod network;mod waypoints;
pub use time::*;pub use spatial::*;pub use network::*;pub use waypoints::*;Step 5: Build Systems
Section titled “Step 5: Build Systems”Each system is a standalone function - easy to test:
// systems/mod.rsmod 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::*;Step 6: Create GameServer
Section titled “Step 6: Create GameServer”Replace WorldRuntime with new GameServer:
// server.rsuse 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) { ... }}Step 7: Update Command Handlers
Section titled “Step 7: Update Command Handlers”WebSocket command handlers now interact with GameServer:
// commands.rsimpl 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 } }}Step 8: Delete Legacy Code
Section titled “Step 8: Delete Legacy Code”Once everything works, delete:
entity_state.rsworld_runtime.rsnpc/controller.rsnpc/instance.rs- Any other files replaced by ECS
File Structure (Post-Migration)
Section titled “File Structure (Post-Migration)”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 handlingPerformance Benefits
Section titled “Performance Benefits”Archetype Storage
Section titled “Archetype Storage”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
Automatic Parallelism
Section titled “Automatic Parallelism”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 sequentialChange Detection
Section titled “Change Detection”Zero-cost change detection built into component storage:
// Only iterates entities where Position actually changedfor (entity, pos) in query.iter().filter(Changed<Position>) { // ...}Testing
Section titled “Testing”Unit Tests
Section titled “Unit Tests”#[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 }}Migration Checklist
Section titled “Migration Checklist”A prioritized checklist for the clean ECS rewrite. Complete each phase before moving to the next.
Phase 1: Core Infrastructure ⬜
Section titled “Phase 1: Core Infrastructure ⬜”Goal: Set up bevy_ecs foundation without breaking existing code.
| Task | Status | File(s) |
|---|---|---|
Add bevy_ecs, glam to Cargo.toml | ⬜ | Cargo.toml |
Enable convert-glam029 on nalgebra | ⬜ | Cargo.toml |
Create src/game/ecs/mod.rs module | ⬜ | New file |
Create GameTime resource | ⬜ | resources/time.rs |
Create GameConfig resource | ⬜ | resources/config.rs |
Verify cargo check passes | ⬜ | - |
Phase 2: Components ⬜
Section titled “Phase 2: Components ⬜”Goal: Define all ECS components from existing structs.
| Task | Status | File(s) |
|---|---|---|
Position, Rotation, Velocity | ⬜ | components/transform.rs |
Health, Stats | ⬜ | components/stats.rs |
NetworkId | ⬜ | components/network.rs |
Player, PlayerData, Inventory | ⬜ | components/player.rs |
Npc, NpcState, NpcNavigation, NpcCombat | ⬜ | components/npc.rs |
AnimationState, JumpState | ⬜ | components/player.rs |
HarvestTarget, ActiveHarvest | ⬜ | components/harvest.rs |
ObjectId, ResourceData, Harvestable | ⬜ | components/environment.rs |
Marker components: Enemy, Boss | ⬜ | components/markers.rs |
Phase 3: Resources ⬜
Section titled “Phase 3: Resources ⬜”Goal: Define all singleton resources.
| Task | Status | File(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 |
Phase 4: GameServer Shell ⬜
Section titled “Phase 4: GameServer Shell ⬜”Goal: Create new game server that can run alongside old code (for testing).
| Task | Status | File(s) |
|---|---|---|
Create GameServer struct with World | ⬜ | server.rs |
| Insert all resources into World | ⬜ | server.rs |
| Create empty Schedule | ⬜ | ecs/schedule.rs |
Implement GameServer::run() tick loop | ⬜ | server.rs |
| Wire up to tokio runtime | ⬜ | main.rs |
Phase 5: Player Systems ⬜
Section titled “Phase 5: Player Systems ⬜”Goal: Player input and state management.
| Task | Status | File(s) |
|---|---|---|
player_input_system | ⬜ | systems/input.rs |
jump_validation_system | ⬜ | systems/input.rs |
| Player spawn/despawn commands | ⬜ | server.rs |
Port InputProcessor logic | ⬜ | systems/input.rs |
Phase 6: NPC Systems ⬜
Section titled “Phase 6: NPC Systems ⬜”Goal: NPC behavior state machine.
| Task | Status | File(s) |
|---|---|---|
npc_behavior_system (state machine) | ⬜ | systems/npc.rs |
npc_patrol_system | ⬜ | systems/npc.rs |
npc_chase_system | ⬜ | systems/npc.rs |
npc_combat_system | ⬜ | systems/npc.rs |
npc_detection_system | ⬜ | systems/npc.rs |
| NPC spawn system | ⬜ | systems/npc.rs |
Port NpcController::tick logic | ⬜ | systems/npc.rs |
Phase 7: Gameplay Systems ⬜
Section titled “Phase 7: Gameplay Systems ⬜”Goal: Combat, harvesting, regeneration.
| Task | Status | File(s) |
|---|---|---|
health_regen_system | ⬜ | systems/regen.rs |
stats_regen_system | ⬜ | systems/regen.rs |
harvest_progress_system | ⬜ | systems/harvest.rs |
harvest_complete_system | ⬜ | systems/harvest.rs |
death_system | ⬜ | systems/combat.rs |
cleanup_system (stale entities) | ⬜ | systems/cleanup.rs |
Phase 8: Networking Systems ⬜
Section titled “Phase 8: Networking Systems ⬜”Goal: Delta snapshots and sync.
| Task | Status | File(s) |
|---|---|---|
spatial_index_system | ⬜ | systems/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 |
Phase 9: Integration & Cutover ⬜
Section titled “Phase 9: Integration & Cutover ⬜”Goal: Switch production to ECS.
| Task | Status | File(s) |
|---|---|---|
| Wire WebSocket handlers to GameServer | ⬜ | websocket.rs |
| Update protobuf message handling | ⬜ | proto/ |
| Run integration tests | ⬜ | tests/ |
| Load test with multiple clients | ⬜ | - |
| Performance benchmarks | ⬜ | - |
Phase 10: Cleanup ⬜
Section titled “Phase 10: Cleanup ⬜”Goal: Remove legacy code.
| Task | Status | File(s) |
|---|---|---|
Delete entity_state.rs | ⬜ | DELETE |
Delete world_runtime.rs | ⬜ | DELETE |
Delete npc/controller.rs | ⬜ | DELETE |
Delete npc/instance.rs | ⬜ | DELETE |
Delete input_processor.rs | ⬜ | DELETE |
Delete actions.rs (ActionManager) | ⬜ | DELETE |
| Update imports throughout codebase | ⬜ | Various |
Final cargo clippy pass | ⬜ | - |
File-by-File Migration Mapping
Section titled “File-by-File Migration Mapping”Detailed mapping of legacy files to new ECS structure.
Files to DELETE
Section titled “Files to DELETE”| Legacy File | Reason | Replaced By |
|---|---|---|
entity_state.rs | Monolithic EntityState struct | Multiple components |
world_runtime.rs | Old game loop + EntityStateManager | server.rs + bevy World |
npc/controller.rs | NpcController, tick() | systems/npc.rs |
npc/instance.rs | NpcInstance struct | NPC component bundle |
input_processor.rs | InputProcessor queue | systems/input.rs + CommandQueue |
actions.rs | HarvestAction, ActionManager | components/harvest.rs + systems |
Files to CREATE
Section titled “Files to CREATE”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 structFiles to MODIFY
Section titled “Files to MODIFY”| File | Changes |
|---|---|
Cargo.toml | Add bevy_ecs = "0.15", glam = "0.29", update nalgebra features |
main.rs | Replace WorldRuntime with GameServer |
websocket.rs | Use GameServer.handle_command() |
proto/*.rs | Keep as-is (protobuf doesn’t change) |
environment_manager.rs | Port to EnvironmentObject entities (optional - can keep for now) |
physics_worker.rs | Keep physics worker, just update message types for glam |
Legacy → ECS Field Mapping
Section titled “Legacy → ECS Field Mapping”EntityState → Components
Section titled “EntityState → Components”// 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}NpcInstance → Components
Section titled “NpcInstance → Components”// 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}HarvestAction → Components
Section titled “HarvestAction → Components”// 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 cancelledReferences
Section titled “References”Decision Log
Section titled “Decision Log”| Date | Decision | Rationale |
|---|---|---|
| TBD | Use bevy_ecs | Battle-tested, change detection, parallel systems |
| TBD | Keep tokio runtime | bevy_ecs is sync, fits in async tick loop |
| TBD | Use glam for ECS | bevy_ecs designed around glam types |
| TBD | nalgebra convert-glam029 | Automatic .into() between glam↔nalgebra, no manual conversion |
| TBD | NetworkId component | Map bevy Entity to network Uuid |
| TBD | Clean rewrite | Simpler than dual systems, less maintenance |
| TBD | Keep DashMap for lookups | NetworkIdLookup needs concurrent Uuid↔Entity mapping |