Skip to content

Instantly share code, notes, and snippets.

@matthewjberger
Created May 16, 2026 03:04
Show Gist options
  • Select an option

  • Save matthewjberger/4578b6d03514523f5f345951c7395b40 to your computer and use it in GitHub Desktop.

Select an option

Save matthewjberger/4578b6d03514523f5f345951c7395b40 to your computer and use it in GitHub Desktop.
Build your own ECS in Rust, part 3: change detection, events, tags, commands
use std::collections::{HashMap, HashSet};
#[derive(Default, Copy, Clone, Debug, Eq, PartialEq, Hash)]
pub struct Entity {
pub id: u32,
pub generation: u32,
}
#[derive(Default)]
pub struct EntityAllocator {
pub next_id: u32,
pub free: Vec<(u32, u32)>,
}
impl EntityAllocator {
pub fn allocate(&mut self) -> Entity {
if let Some((id, generation)) = self.free.pop() {
Entity { id, generation }
} else {
let id = self.next_id;
self.next_id += 1;
Entity { id, generation: 0 }
}
}
pub fn deallocate(&mut self, entity: Entity) {
self.free.push((entity.id, entity.generation.wrapping_add(1)));
}
}
#[derive(Default, Copy, Clone)]
pub struct EntityLocation {
pub generation: u32,
pub table_index: u32,
pub array_index: u32,
pub allocated: bool,
}
#[derive(Default)]
pub struct EntityLocations {
pub locations: Vec<EntityLocation>,
}
impl EntityLocations {
pub fn ensure_slot(&mut self, id: u32) {
let needed = id as usize + 1;
if needed > self.locations.len() {
self.locations.resize(needed, EntityLocation::default());
}
}
pub fn get(&self, entity: Entity) -> Option<(usize, usize)> {
let location = self.locations.get(entity.id as usize)?;
if !location.allocated || location.generation != entity.generation {
return None;
}
Some((location.table_index as usize, location.array_index as usize))
}
pub fn set(&mut self, entity: Entity, table_index: usize, array_index: usize) {
self.ensure_slot(entity.id);
self.locations[entity.id as usize] = EntityLocation {
generation: entity.generation,
table_index: table_index as u32,
array_index: array_index as u32,
allocated: true,
};
}
pub fn mark_deallocated(&mut self, id: u32) {
if let Some(location) = self.locations.get_mut(id as usize) {
location.allocated = false;
}
}
}
#[derive(Default, Clone, Debug)]
pub struct Position {
pub x: f32,
pub y: f32,
}
#[derive(Default, Clone, Debug)]
pub struct Velocity {
pub x: f32,
pub y: f32,
}
pub const POSITION: u64 = 1 << 0;
pub const VELOCITY: u64 = 1 << 1;
pub const COMPONENT_COUNT: usize = 2;
pub fn component_index(mask: u64) -> Option<usize> {
match mask {
POSITION => Some(0),
VELOCITY => Some(1),
_ => None,
}
}
#[derive(Default)]
pub struct ComponentArrays {
pub mask: u64,
pub entities: Vec<Entity>,
pub positions: Vec<Position>,
pub positions_changed: Vec<u32>,
pub velocities: Vec<Velocity>,
pub velocities_changed: Vec<u32>,
}
#[derive(Default, Clone)]
pub struct TableEdges {
pub add_edges: [Option<usize>; COMPONENT_COUNT],
pub remove_edges: [Option<usize>; COMPONENT_COUNT],
}
#[derive(Clone)]
pub struct EventQueue<T> {
pub current: Vec<T>,
pub previous: Vec<T>,
}
impl<T> Default for EventQueue<T> {
fn default() -> Self {
Self {
current: Vec::new(),
previous: Vec::new(),
}
}
}
impl<T> EventQueue<T> {
pub fn send(&mut self, event: T) {
self.current.push(event);
}
pub fn read(&self) -> impl Iterator<Item = &T> {
self.previous.iter().chain(self.current.iter())
}
pub fn drain(&mut self) -> impl Iterator<Item = T> + '_ {
self.previous.drain(..).chain(self.current.drain(..))
}
pub fn update(&mut self) {
self.previous.clear();
std::mem::swap(&mut self.current, &mut self.previous);
}
pub fn len(&self) -> usize {
self.current.len() + self.previous.len()
}
pub fn is_empty(&self) -> bool {
self.current.is_empty() && self.previous.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct CollisionEvent {
pub entity_a: Entity,
pub entity_b: Entity,
}
pub enum Command {
Spawn { mask: u64 },
Despawn { entity: Entity },
AddComponents { entity: Entity, mask: u64 },
RemoveComponents { entity: Entity, mask: u64 },
SetPosition { entity: Entity, value: Position },
SetVelocity { entity: Entity, value: Velocity },
AddPlayer { entity: Entity },
RemovePlayer { entity: Entity },
AddEnemy { entity: Entity },
RemoveEnemy { entity: Entity },
}
#[derive(Default)]
pub struct World {
pub allocator: EntityAllocator,
pub entity_locations: EntityLocations,
pub tables: Vec<ComponentArrays>,
pub table_lookup: HashMap<u64, usize>,
pub table_edges: Vec<TableEdges>,
pub query_cache: HashMap<u64, Vec<usize>>,
pub current_tick: u32,
pub last_tick: u32,
pub collisions: EventQueue<CollisionEvent>,
pub players: HashSet<Entity>,
pub enemies: HashSet<Entity>,
pub command_buffer: Vec<Command>,
}
impl World {
pub fn invalidate_query_cache_for_new_table(
&mut self,
new_mask: u64,
new_table_index: usize,
) {
for (query_mask, cached_tables) in self.query_cache.iter_mut() {
if new_mask & query_mask == *query_mask {
cached_tables.push(new_table_index);
}
}
}
pub fn get_or_create_table(&mut self, mask: u64) -> usize {
if let Some(&index) = self.table_lookup.get(&mask) {
return index;
}
let new_table_index = self.tables.len();
self.tables.push(ComponentArrays {
mask,
..Default::default()
});
self.table_edges.push(TableEdges::default());
self.table_lookup.insert(mask, new_table_index);
self.invalidate_query_cache_for_new_table(mask, new_table_index);
for component_bit in [POSITION, VELOCITY] {
let Some(component_index_value) = component_index(component_bit) else {
continue;
};
for (existing_index, existing) in self.tables.iter().enumerate() {
if existing.mask | component_bit == mask {
self.table_edges[existing_index].add_edges[component_index_value] =
Some(new_table_index);
}
if existing.mask & !component_bit == mask {
self.table_edges[existing_index].remove_edges[component_index_value] =
Some(new_table_index);
}
}
}
new_table_index
}
pub fn cached_tables(&mut self, mask: u64) -> &[usize] {
if !self.query_cache.contains_key(&mask) {
let matching: Vec<usize> = self
.tables
.iter()
.enumerate()
.filter(|(_, table)| table.mask & mask == mask)
.map(|(index, _)| index)
.collect();
self.query_cache.insert(mask, matching);
}
&self.query_cache[&mask]
}
pub fn current_tick(&self) -> u32 {
self.current_tick
}
pub fn last_tick(&self) -> u32 {
self.last_tick
}
pub fn step(&mut self) {
self.collisions.update();
self.last_tick = self.current_tick;
self.current_tick = self.current_tick.wrapping_add(1);
}
pub fn spawn(&mut self, mask: u64) -> Entity {
let entity = self.allocator.allocate();
let table_index = self.get_or_create_table(mask);
let current_tick = self.current_tick;
let table = &mut self.tables[table_index];
let array_index = table.entities.len();
table.entities.push(entity);
if mask & POSITION != 0 {
table.positions.push(Position::default());
table.positions_changed.push(current_tick);
}
if mask & VELOCITY != 0 {
table.velocities.push(Velocity::default());
table.velocities_changed.push(current_tick);
}
self.entity_locations.set(entity, table_index, array_index);
entity
}
pub fn get_position(&self, entity: Entity) -> Option<&Position> {
let (table_index, array_index) = self.entity_locations.get(entity)?;
let table = &self.tables[table_index];
if table.mask & POSITION == 0 {
return None;
}
Some(&table.positions[array_index])
}
pub fn get_position_mut(&mut self, entity: Entity) -> Option<&mut Position> {
let (table_index, array_index) = self.entity_locations.get(entity)?;
let current_tick = self.current_tick;
let table = &mut self.tables[table_index];
if table.mask & POSITION == 0 {
return None;
}
table.positions_changed[array_index] = current_tick;
Some(&mut table.positions[array_index])
}
pub fn get_velocity(&self, entity: Entity) -> Option<&Velocity> {
let (table_index, array_index) = self.entity_locations.get(entity)?;
let table = &self.tables[table_index];
if table.mask & VELOCITY == 0 {
return None;
}
Some(&table.velocities[array_index])
}
pub fn get_velocity_mut(&mut self, entity: Entity) -> Option<&mut Velocity> {
let (table_index, array_index) = self.entity_locations.get(entity)?;
let current_tick = self.current_tick;
let table = &mut self.tables[table_index];
if table.mask & VELOCITY == 0 {
return None;
}
table.velocities_changed[array_index] = current_tick;
Some(&mut table.velocities[array_index])
}
pub fn set_position(&mut self, entity: Entity, value: Position) {
let Some((table_index, array_index)) = self.entity_locations.get(entity) else {
return;
};
let current_tick = self.current_tick;
if self.tables[table_index].mask & POSITION != 0 {
self.tables[table_index].positions[array_index] = value;
self.tables[table_index].positions_changed[array_index] = current_tick;
return;
}
self.add_components(entity, POSITION);
if let Some((table_index, array_index)) = self.entity_locations.get(entity) {
self.tables[table_index].positions[array_index] = value;
self.tables[table_index].positions_changed[array_index] = current_tick;
}
}
pub fn set_velocity(&mut self, entity: Entity, value: Velocity) {
let Some((table_index, array_index)) = self.entity_locations.get(entity) else {
return;
};
let current_tick = self.current_tick;
if self.tables[table_index].mask & VELOCITY != 0 {
self.tables[table_index].velocities[array_index] = value;
self.tables[table_index].velocities_changed[array_index] = current_tick;
return;
}
self.add_components(entity, VELOCITY);
if let Some((table_index, array_index)) = self.entity_locations.get(entity) {
self.tables[table_index].velocities[array_index] = value;
self.tables[table_index].velocities_changed[array_index] = current_tick;
}
}
pub fn move_entity(
&mut self,
entity: Entity,
from_table: usize,
from_index: usize,
to_table: usize,
) {
let from_mask = self.tables[from_table].mask;
let current_tick = self.current_tick;
let position = if from_mask & POSITION != 0 {
Some(std::mem::take(
&mut self.tables[from_table].positions[from_index],
))
} else {
None
};
let velocity = if from_mask & VELOCITY != 0 {
Some(std::mem::take(
&mut self.tables[from_table].velocities[from_index],
))
} else {
None
};
let to_array_index = {
let to = &mut self.tables[to_table];
let array_index = to.entities.len();
to.entities.push(entity);
if to.mask & POSITION != 0 {
to.positions.push(position.unwrap_or_default());
to.positions_changed.push(current_tick);
}
if to.mask & VELOCITY != 0 {
to.velocities.push(velocity.unwrap_or_default());
to.velocities_changed.push(current_tick);
}
array_index
};
self.entity_locations.set(entity, to_table, to_array_index);
let from = &mut self.tables[from_table];
let last_index = from.entities.len() - 1;
let swapped = if from_index < last_index {
Some(from.entities[last_index])
} else {
None
};
from.entities.swap_remove(from_index);
if from.mask & POSITION != 0 {
from.positions.swap_remove(from_index);
from.positions_changed.swap_remove(from_index);
}
if from.mask & VELOCITY != 0 {
from.velocities.swap_remove(from_index);
from.velocities_changed.swap_remove(from_index);
}
if let Some(moved) = swapped {
self.entity_locations.set(moved, from_table, from_index);
}
}
pub fn add_components(&mut self, entity: Entity, mask: u64) -> bool {
let Some((table_index, array_index)) = self.entity_locations.get(entity) else {
return false;
};
let current_mask = self.tables[table_index].mask;
if current_mask & mask == mask {
return true;
}
let cached = if mask.count_ones() == 1 {
component_index(mask)
.and_then(|index| self.table_edges[table_index].add_edges[index])
} else {
None
};
let new_table_index = match cached {
Some(index) => index,
None => self.get_or_create_table(current_mask | mask),
};
self.move_entity(entity, table_index, array_index, new_table_index);
true
}
pub fn remove_components(&mut self, entity: Entity, mask: u64) -> bool {
let Some((table_index, array_index)) = self.entity_locations.get(entity) else {
return false;
};
let current_mask = self.tables[table_index].mask;
if current_mask & mask == 0 {
return true;
}
let cached = if mask.count_ones() == 1 {
component_index(mask)
.and_then(|index| self.table_edges[table_index].remove_edges[index])
} else {
None
};
let new_table_index = match cached {
Some(index) => index,
None => self.get_or_create_table(current_mask & !mask),
};
self.move_entity(entity, table_index, array_index, new_table_index);
true
}
pub fn despawn(&mut self, entity: Entity) -> bool {
let Some((table_index, array_index)) = self.entity_locations.get(entity) else {
return false;
};
self.entity_locations.mark_deallocated(entity.id);
self.allocator.deallocate(entity);
self.players.remove(&entity);
self.enemies.remove(&entity);
let table = &mut self.tables[table_index];
let last_index = table.entities.len() - 1;
let swapped = if array_index < last_index {
Some(table.entities[last_index])
} else {
None
};
table.entities.swap_remove(array_index);
if table.mask & POSITION != 0 {
table.positions.swap_remove(array_index);
table.positions_changed.swap_remove(array_index);
}
if table.mask & VELOCITY != 0 {
table.velocities.swap_remove(array_index);
table.velocities_changed.swap_remove(array_index);
}
if let Some(moved) = swapped {
self.entity_locations.set(moved, table_index, array_index);
}
true
}
pub fn query_entities(&self, mask: u64) -> impl Iterator<Item = Entity> + '_ {
self.tables
.iter()
.filter(move |table| table.mask & mask == mask)
.flat_map(|table| table.entities.iter().copied())
}
pub fn for_each<F>(&self, include: u64, exclude: u64, mut f: F)
where
F: FnMut(Entity, &ComponentArrays, usize),
{
for table in &self.tables {
if table.mask & include != include || table.mask & exclude != 0 {
continue;
}
for array_index in 0..table.entities.len() {
let entity = table.entities[array_index];
f(entity, table, array_index);
}
}
}
pub fn for_each_mut<F>(&mut self, include: u64, exclude: u64, mut f: F)
where
F: FnMut(Entity, &mut ComponentArrays, usize),
{
let table_indices: Vec<usize> = self.cached_tables(include).to_vec();
for table_index in table_indices {
let table = &mut self.tables[table_index];
if table.mask & exclude != 0 {
continue;
}
for array_index in 0..table.entities.len() {
let entity = table.entities[array_index];
f(entity, table, array_index);
}
}
}
pub fn for_each_mut_changed<F>(&mut self, include: u64, exclude: u64, mut f: F)
where
F: FnMut(Entity, &mut ComponentArrays, usize),
{
let since_tick = self.last_tick;
let table_indices: Vec<usize> = self.cached_tables(include).to_vec();
for table_index in table_indices {
let table = &mut self.tables[table_index];
if table.mask & exclude != 0 {
continue;
}
for array_index in 0..table.entities.len() {
let mut changed = false;
if include & POSITION != 0
&& table.mask & POSITION != 0
&& table.positions_changed[array_index] > since_tick
{
changed = true;
}
if include & VELOCITY != 0
&& table.mask & VELOCITY != 0
&& table.velocities_changed[array_index] > since_tick
{
changed = true;
}
if changed {
let entity = table.entities[array_index];
f(entity, table, array_index);
}
}
}
}
pub fn send_collision(&mut self, event: CollisionEvent) {
self.collisions.send(event);
}
pub fn read_collisions(&self) -> impl Iterator<Item = &CollisionEvent> {
self.collisions.read()
}
pub fn drain_collisions(&mut self) -> impl Iterator<Item = CollisionEvent> + '_ {
self.collisions.drain()
}
pub fn add_player(&mut self, entity: Entity) {
if self.entity_locations.get(entity).is_some() {
self.players.insert(entity);
}
}
pub fn remove_player(&mut self, entity: Entity) -> bool {
self.players.remove(&entity)
}
pub fn has_player(&self, entity: Entity) -> bool {
self.players.contains(&entity)
}
pub fn query_players(&self) -> impl Iterator<Item = Entity> + '_ {
self.players.iter().copied()
}
pub fn add_enemy(&mut self, entity: Entity) {
if self.entity_locations.get(entity).is_some() {
self.enemies.insert(entity);
}
}
pub fn remove_enemy(&mut self, entity: Entity) -> bool {
self.enemies.remove(&entity)
}
pub fn has_enemy(&self, entity: Entity) -> bool {
self.enemies.contains(&entity)
}
pub fn query_enemies(&self) -> impl Iterator<Item = Entity> + '_ {
self.enemies.iter().copied()
}
pub fn queue_spawn(&mut self, mask: u64) {
self.command_buffer.push(Command::Spawn { mask });
}
pub fn queue_despawn(&mut self, entity: Entity) {
self.command_buffer.push(Command::Despawn { entity });
}
pub fn queue_add_components(&mut self, entity: Entity, mask: u64) {
self.command_buffer
.push(Command::AddComponents { entity, mask });
}
pub fn queue_remove_components(&mut self, entity: Entity, mask: u64) {
self.command_buffer
.push(Command::RemoveComponents { entity, mask });
}
pub fn queue_set_position(&mut self, entity: Entity, value: Position) {
self.command_buffer
.push(Command::SetPosition { entity, value });
}
pub fn queue_set_velocity(&mut self, entity: Entity, value: Velocity) {
self.command_buffer
.push(Command::SetVelocity { entity, value });
}
pub fn queue_add_player(&mut self, entity: Entity) {
self.command_buffer.push(Command::AddPlayer { entity });
}
pub fn queue_remove_player(&mut self, entity: Entity) {
self.command_buffer.push(Command::RemovePlayer { entity });
}
pub fn queue_add_enemy(&mut self, entity: Entity) {
self.command_buffer.push(Command::AddEnemy { entity });
}
pub fn queue_remove_enemy(&mut self, entity: Entity) {
self.command_buffer.push(Command::RemoveEnemy { entity });
}
pub fn apply_commands(&mut self) {
let commands = std::mem::take(&mut self.command_buffer);
for command in commands {
match command {
Command::Spawn { mask } => {
self.spawn(mask);
}
Command::Despawn { entity } => {
self.despawn(entity);
}
Command::AddComponents { entity, mask } => {
self.add_components(entity, mask);
}
Command::RemoveComponents { entity, mask } => {
self.remove_components(entity, mask);
}
Command::SetPosition { entity, value } => {
self.set_position(entity, value);
}
Command::SetVelocity { entity, value } => {
self.set_velocity(entity, value);
}
Command::AddPlayer { entity } => {
self.add_player(entity);
}
Command::RemovePlayer { entity } => {
self.remove_player(entity);
}
Command::AddEnemy { entity } => {
self.add_enemy(entity);
}
Command::RemoveEnemy { entity } => {
self.remove_enemy(entity);
}
}
}
}
}
pub type SystemFn = Box<dyn FnMut(&mut World)>;
#[derive(Default)]
pub struct Schedule {
pub systems: Vec<(&'static str, SystemFn)>,
}
impl Schedule {
pub fn add<F>(&mut self, name: &'static str, system: F) -> &mut Self
where
F: FnMut(&mut World) + 'static,
{
self.systems.push((name, Box::new(system)));
self
}
pub fn run(&mut self, world: &mut World) {
for (_, system) in &mut self.systems {
system(world);
}
}
}
fn physics_system(world: &mut World) {
let current_tick = world.current_tick();
world.for_each_mut(POSITION | VELOCITY, 0, |_entity, table, index| {
table.positions[index].x += table.velocities[index].x;
table.positions[index].y += table.velocities[index].y;
table.positions_changed[index] = current_tick;
});
}
fn collision_system(world: &mut World) {
let positions: Vec<(Entity, Position)> = world
.query_entities(POSITION)
.filter_map(|entity| world.get_position(entity).map(|position| (entity, position.clone())))
.collect();
for index_a in 0..positions.len() {
for index_b in (index_a + 1)..positions.len() {
let (entity_a, position_a) = &positions[index_a];
let (entity_b, position_b) = &positions[index_b];
let delta_x = position_a.x - position_b.x;
let delta_y = position_a.y - position_b.y;
if delta_x * delta_x + delta_y * delta_y < 1.0 {
world.send_collision(CollisionEvent {
entity_a: *entity_a,
entity_b: *entity_b,
});
}
}
}
}
fn collision_reporter(world: &mut World) {
for event in world.drain_collisions() {
println!("collision: {:?} and {:?}", event.entity_a, event.entity_b);
}
}
fn render_changed_system(world: &mut World) {
world.for_each_mut_changed(POSITION, 0, |entity, table, index| {
println!(
"redraw {:?}: ({}, {})",
entity, table.positions[index].x, table.positions[index].y
);
});
}
fn main() {
let mut world = World::default();
let player = world.spawn(POSITION | VELOCITY);
world.set_position(player, Position { x: 0.0, y: 0.0 });
world.set_velocity(player, Velocity { x: 0.5, y: 0.0 });
world.add_player(player);
let enemy = world.spawn(POSITION | VELOCITY);
world.set_position(enemy, Position { x: 3.0, y: 0.0 });
world.set_velocity(enemy, Velocity { x: -0.5, y: 0.0 });
world.add_enemy(enemy);
let landmark = world.spawn(POSITION);
world.set_position(landmark, Position { x: 10.0, y: 10.0 });
let mut schedule = Schedule::default();
schedule
.add("physics", physics_system)
.add("collision", collision_system)
.add("collision_reporter", collision_reporter)
.add("render_changed", render_changed_system);
world.step();
for frame in 0..4 {
println!("--- frame {frame} ---");
schedule.run(&mut world);
if frame == 2 {
world.queue_despawn(enemy);
}
world.apply_commands();
world.step();
}
println!("--- final ---");
println!("players: {}", world.query_players().count());
println!("enemies: {}", world.query_enemies().count());
println!("entities with position: {}", world.query_entities(POSITION).count());
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment