Skip to main content

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}