Skip to content

Rust Ownership, IDs and ECS Design

Rust ownership decides who controls a value's lifetime; an entity ID identifies a value that may already have been deleted. ECS changes how data and processing are organised. These are separate choices, so start with the requirement rather than replacing a C++ object hierarchy with an ECS by default.

Requirements: ownership first, persistent identity when needed

A world owns up to two bodies. Every live body has a position and may have a velocity; absent velocity differs from a present velocity of zero. One movement step adds velocity to position only when both components exist. The integer arithmetic in these bounded fixtures fits the signed position type; unbounded input needs an explicit overflow policy.

Start with plain records and a loop. Add IDs only when a selection must survive between operations, reject deleted bodies and refuse handles from another world. Add generations only when the requirement explicitly allows reusing a storage slot. A handle copy must not keep the body alive. At the last generation, retire the slot instead of wrapping; when no usable slot remains, insertion fails.

The existing C++ ECS course starts with records and non-reused IDs. Its generations extension adds slot reuse only for that changed requirement. This lesson deliberately exercises reuse with an 8-bit generation so retirement can be tested, not because every application needs a generational allocator.

C++: one owner, scoped handles and component stores

Save as design.cpp. The reference record loop and component world implement the same movement rule. All component mutation goes through the owning world; external callers cannot independently leave a velocity attached to a deleted slot.

#include <array>
#include <cassert>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <limits>
#include <memory>
#include <optional>
#include <utility>

struct Body {
    int x;
    std::optional<int> velocity;
    bool operator==(const Body&) const = default;
};
void step_records(std::array<Body, 2>& bodies) {
    for (auto& body : bodies) if (body.velocity) body.x += *body.velocity;
}
struct OwnerTag {};
class World;
class Handle {
    friend class World;
    std::shared_ptr<const OwnerTag> owner_;
    std::size_t slot_ = 0;
    std::uint8_t generation_ = 0;
    Handle(std::shared_ptr<const OwnerTag> owner, std::size_t slot, std::uint8_t generation)
        : owner_(std::move(owner)), slot_(slot), generation_(generation) {}
public:
    Handle() = default; // Explicit invalid handle; Rust below issues handles only.
    std::size_t slot() const { return slot_; }
    unsigned generation() const { return generation_; }
};
class World {
    struct Slot { std::uint8_t generation = 0; bool retired = false; };
    std::shared_ptr<const OwnerTag> owner_ = std::make_shared<OwnerTag>();
    std::array<Slot, 2> slots_{};
    std::array<std::optional<int>, 2> positions_{};
    std::array<std::optional<int>, 2> velocities_{};
    bool valid(const Handle& handle) const {
        return handle.owner_ == owner_ && handle.slot_ < slots_.size()
            && positions_[handle.slot_].has_value()
            && slots_[handle.slot_].generation == handle.generation_;
    }
public:
    World() = default;
    World(const World&) = delete;
    World& operator=(const World&) = delete;
    World(World&&) = delete;
    World& operator=(World&&) = delete;
    std::optional<Handle> insert(Body body) {
        for (std::size_t index = 0; index < slots_.size(); ++index) {
            if (!positions_[index] && !slots_[index].retired) {
                positions_[index] = body.x;
                velocities_[index] = body.velocity;
                return Handle(owner_, index, slots_[index].generation);
            }
        }
        return std::nullopt;
    }
    std::optional<Body> inspect(const Handle& handle) const {
        if (!valid(handle)) return std::nullopt;
        return Body{*positions_[handle.slot_], velocities_[handle.slot_]};
    }
    bool set_velocity(const Handle& handle, std::optional<int> velocity) {
        if (!valid(handle)) return false;
        velocities_[handle.slot_] = velocity;
        return true;
    }
    bool remove(const Handle& handle) {
        if (!valid(handle)) return false;
        auto index = handle.slot_;
        positions_[index].reset();
        velocities_[index].reset(); // Clear every component before slot reuse.
        if (slots_[index].generation == std::numeric_limits<std::uint8_t>::max()) {
            slots_[index].retired = true;
        } else {
            ++slots_[index].generation;
        }
        return true;
    }
    void step() {
        for (std::size_t index = 0; index < slots_.size(); ++index) {
            if (positions_[index] && velocities_[index])
                *positions_[index] += *velocities_[index];
        }
    }
};

int main() {
    std::array<Body, 2> records{Body{0, 1}, Body{10, std::nullopt}};
    World world;
    auto a = world.insert(records[0]).value();
    auto b = world.insert(records[1]).value();
    step_records(records);
    world.step();
    assert(world.inspect(a) == records[0] && world.inspect(b) == records[1]);
    assert(world.set_velocity(b, -2));
    records[1].velocity = -2;
    step_records(records);
    world.step();
    assert(world.inspect(a) == records[0] && world.inspect(b) == records[1]);
    assert(records[0].x == 2 && records[1].x == 8);
    std::cout << "matched positions=" << records[0].x << ',' << records[1].x << '\n';
    auto snapshot = world.inspect(a).value();
    auto old = a;
    assert(world.remove(a) && !world.remove(old));
    auto replacement = world.insert(Body{99, std::nullopt}).value();
    bool reused = old.slot() == replacement.slot();
    bool stale = !world.inspect(old);
    assert(reused && stale && replacement.generation() == 1);
    assert(!world.set_velocity(old, 7));
    world.step();
    assert((world.inspect(replacement) == Body{99, std::nullopt}));
    assert(snapshot.x == 2); // An owned past snapshot, not a live view.
    std::cout << "reused=" << reused << ", stale=" << stale << '\n';
    World foreign;
    auto collision = foreign.insert(Body{7, 0}).value();
    assert(collision.slot() == old.slot() && collision.generation() == old.generation());
    assert(!world.inspect(collision) && !foreign.inspect(old) && !world.inspect(Handle{}));
    std::cout << "foreign rejected=1\n";
    World limited;
    Handle ancient;
    for (unsigned generation = 0; generation <= 255; ++generation) {
        auto current = limited.insert(Body{0, 0}).value();
        if (generation == 0) ancient = current;
        assert(current.slot() == 0 && current.generation() == generation);
        assert(limited.remove(current));
    }
    auto after_retirement = limited.insert(Body{5, std::nullopt}).value();
    assert(after_retirement.slot() == 1 && !limited.inspect(ancient));
    assert(!limited.insert(Body{0, 0}));
    std::cout << "retired slot=" << after_retirement.slot() << '\n';
    Handle surviving;
    {
        World temporary;
        surviving = temporary.insert(Body{0, 1}).value();
    }
    assert(!foreign.inspect(surviving));
    assert(foreign.inspect(collision)->velocity == 0);
    assert(foreign.set_velocity(collision, std::nullopt));
    foreign.step();
    assert((foreign.inspect(collision) == Body{7, std::nullopt}));
}
1
2
3
4
g++ -std=c++20 -Wall -Wextra -Wpedantic -O0 design.cpp -o design
./design
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 design.cpp -o design
./design

Both complete examples produce:

1
2
3
4
matched positions=2,8
reused=1, stale=1
foreign rejected=1
retired slot=1

The shared pointer retains only an identity marker. It does not own a position or velocity. A copied handle therefore cannot make a deleted body live again. The component arrays stay under one World owner, and inspect returns a copied snapshot rather than a pointer that may outlive mutation.

Rust: the same contracts without shared entity ownership

cargo new --edition 2024 design_demo
cd design_demo

Replace src/main.rs:

mod design {
    use std::rc::Rc;

    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub struct Body {
        pub x: i32,
        pub velocity: Option<i32>,
    }
    pub fn step_records(bodies: &mut [Body; 2]) {
        for body in bodies {
            if let Some(velocity) = body.velocity {
                body.x += velocity;
            }
        }
    }
    #[derive(Debug, Clone)]
    pub struct Handle {
        owner: Rc<()>,
        slot: usize,
        generation: u8,
    }
    impl Handle {
        pub fn slot(&self) -> usize {
            self.slot
        }
        pub fn generation(&self) -> u8 {
            self.generation
        }
    }
    #[derive(Clone, Copy, Default)]
    struct Slot {
        generation: u8,
        retired: bool,
    }
    pub struct World {
        owner: Rc<()>,
        slots: [Slot; 2],
        positions: [Option<i32>; 2],
        velocities: [Option<i32>; 2],
    }
    impl World {
        pub fn new() -> Self {
            Self {
                owner: Rc::new(()),
                slots: [Slot::default(); 2],
                positions: [None; 2],
                velocities: [None; 2],
            }
        }
        fn valid(&self, handle: &Handle) -> bool {
            Rc::ptr_eq(&self.owner, &handle.owner)
                && handle.slot < self.slots.len()
                && self.positions[handle.slot].is_some()
                && self.slots[handle.slot].generation == handle.generation
        }
        pub fn insert(&mut self, body: Body) -> Option<Handle> {
            for index in 0..self.slots.len() {
                if self.positions[index].is_none() && !self.slots[index].retired {
                    self.positions[index] = Some(body.x);
                    self.velocities[index] = body.velocity;
                    return Some(Handle {
                        owner: Rc::clone(&self.owner),
                        slot: index,
                        generation: self.slots[index].generation,
                    });
                }
            }
            None
        }
        pub fn inspect(&self, handle: &Handle) -> Option<Body> {
            if !self.valid(handle) {
                return None;
            }
            Some(Body {
                x: self.positions[handle.slot]?,
                velocity: self.velocities[handle.slot],
            })
        }
        pub fn set_velocity(&mut self, handle: &Handle, velocity: Option<i32>) -> bool {
            if !self.valid(handle) {
                return false;
            }
            self.velocities[handle.slot] = velocity;
            true
        }
        pub fn remove(&mut self, handle: &Handle) -> bool {
            if !self.valid(handle) {
                return false;
            }
            let index = handle.slot;
            self.positions[index] = None;
            self.velocities[index] = None;
            match self.slots[index].generation.checked_add(1) {
                Some(next) => self.slots[index].generation = next,
                None => self.slots[index].retired = true,
            }
            true
        }
        pub fn step(&mut self) {
            for index in 0..self.slots.len() {
                if let (Some(position), Some(velocity)) =
                    (&mut self.positions[index], self.velocities[index])
                {
                    *position += velocity;
                }
            }
        }
    }
}

use design::{Body, World, step_records};

