Last active
May 27, 2026 13:00
-
-
Save matthewjberger/d4c11ec250cfd1a8d761f54cb0ae51f4 to your computer and use it in GitHub Desktop.
An archetype ECS kernel plus the engine layer (change detection, events, tags, command buffers, resources, schedule) in Rust
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // An archetype ECS kernel plus the engine layer, in one file. | |
| // | |
| // The first half is the same kernel taught in the article: generational | |
| // handles, dense locations, archetype tables, runtime structural change, and | |
| // the two caches. Woven through it is change detection (every component column | |
| // carries a parallel Vec of modification ticks). The second half adds the | |
| // pieces a frame loop needs on top of the kernel: events, tags, command | |
| // buffers, resources, and a schedule. | |
| use std::collections::{HashMap, HashSet}; | |
| // -- Component types ---------------------------------------------------------- | |
| #[derive(Default, Clone, Debug)] | |
| struct Position { x: f32, y: f32 } | |
| #[derive(Default, Clone, Debug)] | |
| struct Velocity { dx: f32, dy: f32 } | |
| #[derive(Default, Clone, Debug)] | |
| struct Vitality { hp: f32 } | |
| const POSITION: u64 = 1 << 0; | |
| const VELOCITY: u64 = 1 << 1; | |
| const VITALITY: u64 = 1 << 2; | |
| const COMPONENT_COUNT: usize = 3; | |
| // -- Entity handle ------------------------------------------------------------ | |
| #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] | |
| struct Entity { index: u32, generation: u32 } | |
| // -- Location ----------------------------------------------------------------- | |
| #[derive(Clone, Copy, Debug)] | |
| struct Location { table: usize, row: usize } | |
| // -- Archetype table ---------------------------------------------------------- | |
| // | |
| // Each component column has a parallel `_changed` Vec holding the tick at which | |
| // every row was last written. Change detection is "the stamp is newer than the | |
| // previous frame's watermark", so no per-frame clearing pass is needed. | |
| struct Table { | |
| mask: u64, | |
| entities: Vec<Entity>, | |
| positions: Vec<Position>, | |
| positions_changed: Vec<u32>, | |
| velocities: Vec<Velocity>, | |
| velocities_changed: Vec<u32>, | |
| vitalities: Vec<Vitality>, | |
| vitalities_changed: Vec<u32>, | |
| add_single: [Option<usize>; COMPONENT_COUNT], | |
| remove_single: [Option<usize>; COMPONENT_COUNT], | |
| add_multi: HashMap<u64, usize>, | |
| remove_multi: HashMap<u64, usize>, | |
| } | |
| impl Default for Table { | |
| fn default() -> Self { | |
| Self { | |
| mask: 0, | |
| entities: Vec::new(), | |
| positions: Vec::new(), | |
| positions_changed: Vec::new(), | |
| velocities: Vec::new(), | |
| velocities_changed: Vec::new(), | |
| vitalities: Vec::new(), | |
| vitalities_changed: Vec::new(), | |
| add_single: [None; COMPONENT_COUNT], | |
| remove_single: [None; COMPONENT_COUNT], | |
| add_multi: HashMap::new(), | |
| remove_multi: HashMap::new(), | |
| } | |
| } | |
| } | |
| // -- Events ------------------------------------------------------------------- | |
| // | |
| // A double-buffered queue. An event sent on frame N is readable through the | |
| // end of frame N+1 and gone by N+2, so every system gets a full frame to react | |
| // regardless of order. update() runs once per frame inside step(). | |
| struct EventQueue<T> { current: Vec<T>, previous: Vec<T> } | |
| impl<T> Default for EventQueue<T> { | |
| fn default() -> Self { Self { current: Vec::new(), previous: Vec::new() } } | |
| } | |
| impl<T> EventQueue<T> { | |
| fn send(&mut self, event: T) { self.current.push(event); } | |
| fn read(&self) -> impl Iterator<Item = &T> { self.previous.iter().chain(self.current.iter()) } | |
| fn drain(&mut self) -> impl Iterator<Item = T> + '_ { self.previous.drain(..).chain(self.current.drain(..)) } | |
| fn update(&mut self) { self.previous.clear(); std::mem::swap(&mut self.current, &mut self.previous); } | |
| } | |
| #[derive(Clone, Copy, Debug)] | |
| struct CollisionEvent { a: Entity, b: Entity } | |
| // -- Resources ---------------------------------------------------------------- | |
| // | |
| // State that belongs to the world rather than any entity. Systems read it | |
| // directly off the World, which is why every system can share one signature. | |
| #[derive(Default)] | |
| struct Resources { delta_time: f32 } | |
| // -- Command buffer ----------------------------------------------------------- | |
| // | |
| // Structural changes cannot happen mid-iteration, so they are queued as plain | |
| // data and applied after the loop ends. | |
| enum Command { | |
| Spawn(u64), | |
| Despawn(Entity), | |
| AddComponents(Entity, u64), | |
| RemoveComponents(Entity, u64), | |
| } | |
| // -- World -------------------------------------------------------------------- | |
| #[derive(Default)] | |
| struct World { | |
| tables: Vec<Table>, | |
| table_map: HashMap<u64, usize>, | |
| query_cache: HashMap<u64, Vec<usize>>, | |
| locations: Vec<Option<Location>>, | |
| generations: Vec<u32>, | |
| free_ids: Vec<u32>, | |
| current_tick: u32, | |
| last_tick: u32, | |
| collisions: EventQueue<CollisionEvent>, | |
| blooming: HashSet<Entity>, | |
| commands: Vec<Command>, | |
| resources: Resources, | |
| } | |
| // current_tick starts at 1, not 0, so writes made before the first step (setup | |
| // spawns and mutations) are strictly greater than the zero-valued watermark and | |
| // are visible to change detection on frame zero. | |
| fn new_world() -> World { | |
| World { current_tick: 1, ..Default::default() } | |
| } | |
| fn step(world: &mut World) { | |
| world.collisions.update(); | |
| world.last_tick = world.current_tick; | |
| world.current_tick = world.current_tick.wrapping_add(1); | |
| } | |
| // -- Allocator operations ----------------------------------------------------- | |
| fn alloc_entity(generations: &mut Vec<u32>, free_ids: &mut Vec<u32>) -> Entity { | |
| if let Some(index) = free_ids.pop() { | |
| Entity { index, generation: generations[index as usize] } | |
| } else { | |
| let index = generations.len() as u32; | |
| generations.push(0); | |
| Entity { index, generation: 0 } | |
| } | |
| } | |
| fn free_entity(generations: &mut Vec<u32>, free_ids: &mut Vec<u32>, e: Entity) { | |
| generations[e.index as usize] = generations[e.index as usize].wrapping_add(1); | |
| free_ids.push(e.index); | |
| } | |
| fn is_live(generations: &[u32], e: Entity) -> bool { | |
| generations.get(e.index as usize).copied() == Some(e.generation) | |
| } | |
| // -- Location operations ------------------------------------------------------ | |
| fn get_location(locations: &[Option<Location>], e: Entity) -> Option<Location> { | |
| locations.get(e.index as usize).and_then(|l| *l) | |
| } | |
| fn set_location(locations: &mut Vec<Option<Location>>, e: Entity, loc: Location) { | |
| let i = e.index as usize; | |
| if i >= locations.len() { locations.resize(i + 1, None); } | |
| locations[i] = Some(loc); | |
| } | |
| fn clear_location(locations: &mut Vec<Option<Location>>, e: Entity) { | |
| if let Some(s) = locations.get_mut(e.index as usize) { *s = None; } | |
| } | |
| // -- Query cache operations --------------------------------------------------- | |
| fn query_tables<'a>( | |
| cache: &'a mut HashMap<u64, Vec<usize>>, | |
| tables: &[Table], | |
| required: u64, | |
| ) -> &'a [usize] { | |
| cache.entry(required).or_insert_with(|| { | |
| tables.iter().enumerate() | |
| .filter(|(_, t)| t.mask & required == required) | |
| .map(|(i, _)| i) | |
| .collect() | |
| }) | |
| } | |
| fn on_new_table(cache: &mut HashMap<u64, Vec<usize>>, new_mask: u64, new_index: usize) { | |
| for (required, indices) in cache.iter_mut() { | |
| if new_mask & required == *required { | |
| indices.push(new_index); | |
| } | |
| } | |
| } | |
| // -- Table operations --------------------------------------------------------- | |
| fn component_index(single_bit: u64) -> Option<usize> { | |
| match single_bit { | |
| POSITION => Some(0), | |
| VELOCITY => Some(1), | |
| VITALITY => Some(2), | |
| _ => None, | |
| } | |
| } | |
| fn table_push(table: &mut Table, e: Entity, tick: u32) -> usize { | |
| let row = table.entities.len(); | |
| table.entities.push(e); | |
| if table.mask & POSITION != 0 { table.positions.push(Position::default()); table.positions_changed.push(tick); } | |
| if table.mask & VELOCITY != 0 { table.velocities.push(Velocity::default()); table.velocities_changed.push(tick); } | |
| if table.mask & VITALITY != 0 { table.vitalities.push(Vitality::default()); table.vitalities_changed.push(tick); } | |
| row | |
| } | |
| fn table_swap_remove(table: &mut Table, idx: usize) -> Option<Entity> { | |
| let last = table.entities.len().saturating_sub(1); | |
| let moved = if idx < last { Some(table.entities[last]) } else { None }; | |
| table.entities.swap_remove(idx); | |
| if table.mask & POSITION != 0 { table.positions.swap_remove(idx); table.positions_changed.swap_remove(idx); } | |
| if table.mask & VELOCITY != 0 { table.velocities.swap_remove(idx); table.velocities_changed.swap_remove(idx); } | |
| if table.mask & VITALITY != 0 { table.vitalities.swap_remove(idx); table.vitalities_changed.swap_remove(idx); } | |
| moved | |
| } | |
| fn table_move_row(src: &mut Table, idx: usize, dst: &mut Table, tick: u32) -> (usize, Option<Entity>) { | |
| let entity = src.entities[idx]; | |
| dst.entities.push(entity); | |
| if dst.mask & POSITION != 0 { | |
| dst.positions.push(if src.mask & POSITION != 0 { std::mem::take(&mut src.positions[idx]) } else { Position::default() }); | |
| dst.positions_changed.push(tick); | |
| } | |
| if dst.mask & VELOCITY != 0 { | |
| dst.velocities.push(if src.mask & VELOCITY != 0 { std::mem::take(&mut src.velocities[idx]) } else { Velocity::default() }); | |
| dst.velocities_changed.push(tick); | |
| } | |
| if dst.mask & VITALITY != 0 { | |
| dst.vitalities.push(if src.mask & VITALITY != 0 { std::mem::take(&mut src.vitalities[idx]) } else { Vitality::default() }); | |
| dst.vitalities_changed.push(tick); | |
| } | |
| let dst_row = dst.entities.len() - 1; | |
| let back_filled = table_swap_remove(src, idx); | |
| (dst_row, back_filled) | |
| } | |
| // -- Table registry ----------------------------------------------------------- | |
| fn get_or_create_table( | |
| tables: &mut Vec<Table>, | |
| table_map: &mut HashMap<u64, usize>, | |
| query_cache: &mut HashMap<u64, Vec<usize>>, | |
| mask: u64, | |
| ) -> usize { | |
| if let Some(&i) = table_map.get(&mask) { return i; } | |
| let i = tables.len(); | |
| tables.push(Table { mask, ..Default::default() }); | |
| table_map.insert(mask, i); | |
| on_new_table(query_cache, mask, i); | |
| i | |
| } | |
| // -- Migration ---------------------------------------------------------------- | |
| fn migrate(world: &mut World, e: Entity, loc: Location, new_mask: u64) { | |
| let src_ti = loc.table; | |
| let old_mask = world.tables[src_ti].mask; | |
| let delta = old_mask ^ new_mask; | |
| let adding = new_mask > old_mask; | |
| let tick = world.current_tick; | |
| let dst_ti = if delta.count_ones() == 1 { | |
| let ci = component_index(delta).unwrap(); | |
| let cached = if adding { | |
| world.tables[src_ti].add_single[ci] | |
| } else { | |
| world.tables[src_ti].remove_single[ci] | |
| }; | |
| cached.unwrap_or_else(|| { | |
| let dst = get_or_create_table( | |
| &mut world.tables, &mut world.table_map, &mut world.query_cache, new_mask, | |
| ); | |
| if adding { world.tables[src_ti].add_single[ci] = Some(dst); } | |
| else { world.tables[src_ti].remove_single[ci] = Some(dst); } | |
| dst | |
| }) | |
| } else { | |
| let cached = if adding { | |
| world.tables[src_ti].add_multi.get(&delta).copied() | |
| } else { | |
| world.tables[src_ti].remove_multi.get(&delta).copied() | |
| }; | |
| cached.unwrap_or_else(|| { | |
| let dst = get_or_create_table( | |
| &mut world.tables, &mut world.table_map, &mut world.query_cache, new_mask, | |
| ); | |
| if adding { world.tables[src_ti].add_multi.insert(delta, dst); } | |
| else { world.tables[src_ti].remove_multi.insert(delta, dst); } | |
| dst | |
| }) | |
| }; | |
| assert_ne!(src_ti, dst_ti); | |
| let (src, dst) = if src_ti < dst_ti { | |
| let (left, right) = world.tables.split_at_mut(dst_ti); | |
| (&mut left[src_ti], &mut right[0]) | |
| } else { | |
| let (left, right) = world.tables.split_at_mut(src_ti); | |
| (&mut right[0], &mut left[dst_ti]) | |
| }; | |
| let (dst_row, back_filled) = table_move_row(src, loc.row, dst, tick); | |
| set_location(&mut world.locations, e, Location { table: dst_ti, row: dst_row }); | |
| if let Some(moved) = back_filled { | |
| set_location(&mut world.locations, moved, Location { table: src_ti, row: loc.row }); | |
| } | |
| } | |
| // -- World operations --------------------------------------------------------- | |
| fn spawn(world: &mut World, mask: u64) -> Entity { | |
| let e = alloc_entity(&mut world.generations, &mut world.free_ids); | |
| let ti = get_or_create_table( | |
| &mut world.tables, &mut world.table_map, &mut world.query_cache, mask, | |
| ); | |
| let tick = world.current_tick; | |
| let row = table_push(&mut world.tables[ti], e, tick); | |
| set_location(&mut world.locations, e, Location { table: ti, row }); | |
| e | |
| } | |
| fn despawn(world: &mut World, e: Entity) { | |
| if !is_live(&world.generations, e) { return; } | |
| let loc = get_location(&world.locations, e).unwrap(); | |
| if let Some(moved) = table_swap_remove(&mut world.tables[loc.table], loc.row) { | |
| set_location(&mut world.locations, moved, Location { table: loc.table, row: loc.row }); | |
| } | |
| clear_location(&mut world.locations, e); | |
| world.blooming.remove(&e); | |
| free_entity(&mut world.generations, &mut world.free_ids, e); | |
| } | |
| fn add_components(world: &mut World, e: Entity, added: u64) { | |
| if !is_live(&world.generations, e) { return; } | |
| let loc = get_location(&world.locations, e).unwrap(); | |
| let old_mask = world.tables[loc.table].mask; | |
| if old_mask & added == added { return; } | |
| migrate(world, e, loc, old_mask | added); | |
| } | |
| fn remove_components(world: &mut World, e: Entity, removed: u64) { | |
| if !is_live(&world.generations, e) { return; } | |
| let loc = get_location(&world.locations, e).unwrap(); | |
| let old_mask = world.tables[loc.table].mask; | |
| if old_mask & removed == 0 { return; } | |
| migrate(world, e, loc, old_mask & !removed); | |
| } | |
| // -- Component accessors ------------------------------------------------------ | |
| // | |
| // The mutable accessors stamp the row with current_tick before handing out the | |
| // reference. This is conservative (it stamps whether or not the caller writes) | |
| // but it keeps the common path honest without a guard type. Hot inner loops | |
| // that touch the column arrays directly stamp `_changed` themselves. | |
| fn get_position(world: &World, e: Entity) -> Option<&Position> { | |
| if !is_live(&world.generations, e) { return None; } | |
| let loc = get_location(&world.locations, e)?; | |
| let t = &world.tables[loc.table]; | |
| (t.mask & POSITION != 0).then(|| &t.positions[loc.row]) | |
| } | |
| fn get_velocity_mut(world: &mut World, e: Entity) -> Option<&mut Velocity> { | |
| if !is_live(&world.generations, e) { return None; } | |
| let loc = get_location(&world.locations, e)?; | |
| let tick = world.current_tick; | |
| let t = &mut world.tables[loc.table]; | |
| (t.mask & VELOCITY != 0).then(|| { t.velocities_changed[loc.row] = tick; &mut t.velocities[loc.row] }) | |
| } | |
| fn get_vitality_mut(world: &mut World, e: Entity) -> Option<&mut Vitality> { | |
| if !is_live(&world.generations, e) { return None; } | |
| let loc = get_location(&world.locations, e)?; | |
| let tick = world.current_tick; | |
| let t = &mut world.tables[loc.table]; | |
| (t.mask & VITALITY != 0).then(|| { t.vitalities_changed[loc.row] = tick; &mut t.vitalities[loc.row] }) | |
| } | |
| // -- Change detection query --------------------------------------------------- | |
| // | |
| // Every entity whose position was written since the previous frame's watermark. | |
| // A renderer pushes only these to the GPU instead of touching every transform. | |
| fn positions_changed_since_step(world: &World) -> Vec<Entity> { | |
| let mut out = Vec::new(); | |
| for t in &world.tables { | |
| if t.mask & POSITION == 0 { continue; } | |
| for i in 0..t.entities.len() { | |
| if t.positions_changed[i] > world.last_tick { | |
| out.push(t.entities[i]); | |
| } | |
| } | |
| } | |
| out | |
| } | |
| // -- Events ------------------------------------------------------------------- | |
| fn send_collision(world: &mut World, event: CollisionEvent) { | |
| world.collisions.send(event); | |
| } | |
| fn read_collisions(world: &World) -> impl Iterator<Item = &CollisionEvent> { | |
| world.collisions.read() | |
| } | |
| fn drain_collisions(world: &mut World) -> impl Iterator<Item = CollisionEvent> + '_ { | |
| world.collisions.drain() | |
| } | |
| // -- Tags --------------------------------------------------------------------- | |
| // | |
| // A tag is membership in a HashSet, not a bit in the archetype mask. Flipping | |
| // it is an O(1) insert or remove with no table migration, which is what makes | |
| // it the right home for markers that change often. | |
| fn add_blooming(world: &mut World, e: Entity) { | |
| if is_live(&world.generations, e) { world.blooming.insert(e); } | |
| } | |
| fn remove_blooming(world: &mut World, e: Entity) -> bool { | |
| world.blooming.remove(&e) | |
| } | |
| fn is_blooming(world: &World, e: Entity) -> bool { | |
| world.blooming.contains(&e) | |
| } | |
| fn query_blooming(world: &World) -> impl Iterator<Item = Entity> + '_ { | |
| world.blooming.iter().copied() | |
| } | |
| // -- Command buffer ----------------------------------------------------------- | |
| fn queue_spawn(world: &mut World, mask: u64) { world.commands.push(Command::Spawn(mask)); } | |
| fn queue_despawn(world: &mut World, e: Entity) { world.commands.push(Command::Despawn(e)); } | |
| fn queue_add_components(world: &mut World, e: Entity, mask: u64) { world.commands.push(Command::AddComponents(e, mask)); } | |
| fn queue_remove_components(world: &mut World, e: Entity, mask: u64) { world.commands.push(Command::RemoveComponents(e, mask)); } | |
| fn apply_commands(world: &mut World) { | |
| let commands = std::mem::take(&mut world.commands); | |
| for command in commands { | |
| match command { | |
| Command::Spawn(mask) => { spawn(world, mask); } | |
| Command::Despawn(e) => despawn(world, e), | |
| Command::AddComponents(e, mask) => add_components(world, e, mask), | |
| Command::RemoveComponents(e, mask) => remove_components(world, e, mask), | |
| } | |
| } | |
| } | |
| // -- Schedule ----------------------------------------------------------------- | |
| // | |
| // A named, ordered list of systems. Every system shares the signature | |
| // fn(&mut World) and reads whatever it needs (delta time, input) out of | |
| // world.resources, so the schedule can run them with no per-system wiring. | |
| #[derive(Default)] | |
| struct Schedule { systems: Vec<(&'static str, fn(&mut World))> } | |
| fn schedule_push(schedule: &mut Schedule, name: &'static str, system: fn(&mut World)) { | |
| schedule.systems.push((name, system)); | |
| } | |
| fn schedule_run(schedule: &Schedule, world: &mut World) { | |
| for (_, system) in &schedule.systems { | |
| system(world); | |
| } | |
| } | |
| // -- Systems ------------------------------------------------------------------ | |
| fn wind_system(world: &mut World) { | |
| let dt = world.resources.delta_time; | |
| let tick = world.current_tick; | |
| let table_indices = query_tables(&mut world.query_cache, &world.tables, POSITION | VELOCITY).to_vec(); | |
| for ti in table_indices { | |
| let t = &mut world.tables[ti]; | |
| for i in 0..t.entities.len() { | |
| t.positions[i].x += t.velocities[i].dx * dt; | |
| t.positions[i].y += t.velocities[i].dy * dt; | |
| t.positions_changed[i] = tick; | |
| } | |
| } | |
| } | |
| fn sunlight_system(world: &mut World) { | |
| let dt = world.resources.delta_time; | |
| let tick = world.current_tick; | |
| let table_indices = query_tables(&mut world.query_cache, &world.tables, VITALITY).to_vec(); | |
| for ti in table_indices { | |
| let t = &mut world.tables[ti]; | |
| for i in 0..t.entities.len() { | |
| t.vitalities[i].hp = (t.vitalities[i].hp + 5.0 * dt).min(100.0); | |
| t.vitalities_changed[i] = tick; | |
| } | |
| } | |
| } | |
| // -- Demo --------------------------------------------------------------------- | |
| fn main() { | |
| let mut world = new_world(); | |
| world.resources.delta_time = 1.0; | |
| let mut schedule = Schedule::default(); | |
| schedule_push(&mut schedule, "wind", wind_system); | |
| schedule_push(&mut schedule, "sunlight", sunlight_system); | |
| // Setup: a seedling that drifts and is alive, and a rock that only sits. | |
| let seedling = spawn(&mut world, POSITION | VELOCITY | VITALITY); | |
| get_velocity_mut(&mut world, seedling).unwrap().dy = 2.0; | |
| get_vitality_mut(&mut world, seedling).unwrap().hp = 80.0; | |
| let rock = spawn(&mut world, POSITION); | |
| // Tags: mark the seedling as blooming. No archetype migration happens. | |
| add_blooming(&mut world, seedling); | |
| println!("blooming: {:?}", query_blooming(&world).collect::<Vec<_>>()); | |
| println!("rock blooming? {}", is_blooming(&world, rock)); | |
| // Close out setup so its writes fall behind the watermark. | |
| step(&mut world); | |
| // -- One frame ------------------------------------------------------------ | |
| schedule_run(&schedule, &mut world); | |
| // Change detection: the seedling moved under wind, the rock did not. | |
| println!("moved this frame: {:?}", positions_changed_since_step(&world)); | |
| // Events: the collision is readable now and through the next frame. | |
| send_collision(&mut world, CollisionEvent { a: seedling, b: rock }); | |
| for collision in read_collisions(&world) { | |
| println!("collision: {:?} touched {:?}", collision.a, collision.b); | |
| } | |
| // Command buffer: queue structural change, apply after the frame's reads. | |
| queue_despawn(&mut world, rock); | |
| apply_commands(&mut world); | |
| println!("rock live after apply: {}", is_live(&world.generations, rock)); | |
| step(&mut world); | |
| // The collision survives one step, then is gone. | |
| println!("collisions after one step: {}", read_collisions(&world).count()); | |
| step(&mut world); | |
| println!("collisions after two steps: {}", read_collisions(&world).count()); | |
| println!("seedling pos: {:?}", get_position(&world, seedling)); | |
| println!("seedling hp: {:?}", get_vitality_mut(&mut world, seedling).map(|v| v.hp)); | |
| // Drain any stragglers, retire the bloom tag, and round-trip a component | |
| // through the command buffer to show add and remove queueing together. | |
| let drained: Vec<CollisionEvent> = drain_collisions(&mut world).collect(); | |
| println!("drained {} collision(s)", drained.len()); | |
| remove_blooming(&mut world, seedling); | |
| println!("seedling blooming? {}", is_blooming(&world, seedling)); | |
| queue_spawn(&mut world, POSITION | VITALITY); | |
| queue_remove_components(&mut world, seedling, VELOCITY); | |
| queue_add_components(&mut world, seedling, VELOCITY); | |
| apply_commands(&mut world); | |
| println!("seedling has velocity? {}", get_velocity_mut(&mut world, seedling).is_some()); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment