23 — Index maps
Concept node: see the DAG and glossary entry 23.

The presence-replaces-flags substitution from §17 had a sting in its tail. A presence query — “is creature 42 hungry?” — costs O(N) when implemented naively as hungry.iter().any(|&x| x == 42). At 1 000 000 creatures, that is too slow for any system that needs to ask the question many times per tick.
The fix is a parallel data structure: an index map id_to_slot: Vec<u32> that maps every id to its current slot in the table. Lookup is now O(1):
#![allow(unused)]
fn main() {
const INVALID: u32 = u32::MAX;
fn slot_of(id_to_slot: &[u32], id: u32) -> Option<usize> {
let slot = id_to_slot[id as usize];
if slot == INVALID { None } else { Some(slot as usize) }
}
}
A sentinel value (u32::MAX) marks “no slot — this id does not have a current row”. The Option return makes the missing case explicit.
Maintenance. The map must be updated whenever a row moves. The events that move rows:
swap_remove. When slotiis removed by swapping the last row in, the row that was atlastis now ati. Updateid_to_slot[that_row.id] = i. Setid_to_slot[deleted_row.id] = INVALID.- Append. When a new row is appended at slot
n, setid_to_slot[new_row.id] = n. - Sort or shuffle. When the table is reordered (for locality, §28), every slot moves. The full map is rewritten in lockstep with the sort.
The cleanup system from §22 is the natural home for these updates. Every removal and every insertion goes through cleanup; cleanup keeps the map in step.
Cost. The map adds one u32 per id ever issued, including ids that are currently dead but whose slots have not been recycled. For a simulator that issues a million ids over its lifetime but has 100 000 alive at any moment, the map is 4 MB. That is a real cost — bigger than the alive table itself if the table has narrow columns. Mitigations include:
- Generational ids (§10) bound the map’s size to the maximum live + recycled count, not the total ever issued.
- A
HashMap<u32, u32>trades a constant-time lookup overhead for tighter memory; useful when ids are sparse. - A separate id allocator that recycles dead ids, so the map’s size matches the high-water mark of live ids.
For most simulators, the dense Vec<u32> is the right shape. It is one cache line per 16 ids; cleanup streams sequentially through it.
The pattern in the wild. Every ECS engine ships an index map. Bevy’s Entity is a 64-bit handle whose unpacking is essentially a slot lookup with a generation check. slotmap’s SlotMap keeps an internal map. Database engines maintain index maps as B-trees over primary keys. The shape — id-to-slot lookup, maintained on every move — is universal.
Combined with §10’s stable ids and §24’s slot recycling, the index map is the third piece of the generational arena — the canonical handle-based data structure in modern systems software.
Exercises
- Build the map. Add
id_to_slot: Vec<u32>to your simulator. Initialise toINVALIDfor all ids. When a creature is appended at slot N, setid_to_slot[id] = N. - O(1) presence query. Add a parallel
hungry_membership: Vec<bool>set totruewhen an id is inhungry. Nowis_hungry(id)is two array lookups, both O(1). - Maintain on swap_remove. Modify your cleanup so that after
creatures.swap_remove(slot):id_to_slot[deleted_id] = INVALIDid_to_slot[moved_id] = slot(the last row, now atslot)
- Time the difference. Rerun the simulator at 1 M creatures, calling
is_hungry(random_id)100 000 times per tick. Compare the linear-scan version (§17) and the indexed version (§23). The ratio is roughly N — about a million. - The bandwidth cost. At 1 M ids,
id_to_slotis 4 MB. Cleanup’s update of the map writes ~12 bytes per swap_remove (delete row’s slot, moved row’s slot, plus bookkeeping). Compute the cleanup cost in microseconds for 1 000 deletes per tick; compare to the budget at 30 Hz. - Sort-for-locality compatibility. When
creaturesis sorted (a preview of §28), every slot moves. Rewriteid_to_slotin lockstep. Verify external references (held as ids) are still correct after the sort. - (stretch) A from-scratch generational arena. Combine §10’s
gen: Vec<u32>, §22’s deferred cleanup, and §23’sid_to_slotmap into aSlotMap<T>struct. Compare the shape withslotmap::SlotMap— same machinery, organised differently.
Reference notes in 23_index_maps_solutions.md.
What’s next
§24 — Append-only and recycling names two strategies for what happens to a slot after it has been freed. The choice is decided by access pattern, not by taste.