fn main() {
    let mut records = [
        Body {
            x: 0,
            velocity: Some(1),
        },
        Body {
            x: 10,
            velocity: None,
        },
    ];
    let mut world = World::new();
    let a = world.insert(records[0]).unwrap();
    let b = world.insert(records[1]).unwrap();
    step_records(&mut records);
    world.step();
    assert_eq!(world.inspect(&a), Some(records[0]));
    assert_eq!(world.inspect(&b), Some(records[1]));
    assert!(world.set_velocity(&b, Some(-2)));
    records[1].velocity = Some(-2);
    step_records(&mut records);
    world.step();
    assert_eq!(world.inspect(&a), Some(records[0]));
    assert_eq!(world.inspect(&b), Some(records[1]));
    assert_eq!((records[0].x, records[1].x), (2, 8));
    println!("matched positions={},{}", records[0].x, records[1].x);
    let snapshot = world.inspect(&a).unwrap();
    let old = a.clone();
    assert!(world.remove(&a));
    assert!(!world.remove(&old));
    let replacement = world
        .insert(Body {
            x: 99,
            velocity: None,
        })
        .unwrap();
    let reused = old.slot() == replacement.slot();
    let stale = world.inspect(&old).is_none();
    assert!(reused && stale);
    assert_eq!(replacement.generation(), 1);
    assert!(!world.set_velocity(&old, Some(7)));
    world.step();
    assert_eq!(
        world.inspect(&replacement),
        Some(Body {
            x: 99,
            velocity: None
        })
    );
    assert_eq!(snapshot.x, 2);
    println!("reused={}, stale={}", u8::from(reused), u8::from(stale));
    let mut foreign = World::new();
    let collision = foreign
        .insert(Body {
            x: 7,
            velocity: Some(0),
        })
        .unwrap();
    assert_eq!(
        (collision.slot(), collision.generation()),
        (old.slot(), old.generation())
    );
    assert!(world.inspect(&collision).is_none() && foreign.inspect(&old).is_none());
    println!("foreign rejected=1");
    let mut limited = World::new();
    let mut ancient = None;
    for generation in 0..=u8::MAX {
        let current = limited
            .insert(Body {
                x: 0,
                velocity: Some(0),
            })
            .unwrap();
        if generation == 0 {
            ancient = Some(current.clone());
        }
        assert_eq!((current.slot(), current.generation()), (0, generation));
        assert!(limited.remove(&current));
    }
    let after_retirement = limited
        .insert(Body {
            x: 5,
            velocity: None,
        })
        .unwrap();
    assert_eq!(after_retirement.slot(), 1);
    assert!(limited.inspect(ancient.as_ref().unwrap()).is_none());
    assert!(
        limited
            .insert(Body {
                x: 0,
                velocity: Some(0)
            })
            .is_none()
    );
    println!("retired slot={}", after_retirement.slot());
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn records_and_components_match_across_capability_changes() {
        for velocity in [None, Some(0), Some(-2), Some(3)] {
            let mut records = [
                Body { x: 0, velocity },
                Body {
                    x: 10,
                    velocity: None,
                },
            ];
            let mut world = World::new();
            let a = world.insert(records[0]).unwrap();
            let b = world.insert(records[1]).unwrap();
            for changed in [None, Some(0), Some(-1)] {
                assert!(world.set_velocity(&b, changed));
                records[1].velocity = changed;
                step_records(&mut records);
                world.step();
                assert_eq!(world.inspect(&a), Some(records[0]));
                assert_eq!(world.inspect(&b), Some(records[1]));
            }
        }
    }

    #[test]
    fn stale_copies_cannot_resolve_or_modify_reused_slots() {
        let mut world = World::new();
        let first = world
            .insert(Body {
                x: 0,
                velocity: Some(9),
            })
            .unwrap();
        let copied = first.clone();
        assert!(world.remove(&first));
        assert!(!world.remove(&copied));
        let replacement = world
            .insert(Body {
                x: 5,
                velocity: None,
            })
            .unwrap();
        assert_eq!(first.slot(), replacement.slot());
        assert!(world.inspect(&copied).is_none());
        assert!(!world.set_velocity(&copied, Some(7)));
        world.step();
        assert_eq!(
            world.inspect(&replacement),
            Some(Body {
                x: 5,
                velocity: None
            })
        );
    }

    #[test]
    fn foreign_identity_and_former_world_are_not_new_owners() {
        let old = {
            let mut world = World::new();
            world
                .insert(Body {
                    x: 0,
                    velocity: None,
                })
                .unwrap()
        };
        let mut world = World::new();
        let local = world
            .insert(Body {
                x: 0,
                velocity: None,
            })
            .unwrap();
        assert_eq!(
            (old.slot(), old.generation()),
            (local.slot(), local.generation())
        );
        assert!(world.inspect(&old).is_none());
        assert!(!world.remove(&old));
        assert!(world.inspect(&local).is_some());
    }

    #[test]
    fn last_generation_retires_instead_of_resurrecting_ancient_id() {
        let mut world = World::new();
        let ancient = world
            .insert(Body {
                x: 0,
                velocity: None,
            })
            .unwrap();
        assert!(world.remove(&ancient));
        for generation in 1..=u8::MAX {
            let current = world
                .insert(Body {
                    x: 0,
                    velocity: None,
                })
                .unwrap();
            assert_eq!((current.slot(), current.generation()), (0, generation));
            assert!(world.remove(&current));
        }
        let next = world
            .insert(Body {
                x: 0,
                velocity: None,
            })
            .unwrap();
        assert_eq!(next.slot(), 1);
        assert!(world.inspect(&ancient).is_none());
        assert!(
            world
                .insert(Body {
                    x: 0,
                    velocity: None
                })
                .is_none()
        );
    }

    #[test]
    fn snapshots_are_past_values_and_world_transfer_preserves_identity() {
        let mut world = World::new();
        let handle = world
            .insert(Body {
                x: 0,
                velocity: Some(1),
            })
            .unwrap();
        let snapshot = world.inspect(&handle).unwrap();
        let mut transferred = world;
        transferred.step();
        assert_eq!(snapshot.x, 0);
        assert_eq!(transferred.inspect(&handle).unwrap().x, 1);
    }

    #[test]
    fn full_world_preserves_existing_values() {
        let mut world = World::new();
        let a = world
            .insert(Body {
                x: 0,
                velocity: Some(0),
            })
            .unwrap();
        world
            .insert(Body {
                x: 7,
                velocity: None,
            })
            .unwrap();
        assert!(
            world
                .insert(Body {
                    x: 99,
                    velocity: Some(8)
                })
                .is_none()
        );
        assert_eq!(
            world.inspect(&a),
            Some(Body {
                x: 0,
                velocity: Some(0)
            })
        );
        assert!(world.set_velocity(&a, None));
        world.step();
        assert_eq!(
            world.inspect(&a),
            Some(Body {
                x: 0,
                velocity: None
            })
        );
    }
}
1
2
3
4
5
6
cargo fmt --check
cargo check --offline
cargo test --offline
cargo test --offline --release
cargo run --offline --quiet
cargo run --offline --release --quiet

