Skip to content

Instantly share code, notes, and snippets.

@matthewjberger
Created May 27, 2026 12:53
Show Gist options
  • Select an option

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

Select an option

Save matthewjberger/ff6fc2aba33d94330fdadbbb99d45563 to your computer and use it in GitHub Desktop.
An archetype ECS kernel in ~320 lines of Rust (no proc-macros, no unsafe, std only)
// An archetype ECS kernel in ~320 lines of Rust.
//
// An ECS (Entity Component System) organises game/simulation state as three
// things:
//
// Entities -- opaque handles that identify a "thing" in the world.
// Components -- plain data attached to entities (position, velocity, ...).
// Systems -- functions that iterate over entities with a given set of
// components and transform their data.
//
// This implementation uses the *archetype* storage strategy: entities that
// share exactly the same set of components are grouped together in a table,
// with one contiguous array per component type. That layout is cache-friendly
// for iteration (systems read one array at a time, never chasing pointers) and
// makes adding/removing components a well-defined migration between tables.
//
// The five load-bearing ideas, in the order they appear below:
//
// 1. Generational handles -- stale entity references are detectable.
// 2. Dense location Vec -- O(1) entity-to-table-row lookup.
// 3. Archetype tables (SoA) -- one Vec per component, shared row index.
// 4. Runtime structural change -- migrate() moves a row between tables.
// 5. Two caches -- edge graph (migration fast-path) and
// query cache (system iteration fast-path).
// -- Component types ----------------------------------------------------------
//
// Components are plain data structs with no methods. Default is required
// because new rows are zero-initialised when an entity gains a component it
// did not previously have (see table_move_row). Clone is required because the
// edge graph stores component data that must survive a table resize.
#[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 }
// Each component type gets a unique power-of-two bit. An archetype's identity
// is the bitwise OR of all its component bits -- its "mask". Two entities with
// the same mask live in the same table.
const POSITION: u64 = 1 << 0;
const VELOCITY: u64 = 1 << 1;
const VITALITY: u64 = 1 << 2;
// Used to size the fixed edge arrays inside each table. Must equal the number
// of component constants above.
const COMPONENT_COUNT: usize = 3;
// -- Entity handle ------------------------------------------------------------
//
// An Entity is a (index, generation) pair. The index is a slot number in the
// allocator's generations Vec and in the locations Vec -- a dense array, not a
// HashMap. The generation distinguishes successive occupants of the same slot:
// when an entity is despawned its slot's generation is bumped, so any handle
// that survived with the old generation is detectably stale.
//
// This is the "generational index" pattern (also called a "slot map handle").
// It gives O(1) liveness checks and O(1) location lookups with no hashing.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Entity { index: u32, generation: u32 }
// -- Location -----------------------------------------------------------------
//
// For every live entity we record exactly where its component data lives:
// which table (by index into World::tables) and which row within that table.
// Every component Vec in the table shares the same row index, so reading
// table.positions[loc.row] and table.velocities[loc.row] gives the same
// entity's data.
#[derive(Clone, Copy, Debug)]
struct Location { table: usize, row: usize }
// -- Archetype table ----------------------------------------------------------
//
// One Table exists per unique component combination ever seen. Its mask is the
// bitwise OR of the components it holds. The component Vecs are the actual
// storage; they are always the same length as entities and share a row index.
//
// The edge graph fields cache migration destinations so that add/remove
// component calls do not need to hash the table_map on every call:
//
// add_single / remove_single -- fixed arrays indexed by component_index().
// Single-component transitions are the hot path (one status effect, one
// flag), so they get an array probe: one index load, no hashing. Slot ci
// in add_single holds the index of the table reached by adding component ci
// from this archetype. None means the transition has not been taken yet.
//
// add_multi / remove_multi -- HashMap keyed on the changed-bit delta.
// Multi-component transitions are rarer (e.g. spawning with three
// components at once, or stripping two at once). They fall back to a map
// keyed on the XOR of old and new masks.
//
// Edges are filled lazily on first traversal and never evicted -- tables are
// never removed, so a cached destination index never goes stale.
use std::collections::HashMap;
struct Table {
mask: u64,
entities: Vec<Entity>,
positions: Vec<Position>,
velocities: Vec<Velocity>,
vitalities: Vec<Vitality>,
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(),
velocities: Vec::new(),
vitalities: Vec::new(),
add_single: [None; COMPONENT_COUNT],
remove_single: [None; COMPONENT_COUNT],
add_multi: HashMap::new(),
remove_multi: HashMap::new(),
}
}
}
// -- World --------------------------------------------------------------------
//
// World is a plain data struct -- a bag of Vecs and HashMaps with no hidden
// state. All operations are free functions that take &mut World (or the
// relevant sub-fields) as arguments.
//
// tables -- the archetype tables; indexed by table index.
// table_map -- mask -> table index; O(1) lookup by component set.
// query_cache -- required_mask -> Vec of matching table indices.
// Systems call this every frame; it must be fast.
// locations -- Entity::index -> Option<Location>; dense Vec, O(1) lookup.
// generations -- Entity::index -> current generation; drives is_live().
// free_ids -- recycled slot indices waiting for reuse.
#[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>,
}
// -- Allocator operations -----------------------------------------------------
//
// alloc_entity pops a recycled slot from free_ids if one exists, reusing its
// index with the generation that was written when it was freed. Otherwise it
// extends the generations Vec with a fresh slot at generation 0.
//
// free_entity bumps the generation *before* pushing the index onto free_ids.
// Any handle that was alive before the bump now has a generation that no longer
// matches generations[index], so is_live returns false for it. The slot is then
// safe to reuse for a new entity.
//
// is_live does a single indexed read and an equality check -- no hashing,
// no pointer chasing. It is called on every accessor and on every
// add/remove/despawn to guard against stale handles.
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 ------------------------------------------------------
//
// locations is a Vec<Option<Location>> indexed by Entity::index. Using a Vec
// instead of a HashMap is intentional: entity indices come from a dense
// counter, so the Vec stays compact and every access is a single indexed load.
//
// set_location extends the Vec if the new index is beyond its current length.
// clear_location writes None to mark the slot as empty after despawn; this is
// belt-and-suspenders -- is_live already rejects stale handles before the
// location is read, but clearing keeps the Vec tidy.
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 ---------------------------------------------------
//
// query_tables returns the list of table indices whose mask is a superset of
// `required`. Systems call this every frame, so the result is memoised in a
// HashMap keyed on the required mask.
//
// The first call for a given mask scans all tables once and stores the result.
// Subsequent calls return the cached slice immediately -- one HashMap probe,
// no scanning.
//
// on_new_table is called whenever get_or_create_table adds a new table. It
// patches every existing cache entry whose required mask is a subset of the
// new table's mask, appending the new table index to those entries. New tables
// are rare (at most one per unique component combination ever used) so this
// patch loop is cold.
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 ---------------------------------------------------------
//
// component_index maps a single-bit mask to its array index (0, 1, 2, ...).
// It is only called in the delta.count_ones() == 1 branch of migrate, where
// the single-bit precondition is already established.
fn component_index(single_bit: u64) -> Option<usize> {
match single_bit {
POSITION => Some(0),
VELOCITY => Some(1),
VITALITY => Some(2),
_ => None,
}
}
// table_push appends a new row of default-initialised components and returns
// the row index. Only the Vecs for components present in table.mask grow;
// absent components have no storage in this table.
fn table_push(table: &mut Table, e: Entity) -> usize {
let row = table.entities.len();
table.entities.push(e);
if table.mask & POSITION != 0 { table.positions.push(Position::default()); }
if table.mask & VELOCITY != 0 { table.velocities.push(Velocity::default()); }
if table.mask & VITALITY != 0 { table.vitalities.push(Vitality::default()); }
row
}
// table_swap_remove removes row `idx` in O(1) by overwriting it with the last
// row and truncating. All component Vecs are kept in sync. Returns the entity
// that was moved from the last slot into `idx` (if any) so the caller can
// update its location record. Returns None if `idx` was already the last 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); }
if table.mask & VELOCITY != 0 { table.velocities.swap_remove(idx); }
if table.mask & VITALITY != 0 { table.vitalities.swap_remove(idx); }
moved
}
// table_move_row is the migration primitive. It copies one row from src into
// dst, preserving component data for components present in both tables and
// default-initialising components that are new in dst. Components that exist
// in src but not in dst are simply dropped (they are being removed).
//
// mem::take replaces the source slot with Default::default() in-place before
// swap_remove cleans it up. This avoids cloning: the data moves out of src
// and into dst without copying.
//
// Returns (dst_row, back_filled): dst_row is the new row index in dst;
// back_filled is the entity that swap_remove moved into src[idx] (if any).
fn table_move_row(src: &mut Table, idx: usize, dst: &mut Table) -> (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() });
}
if dst.mask & VELOCITY != 0 {
dst.velocities.push(if src.mask & VELOCITY != 0 {
std::mem::take(&mut src.velocities[idx])
} else { Velocity::default() });
}
if dst.mask & VITALITY != 0 {
dst.vitalities.push(if src.mask & VITALITY != 0 {
std::mem::take(&mut src.vitalities[idx])
} else { Vitality::default() });
}
let dst_row = dst.entities.len() - 1;
let back_filled = table_swap_remove(src, idx);
(dst_row, back_filled)
}
// -- Table registry -----------------------------------------------------------
//
// get_or_create_table returns the index of the table for `mask`, creating it
// if it does not yet exist. table_map gives O(1) lookup by mask. When a new
// table is created, on_new_table patches the query cache so existing system
// queries immediately see the new table without a full rescan.
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 ----------------------------------------------------------------
//
// migrate moves entity e from its current table to the table for new_mask.
// It is called by add_components and remove_components after they have
// computed the target mask.
//
// Edge graph lookup:
// delta = old_mask XOR new_mask -- the bits that changed.
// adding = new_mask > old_mask -- true iff new_mask is a strict superset.
// (A superset mask is always numerically larger because all bits in
// old_mask are also in new_mask, plus at least one more. This holds for
// any bit-count delta, not just single-bit ones.)
//
// If delta has exactly one bit set, probe add_single or remove_single with
// the component index -- an array access, no hashing. On a miss, resolve via
// table_map and write the edge for next time.
//
// If delta has multiple bits set, probe add_multi or remove_multi with delta
// as the key. Same miss/fill logic.
//
// The split_at_mut dance gives simultaneous mutable references to two
// different elements of world.tables. Rust does not allow two &mut borrows
// into the same Vec, but split_at_mut splits it into two non-overlapping
// slices. assert_ne!(src_ti, dst_ti) documents and enforces the precondition
// (add_components and remove_components both short-circuit the no-op case
// before calling migrate, guaranteeing the tables differ).
//
// After table_move_row, two location records need updating:
// - e itself now lives in dst at dst_row.
// - The entity back-filled into src[loc.row] by swap_remove (if any) needs
// its row updated to loc.row.
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 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);
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 ---------------------------------------------------------
// spawn creates a new entity with the given component mask, places it in the
// appropriate table, and returns its handle. The handle is valid until despawn
// is called for that entity.
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 row = table_push(&mut world.tables[ti], e);
set_location(&mut world.locations, e, Location { table: ti, row });
e
}
// despawn removes the entity from its table (swap_remove, O(1)), patches the
// back-filled entity's location, clears the entity's own location, and frees
// the slot. After despawn, is_live returns false for the handle.
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);
free_entity(&mut world.generations, &mut world.free_ids, e);
}
// add_components migrates e to a new archetype whose mask is old | added.
// No-ops if e already has all components in `added`, or if e is not live.
// Component data already held by e is preserved across the migration.
// Newly gained components are default-initialised.
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);
}
// remove_components migrates e to a new archetype whose mask is old & !removed.
// No-ops if e has none of the components in `removed`, or if e is not live.
// Removed component data is dropped during migration.
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 ------------------------------------------------------
//
// Each accessor checks is_live first, then resolves the location, then checks
// that the table's mask includes the requested component. Returning None for a
// stale handle or a missing component avoids panics and makes the API
// composable with Option combinators.
//
// get_position returns a shared reference (reading position during rendering
// does not need mutation). get_velocity_mut and get_vitality_mut return mutable
// references for systems that write component data.
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 t = &mut world.tables[loc.table];
(t.mask & VELOCITY != 0).then(|| &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 t = &mut world.tables[loc.table];
(t.mask & VITALITY != 0).then(|| &mut t.vitalities[loc.row])
}
// -- Systems ------------------------------------------------------------------
//
// Systems follow the same pattern: call query_tables to get the list of
// matching table indices, then iterate over each table's component Vecs
// directly. No per-entity dispatch, no virtual calls -- just a tight inner
// loop over contiguous arrays.
//
// query_tables returns a borrowed slice, so we call .to_vec() to take a
// snapshot before borrowing world.tables mutably for the inner loop. This is
// the same pattern used throughout: separate the index lookup from the
// mutation so the borrow checker is satisfied without unsafe.
fn wind_system(world: &mut World, dt: f32) {
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;
}
}
}
fn sunlight_system(world: &mut World, dt: f32) {
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 v in t.vitalities.iter_mut() {
v.hp = (v.hp + 5.0 * dt).min(100.0);
}
}
}
// -- Demo ---------------------------------------------------------------------
fn main() {
let mut world = World::default();
// -- Generational handle safety -------------------------------------------
println!("=== generational handle safety ===");
let drifting_seed = spawn(&mut world, POSITION | VELOCITY);
get_velocity_mut(&mut world, drifting_seed).unwrap().dx = 10.0;
despawn(&mut world, drifting_seed);
let new_seedling = spawn(&mut world, POSITION);
println!("drifting_seed live? {}", is_live(&world.generations, drifting_seed)); // false
println!("new_seedling live? {}", is_live(&world.generations, new_seedling)); // true
println!("stale lookup: {:?}", get_position(&world, drifting_seed)); // None
// -- Sprouting: structural change via the edge graph ----------------------
println!("\n=== sprouting (single-bit edge cache) ===");
let shrub = spawn(&mut world, POSITION | VITALITY);
get_vitality_mut(&mut world, shrub).unwrap().hp = 50.0;
add_components(&mut world, shrub, VELOCITY);
get_velocity_mut(&mut world, shrub).unwrap().dx = 3.0;
println!("runner extended -- vel: {:?} vitality: {:?}",
get_velocity_mut(&mut world, shrub).map(|v| (v.dx, v.dy)),
get_vitality_mut(&mut world, shrub).map(|v| v.hp));
remove_components(&mut world, shrub, VELOCITY);
println!("runner rooted -- vel: {:?} vitality: {:?}",
get_velocity_mut(&mut world, shrub).map(|v| (v.dx, v.dy)),
get_vitality_mut(&mut world, shrub).map(|v| v.hp));
add_components(&mut world, shrub, VELOCITY);
println!("second runner (array hit) -- vel: {:?}",
get_velocity_mut(&mut world, shrub).map(|v| (v.dx, v.dy)));
// -- Multi-bit transition -------------------------------------------------
println!("\n=== multi-bit transition (delta-keyed cache) ===");
let seed = spawn(&mut world, POSITION);
add_components(&mut world, seed, VELOCITY | VITALITY);
get_velocity_mut(&mut world, seed).unwrap().dx = 1.0;
get_vitality_mut(&mut world, seed).unwrap().hp = 30.0;
println!("sprouted seed -- vel: {:?} vitality: {:?}",
get_velocity_mut(&mut world, seed).map(|v| (v.dx, v.dy)),
get_vitality_mut(&mut world, seed).map(|v| v.hp));
let seed2 = spawn(&mut world, POSITION);
add_components(&mut world, seed2, VELOCITY | VITALITY);
println!("second sprout (map hit) -- vel: {:?}",
get_velocity_mut(&mut world, seed2).map(|v| (v.dx, v.dy)));
// -- Query cache ----------------------------------------------------------
println!("\n=== query cache ===");
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 first = query_tables(&mut world.query_cache, &world.tables, POSITION | VELOCITY).to_vec();
let second = query_tables(&mut world.query_cache, &world.tables, POSITION | VELOCITY).to_vec();
println!("tables matching POS|VEL (both calls): {:?} == {:?}", first, second);
// -- Tick: wind and sunlight ----------------------------------------------
println!("\n=== tick (dt=1.0) ===");
wind_system(&mut world, 1.0);
sunlight_system(&mut world, 1.0);
println!("seedling pos: {:?}", get_position(&world, seedling));
println!("seedling vitality: {:?}", get_vitality_mut(&mut world, seedling).map(|v| v.hp));
println!("shrub vitality: {:?}", get_vitality_mut(&mut world, shrub).map(|v| v.hp));
println!("seed vitality: {:?}", get_vitality_mut(&mut world, seed).map(|v| v.hp));
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment