renew_ecs/lib.rs
1//! Entities and component storage, with a defined iteration order.
2//!
3//! Two pieces. [`Entities`] hands out and recycles entity handles;
4//! [`Store<T>`] holds components of one type, addressed by entity slot.
5//! A caller keeps one store per component type and joins them with
6//! [`join`].
7//!
8//! # Contract
9//!
10//! - **Every query iterates in ascending entity-slot order.** This is a
11//! promise, not an artifact of the representation, and it is the reason
12//! the storage was chosen the way it was. A system's result therefore
13//! cannot depend on the order components happened to be inserted or
14//! removed in, which is what makes determinism structural rather than a
15//! rule every future contributor must remember.
16//! - **A stale handle is dead, not dangerous.** An entity is a slot plus a
17//! generation; reusing a slot bumps its generation, so a handle to a
18//! despawned entity fails [`Entities::is_alive`] instead of quietly
19//! naming whatever took its place.
20//! - **Ordered iteration costs the gaps.** It walks slots, so it is
21//! proportional to the highest occupied slot rather than to the number
22//! of components. [`Entities::spawn`] reuses low slots first to keep
23//! that range tight, and [`Store::iter_unordered`] exists for systems
24//! that genuinely do not care.
25//!
26//! # What this is not
27//!
28//! There is no type map: a caller holds its stores explicitly rather than
29//! asking a world for `Store<Position>` by type. That is a real feature
30//! and it is deliberately absent — it needs a design for how systems
31//! declare what they touch, and there is no system yet to design against.
32//! Nothing here allocates from an engine allocator, spawns a thread, or
33//! reads a clock.
34//!
35//! # Example
36//!
37//! ```
38//! use renew_ecs::{Entities, Store, join};
39//!
40//! let mut entities = Entities::new();
41//! let mut position = Store::new();
42//! let mut health = Store::new();
43//!
44//! let hero = entities.spawn();
45//! let rock = entities.spawn();
46//! position.insert(hero.index(), (0_i32, 0_i32));
47//! position.insert(rock.index(), (5, 5));
48//! health.insert(hero.index(), 100_u32);
49//!
50//! // Only the hero has both, and joins always run in slot order.
51//! let both: Vec<u32> = join(&position, &health).map(|(slot, _, _)| slot).collect();
52//! assert_eq!(both, vec![hero.index()]);
53//! ```
54
55// Storage answers questions; it never reports. A print from inside a
56// query would be output no caller asked for, on a path that runs once
57// per entity per frame.
58// The determinism rule in the language standard: a simulation crate does not
59// perform floating-point arithmetic whose result can reach digested state.
60// Denied here rather than left to review — the lint covers operators only, so
61// it is necessary and not sufficient, but what it does cover it covers with
62// teeth.
63#![deny(clippy::print_stdout, clippy::print_stderr, clippy::float_arithmetic)]
64
65mod entity;
66mod store;
67
68pub use entity::{Entities, Entity};
69pub use store::Store;
70
71/// Every entity present in both stores, in ascending slot order.
72///
73/// Walks whichever side is cheaper to scan and probes the other, which is
74/// the whole reason a sparse set is worth having: membership is an array
75/// lookup, so a join costs one scan rather than the product. The order is
76/// the same promise the stores make on their own.
77///
78/// **Cheaper means the slot span, not the component count.** [`Store::iter`]
79/// walks `sparse`, so its cost is the highest slot ever occupied — a store
80/// holding three components scattered across a million slots is expensive
81/// to walk and still O(1) to probe. Choosing by component count would pick
82/// the wrong side exactly when the difference is worth having.
83///
84/// Both directions yield identical sequences, so which one runs is
85/// invisible to callers and cannot reach the state digest.
86///
87/// Deliberately a free function rather than a method: it belongs to
88/// neither store, and making it one store's method would suggest an
89/// asymmetry the operation does not have.
90pub fn join<'a, A, B>(
91 left: &'a Store<A>,
92 right: &'a Store<B>,
93) -> impl Iterator<Item = (u32, &'a A, &'a B)> {
94 // `iter` is already slot-ordered and filtering preserves that, so both
95 // arms emit ascending slots over the same intersection.
96 if walks_left(left, right) {
97 Join::FromLeft(
98 left.iter()
99 .filter_map(move |(slot, value)| Some((slot, value, right.get(slot)?))),
100 )
101 } else {
102 Join::FromRight(
103 right
104 .iter()
105 .filter_map(move |(slot, value)| Some((slot, left.get(slot)?, value))),
106 )
107 }
108}
109
110/// Whether [`join`] will walk `left` rather than `right`.
111///
112/// Split out and tested directly because the choice is a cost decision
113/// with no observable effect on results: both arms emit the identical
114/// sequence, so no test over the output can tell which one ran. Asserting
115/// the predicate is the only guard that would have caught the original
116/// defect, where the doc promised a choice the code never made.
117fn walks_left<A, B>(left: &Store<A>, right: &Store<B>) -> bool {
118 left.scan_len() <= right.scan_len()
119}
120
121/// A join walked from one side or the other.
122///
123/// An enum rather than a boxed iterator because the steady-state frame
124/// loop allocates nothing, and rather than always walking one side because
125/// that is the choice being made. Both variants carry the same item type,
126/// so the branch is a cost decision and never a behavioural one.
127enum Join<L, R> {
128 FromLeft(L),
129 FromRight(R),
130}
131
132impl<'a, A: 'a, B: 'a, L, R> Iterator for Join<L, R>
133where
134 L: Iterator<Item = (u32, &'a A, &'a B)>,
135 R: Iterator<Item = (u32, &'a A, &'a B)>,
136{
137 type Item = (u32, &'a A, &'a B);
138
139 fn next(&mut self) -> Option<Self::Item> {
140 match self {
141 Self::FromLeft(iter) => iter.next(),
142 Self::FromRight(iter) => iter.next(),
143 }
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn a_join_yields_only_entities_in_both_stores() {
153 let mut names: Store<&str> = Store::new();
154 let mut scores: Store<u32> = Store::new();
155 names.insert(0, "zero");
156 names.insert(1, "one");
157 names.insert(2, "two");
158 scores.insert(1, 10);
159 scores.insert(2, 20);
160 scores.insert(9, 90);
161
162 let found: Vec<(u32, &str, u32)> = join(&names, &scores)
163 .map(|(slot, name, score)| (slot, *name, *score))
164 .collect();
165 assert_eq!(found, vec![(1, "one", 10), (2, "two", 20)]);
166 }
167
168 /// A join over disjoint stores is empty rather than wrong.
169 #[test]
170 fn a_join_with_nothing_in_common_is_empty() {
171 let mut left: Store<u8> = Store::new();
172 let mut right: Store<u8> = Store::new();
173 left.insert(0, 1);
174 right.insert(1, 2);
175 assert_eq!(join(&left, &right).count(), 0);
176 }
177
178 /// The join keeps slot order after churn, which is the property the
179 /// whole storage choice was made for.
180 #[test]
181 fn a_join_is_in_slot_order_after_churn() {
182 let mut left: Store<u32> = Store::new();
183 let mut right: Store<u32> = Store::new();
184 for slot in [7u32, 2, 5, 1, 9] {
185 left.insert(slot, slot);
186 right.insert(slot, slot);
187 }
188 left.remove(5);
189 right.remove(9);
190 left.insert(3, 3);
191 right.insert(3, 3);
192
193 let slots: Vec<u32> = join(&left, &right).map(|(slot, _, _)| slot).collect();
194 assert_eq!(slots, vec![1, 2, 3, 7]);
195 }
196
197 /// Regression: the doc promised the join walks the cheaper side and
198 /// the code walked `left` unconditionally, so a wide-span left store
199 /// paid for every empty slot while a one-slot right store sat unused.
200 /// Both directions must produce the identical sequence, or the choice
201 /// would be observable — and a cost decision that changes results is
202 /// not a cost decision.
203 #[test]
204 fn a_join_yields_the_same_sequence_from_either_side() {
205 let mut wide: Store<u32> = Store::new();
206 let mut narrow: Store<u32> = Store::new();
207 // `wide` spans far more slots than it holds components; `narrow`
208 // is dense and low. The join must pick `narrow` to walk.
209 for slot in [0u32, 3, 40_000] {
210 wide.insert(slot, slot);
211 }
212 for slot in [0u32, 3] {
213 narrow.insert(slot, slot * 10);
214 }
215 assert!(narrow.scan_len() < wide.scan_len());
216 // The guard that actually bites: results agree either way, so only
217 // the choice itself distinguishes the fix from the defect.
218 assert!(!walks_left(&wide, &narrow));
219 assert!(walks_left(&narrow, &wide));
220
221 let forward: Vec<(u32, u32, u32)> = join(&wide, &narrow)
222 .map(|(slot, a, b)| (slot, *a, *b))
223 .collect();
224 let backward: Vec<(u32, u32, u32)> = join(&narrow, &wide)
225 .map(|(slot, a, b)| (slot, *b, *a))
226 .collect();
227
228 assert_eq!(forward, vec![(0, 0, 0), (3, 3, 30)]);
229 assert_eq!(forward, backward);
230 }
231
232 /// The walk cost is the slot span, not the component count, so a store
233 /// with fewer components can still be the expensive side. This is the
234 /// distinction that made the original comment wrong even in intent.
235 #[test]
236 fn scan_cost_follows_slot_span_not_component_count() {
237 let mut few_but_scattered: Store<u32> = Store::new();
238 few_but_scattered.insert(0, 0);
239 few_but_scattered.insert(50_000, 1);
240
241 let mut many_but_packed: Store<u32> = Store::new();
242 for slot in 0..1_000u32 {
243 many_but_packed.insert(slot, slot);
244 }
245
246 assert!(few_but_scattered.len() < many_but_packed.len());
247 assert!(few_but_scattered.scan_len() > many_but_packed.scan_len());
248 }
249
250 /// Entities and stores agree about who is alive, which is the join a
251 /// real system actually performs.
252 #[test]
253 fn a_despawned_entity_can_be_filtered_out_of_a_query() {
254 let mut entities = Entities::new();
255 let mut store: Store<u32> = Store::new();
256 let keep = entities.spawn();
257 let drop = entities.spawn();
258 store.insert(keep.index(), 1);
259 store.insert(drop.index(), 2);
260
261 entities.despawn(drop);
262 // The store still holds the component: nothing removes it
263 // automatically, and pretending otherwise would be the kind of
264 // hidden behaviour this crate avoids. A caller filters.
265 assert_eq!(store.len(), 2);
266 let live: Vec<u32> = entities
267 .iter()
268 .filter_map(|entity| store.get(entity.index()).map(|_| entity.index()))
269 .collect();
270 assert_eq!(live, vec![keep.index()]);
271 }
272}