Rc::ptr_eq compares identity-marker allocations, not their equal unit values. Retaining a handle keeps that marker distinguishable after world destruction, without retaining the entity. checked_add makes generation exhaustion explicit. Option represents missing components and failed resolution without confusing them with valid zeros.

The Rust world can move as one owner while its marker remains shared with issued handles. It is not Clone; naively deriving Clone would copy its marker and stores, creating two worlds that both accept the same identity. The C++ fixture instead prohibits both copying and moving. These are deliberately different owner APIs, not evidence that every C++ world must be immovable. Private fields keep ordinary callers from fabricating handles or bypassing component cleanup; internal implementation code still bears responsibility for the invariant.

Changed requirement: should this become ECS?

The record loop keeps all of one body's data together. The component world separates Position and optional Velocity, with a movement system joining matching slots. It is a deliberately small ECS-style layout, not a generic engine, archetype allocator, scheduler or serialization format. Fixed slots avoid dense-store swap removal; a production dense store adds its own mapping and cleanup obligations.

The tested equality says both layouts implement these movement fixtures. It does not say the component layout is faster, clearer or less work to maintain. With only this rule, records may be the better choice. ECS becomes a candidate when independently combined capabilities and cross-entity processing justify managing joins, identities and execution order.

If acceleration is added, either design must specify acceleration-before-movement or another intended order. ECS makes system order an explicit responsibility; moving a function into a system does not settle the rule. Structural changes during iteration need a defined immediate/deferred policy. See the ECS execution-order chapter and component-store invariants.

If slot reuse is unnecessary, monotonic non-reused IDs can remove the generation/free-slot policy. If a reference need exist only during one borrowed operation, a reference may remove the ID lookup entirely. Conversely, Rc/Arc or shared_ptr owning the body would deliberately extend its lifetime; that is not equivalent to a handle that must stop resolving when the world deletes it.

Identity is not an authorization token or a database key. The marker cannot be meaningfully serialized as a pointer. Persistence, concurrent access, cross-world transfer and durable IDs require new contracts rather than silently exporting slot and generation as the complete identity.

Intentional failures: owner transfer is not owner duplication

