Skip to content

Instantly share code, notes, and snippets.

@matthewjberger
Last active May 27, 2026 13:00
Show Gist options
  • Select an option

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

Select an option

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
// 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