This independent Rust example transfers an owner and then tries to use its former binding:

1
2
3
4
5
6
7
8
struct World {
    values: Vec<i32>,
}
fn main() {
    let world = World { values: vec![0] };
    let transferred = world;
    assert_eq!(world.values, transferred.values);
}

Use the transferred owner. If independent duplication is required, copy the data explicitly and issue a new identity domain; do not share the old marker merely to make Clone convenient.

The C++ analogue rejects a copy by its declared owner policy:

1
2
3
4
5
6
7
8
9
class World {
public:
    World() = default;
    World(const World&) = delete;
};
int main() {
    World first;
    World duplicate = first;
}

This is a deleted-copy rule, not Rust's deinitialized-binding mechanism. Copyable handles in the full examples are different from copying the world that owns the components.

Decision criteria and exercises

Requirement Start with Obligation added
One owner, short-lived access Owned record plus borrowed operation Keep borrows within the owner's permitted mutation scope
Selection between operations Owner-scoped non-reused ID Resolve live membership and handle failed lookup
Recycle a bounded storage slot Owner, slot and generation Stale rejection, retirement and capacity exhaustion
Independently combined component processing ECS-style stores and systems Joins, complete cleanup, structural-change and execution-order rules
Independent persistent copy Data snapshot or new world with new IDs Define copied relationships and identity remapping
  1. Remove the owner check while retaining the foreign-world test. The broken program compiles but must fail the identity contract.
  2. Remove the generation check while retaining the stale-copy test. Confirm that the old handle must not resolve to the replacement.
  3. Deliberately leave the old velocity behind on remove, and avoid overwriting velocity when inserting a body with None. Retain the reused-slot movement test to expose inherited components.
  4. Add acceleration to both record and component layouts. State the order, compare owned snapshots and separately decide whether the edit map favours either design.
  5. Design a no-reuse variant. Explain which tests remain relevant and why numeric ID exhaustion is still a policy even without generations.

Part 6 checkpoint: defend the design boundary

Revisit each decision with a changed requirement and a test, not a pattern name:

  1. Strategy: a policy now keeps a call count. Distinguish copying owned state from borrowing retained state; test inclusive boundaries and call order.
  2. State: opening may fail and must permit retry. Decide whether failure returns the consumed owner or retains state. Typestate restricts available operations; it does not force an eventual close.
  3. Factory/Builder: a new raw input path appears. Delegate validation and test omission, zero and cross-field precedence.
  4. Visitor: add a variant and an operation separately. Identify each place requiring a semantic decision; exhaustive handling does not prove correct handling.
  5. Observer: replace direct calls with queued delivery. Write capacity, fan-out, acknowledgement and shutdown policies before claiming equivalent behaviour.
  6. Ownership/IDs/ECS: require slot reuse. Test foreign, deleted and recycled identity, cleanup and retirement before deciding whether separate component stores are worth maintaining.

For each answer, name the owner, allowed mutation, failure result and change boundary. Defend when the simpler alternative should remain. These exercises complement the six Rust/C++ comparison contracts, not a benchmark ranking.

Verification and further study

The exact programs were verified on Raspberry Pi 4B with rustc/cargo 1.99.0, GCC 14.2.0 and kernel 6.18.50+rpt-rpi-v8. Rust passed six tests in debug and release, exact-source formatting and both output checks, plus the transferred-owner repair. The former owner failed with E0382. Three intentionally broken variants still compiled but failed debug tests for foreign identity, recycled identity or component cleanup. C++20 passed output/assertions and an additional owner/snapshot repair at -O0 and -O2, with assertions enabled; copying the declared noncopyable owner failed compilation. Both complete fixtures exercised retirement after all 256 generations without counter wrap. They do not prove all arithmetic, concurrency or allocator behaviour.

Continue with the C++ ECS design course for a connected simulation and writer traces. Return to the Rust language-course review to check how ownership, matching, visibility and library contracts relate to the language specification.

Previous: Observer and shutdown · Course overview

Donate