Skip to content

kotserge/esc

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

155 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ECS Framework

Working title: the crate ships as esc (use esc = ... in Cargo.toml, use esc::prelude::*; in code). The name is provisional. -- misspelled & won't fix, as I am used to it now

Example - Traffic Simulation

Overview

In a traditional ECS, a system both reads and writes the world directly; later systems observe the mutations of earlier ones, and the framework's job is to arbitrate that ordering. Here, the core is different: a tick is a discrete transformation, and systems describe state changes against the current state rather than performing them. Every system in a phase observes the same starting world; nothing mutates until every system has finished proposing. The schedule's job is to:

  1. Run every system against the current world.
  2. Bucket all proposed Set / Remove changes by (component type, entity).
  3. Hand each bucket to its registered reducer; the reducer decides the next value for that pair.
  4. Apply the resulting mutations — despawn first, then component reductions, then spawn — in a single serial pass.

World Transition

This framework is heavily inspired by bevy and entt and I recommend using them instead if time-stepped simulations is not what you need.

Examples

Concepts

Components

Components are plain Rust types. Any Send + Sync + 'static type is a component via a blanket impl — there is no derive macro and no trait to implement. Component types must, however, be registered on the world before they are used.

use esc::prelude::*;

struct Position { x: i32, y: i32 }
struct Velocity { x: i32, y: i32 }

World

The World owns every entity, the components attached to them, and world-level Resources.

use esc::prelude::*;

let mut world = World::new();

Registration

Component types must be registered on a world before they are used. Registration assigns each type a dense id and creates its storage column; it is done up front, and the set of component types is fixed for the world's life.

use esc::prelude::*;

struct Position { x: i32, y: i32 }
struct Velocity { x: i32, y: i32 }

// Register directly…
let mut world = World::new();
world.register::<Position>();
world.register::<Velocity>();

// …or with the builder form:
let world = World::new()
    .with_component::<Position>()
    .with_component::<Velocity>();

Reading, writing, querying, or tracking a type that was never registered panics — an unregistered type is assumed not to exist in the simulation, so touching it is a programming error, not a silent "absent". A world holds at most 64 component types.

Entities

Entities are opaque ids returned by World::spawn. Components are attached at spawn time via a ComponentBundle — every component in the bundle must already be registered.

use esc::prelude::*;

struct Position { x: i32, y: i32 }
struct Velocity { x: i32, y: i32 }

let mut world = World::new()
    .with_component::<Position>()
    .with_component::<Velocity>();
let entity = world.spawn(
    ComponentBundle::new()
        .with(Position { x: 0, y: 0 })
        .with(Velocity { x: 1, y: 0 }),
);

assert!(world.contains::<Position>(entity));
let pos = world.get::<Position>(entity).unwrap();
assert_eq!((pos.x, pos.y), (0, 0));

Resources

Resources are world-level singletons keyed by type, used for ambient state that does not belong to any entity. Unlike components, resources need no registration.

use esc::prelude::*;

struct Time { seconds: f32 }

let mut world = World::new();
world.insert_resource(Time { seconds: 0.0 });

let time = world.resource::<Time>().unwrap();
assert_eq!(time.seconds, 0.0);

Systems read resources through &World. To mutate a resource from a system, see Mutable Systems.

Systems

Systems are plain functions or closures with the signature Fn(&World) -> Proposals. Any matching function is a system via a blanket impl.

use esc::prelude::*;

struct Position { x: i32, y: i32 }
struct Velocity { x: i32, y: i32 }

fn movement(world: &World) -> Proposals {
    world
        .query()
        .with::<Position>()
        .with::<Velocity>()
        .into_iter()
        .map(|view| {
            let p = view.get::<Position>().unwrap();
            let v = view.get::<Velocity>().unwrap();
            Proposal::set(view.id(), Position { x: p.x + v.x, y: p.y + v.y })
        })
        .collect()
}

A system never calls world.insert or world.despawn. It builds Proposals and returns them; the schedule does the writing.

Proposals

A Proposal is one of: set a component, remove a component, spawn a new entity, despawn an existing entity.

use esc::prelude::*;

struct Tile;

let mut world = World::new();
let entity_id = world.spawn(ComponentBundle::new());

let _ = Proposal::set(entity_id, Tile);
let _ = Proposal::remove::<Tile>(entity_id);
let _ = Proposal::spawn(ComponentBundle::new().with(Tile));
let _ = Proposal::despawn(entity_id);

Proposals are returned in a Proposals bag — an unordered multiset newtype around Vec<Proposal>. The framework deliberately does not commit to any iteration order between systems' contributions; reducers must be commutative.

Reducers

A reducer collapses every proposed change to one (component, entity) pair into the next value. The signature is Fn(Option<&C>, Changes<C>) -> Option<C> — returning None removes the component, Some(v) sets it.

use esc::prelude::*;

#[derive(Clone, Copy)]
struct Score(i32);

fn sum_scores(current: Option<&Score>, changes: Changes<Score>) -> Option<Score> {
    let start = current.copied().unwrap_or(Score(0)).0;
    let total = changes.into_iter().fold(start, |acc, ch| match ch {
        ComponentChange::Set(s) => acc + s.0,
        ComponentChange::Remove => acc,
    });
    Some(Score(total))
}

If two systems both propose a Set for the same (component, entity), both contributions land in the reducer's Changes<C> argument. The reducer decides what "merging two writes" means for that component type.

Phases

A Phase is a registered set of systems, reducers, and policies that share one full proposal/reduce/apply cycle. Phases are the unit of ordering: phases within a schedule run sequentially, and phase B sees the fully-mutated world produced by phase A.

use esc::prelude::*;

struct Tile;

fn no_op(_w: &World) -> Proposals { Proposals::new() }

let phase = Phase::new("tick")
    .with_default_strategy(DefaultStrategy::LastWriteWins)
    .with_system(no_op);

Schedules

A Schedule is an ordered list of phases. Schedule::step runs each phase's full cycle in turn against the world.

use esc::prelude::*;

fn no_op(_w: &World) -> Proposals { Proposals::new() }

let mut world = World::new();
let schedule = Schedule::new().with_phase(
    Phase::new("tick")
        .with_default_strategy(DefaultStrategy::LastWriteWins)
        .with_system(no_op),
);

schedule.step(&mut world);

A worked example

Entities with Position and Velocity, a movement system, and a reducer that sums per-axis deltas:

use esc::prelude::*;

#[derive(Clone, Copy, Default)]
struct Position { x: i32, y: i32 }

#[derive(Clone, Copy)]
struct Velocity { x: i32, y: i32 }

fn movement(world: &World) -> Proposals {
    world
        .query()
        .with::<Position>()
        .with::<Velocity>()
        .into_iter()
        .map(|view| {
            let v = view.get::<Velocity>().unwrap();
            Proposal::set(view.id(), Position { x: v.x, y: v.y })
        })
        .collect()
}

fn sum_positions(current: Option<&Position>, changes: Changes<Position>) -> Option<Position> {
    let mut next = current.copied().unwrap_or_default();
    for ch in changes {
        if let ComponentChange::Set(delta) = ch {
            next.x += delta.x;
            next.y += delta.y;
        }
    }
    Some(next)
}

fn main() {
    let mut world = World::new()
        .with_component::<Position>()
        .with_component::<Velocity>();
    let entity = world.spawn(
        ComponentBundle::new()
            .with(Position { x: 0, y: 0 })
            .with(Velocity { x: 1, y: 0 }),
    );

    let schedule = Schedule::new().with_phase(
        Phase::new("tick")
            .with_reducer(sum_positions)
            .with_system(movement),
    );

    for _ in 0..3 {
        schedule.step(&mut world);
    }

    let p = world.get::<Position>(entity).unwrap();
    assert_eq!((p.x, p.y), (3, 0));
}

For a more complete example see tests/it-conway-game-of-life.rs, which implements Conway's Game of Life on a periodic grid.

Features

Query Filters

Query narrows the entity set by component membership; with keeps entities that have a component, without keeps entities that lack one.

use esc::prelude::*;

struct Player;
struct Position;
struct Alive;

fn select_living_players(world: &World) -> Proposals {
    for view in world.query().with::<Player>().with::<Position>().without::<Alive>() {
        let _id = view.id();
    }
    Proposals::new()
}

A query iterates EntityViews; each view exposes the entity id plus typed component access via view.get::<C>().

Default Strategies

A Phase without a registered reducer for some component type panics when a proposal targets that type — unless a DefaultStrategy is set. The framework ships one strategy today: LastWriteWins.

use esc::prelude::*;

struct Health(i32);

fn heal(world: &World) -> Proposals {
    world
        .entities()
        .map(|e| Proposal::set(e, Health(100)))
        .collect()
}

let phase = Phase::new("tick")
    .with_default_strategy(DefaultStrategy::LastWriteWins)
    .with_system(heal);

Per-type reducers always take precedence over the default strategy.

Mutable Systems

Systems are read-only by default. A mutable system is a system that declares one to four resource types it needs to mutate; the framework hands it &mut R references alongside &World.

use esc::prelude::*;

struct Counter(i32);

fn bump(_world: &World, c: &mut Counter) -> Proposals {
    c.0 += 1;
    Proposals::new()
}

let mut world = World::new();
world.insert_resource(Counter(0));

Schedule::new()
    .with_phase(Phase::new("tick").with_mut_system(bump))
    .step(&mut world);

Constraints:

  • At most one mutable system per phase may declare any given resource. Two mut systems competing for &mut Counter in the same phase is a registration-time panic.
  • While a mutable system is running, any other system in the same phase that calls world.resource::<C>() for a declared C panics with a clear message naming the phase and the conflicting resource.
  • Resources are restored to the world even if the system panics; the lock is released on unwind.

Conditional Systems

A read-only system can be gated on a predicate via SystemExt::when. The wrapped system runs its body only when the predicate returns true; otherwise it contributes an empty Proposals to the phase.

use esc::prelude::*;

struct Paused;
struct Enemy;

fn physics(_w: &World) -> Proposals { Proposals::new() }
fn rendering_hints(_w: &World) -> Proposals { Proposals::new() }
fn spawn_more_enemies(_w: &World) -> Proposals { Proposals::new() }

let _ = Phase::new("tick")
    .with_default_strategy(DefaultStrategy::LastWriteWins)
    .with_system(physics)
    .with_system(rendering_hints.when(|w| w.resource::<Paused>().is_none()))
    .with_system(
        spawn_more_enemies.when(|w| w.query().with::<Enemy>().into_iter().count() < 10),
    );

Predicates compose: system.when(p1).when(p2) runs the body only if both pass. The outer predicate is checked first.

Predicates have no persistent state — they are Fn(&World) -> bool reads of the current world. For stateful gates (every N ticks, throttle, run-once), put the counter in a Resource advanced by a mutable system, then read it from the predicate.

Parallel Execution

A phase can opt into concurrent system execution and concurrent reducer fan-out. Both are off by default and compose orthogonally.

use esc::prelude::*;

fn no_op(_w: &World) -> Proposals { Proposals::new() }

let _ = Phase::new("tick")
    .with_default_strategy(DefaultStrategy::LastWriteWins)
    .with_system(no_op)
    .parallel()         // fan systems out across threads
    .parallel_reduce(); // fan reducer groups out across threads

Under .parallel(), all systems in the phase — mut and read-only — run concurrently on rayon's work-stealing pool. Mutable resources are taken out of the world before fan-out and put back after join, including on panic. The framework gives no order guarantee between systems; reducers must be commutative.

Under .parallel_reduce(), each (component, entity) reduction group runs on its own task. Useful when reducers are heavy or the world has many entities.

Lifecycle Events / Change Detection

Track what entered, changed, or left the world each tick. Opt in per component type (the type must be registered first):

use esc::prelude::*;

struct Position;

let world = World::new()
    .with_component::<Position>()
    .with_tracked::<Position>();

Once a type is tracked, queries and the world expose two visibility windows:

use esc::prelude::*;

struct Position;
struct Microbe;

let world = World::new()
    .with_component::<Position>()
    .with_component::<Microbe>()
    .with_tracked::<Position>()
    .with_tracked::<Microbe>();

// Previous tick (the common case — what changed last tick):
for view in world.query().with::<Microbe>().changed::<Position>() {
    let _ = view.id();
}
for view in world.query().added::<Position>() {
    let _ = view.id();
}
for entity in world.removed::<Position>() {
    let _ = entity;
}
for entity in world.despawned() {
    let _ = entity;
}
for entity in world.spawned() {
    let _ = entity;
}

// This tick so far (same-tick reactivity — what's already happened this Schedule::step):
for view in world.query().with::<Microbe>().pending().changed::<Position>() {
    let _ = view.id();
}
for entity in world.pending().removed::<Position>() {
    let _ = entity;
}

Semantics:

  • added and changed are disjoint: an entity that just got C this tick appears in added, not in changed. Iterate both to cover all upserts.
  • The previous-tick window is published at the start of every Schedule::step; direct callers of phase.step(&mut world) manage the boundary via World::flush_events.
  • The two windows are disjoint by construction — pending() exposes the in-progress buffer, the bare reads expose the published one.
  • Direct out-of-Phase::step mutations on the world (e.g. world.spawn(...) between phases or during setup) are silent. Events describe the proposal pipeline's output, not arbitrary world mutations.

This is enough to maintain a derived view of the world (spatial indices, per-faction counts, ordered tables) with cost proportional to the change in that view, not its size.

Module structure

The public API is exported from the crate root and from esc::prelude::*. Module paths under src/ are private; users should not import esc::world::World directly.

use esc::prelude::*;

For more, the integration tests under tests/ are worked examples, and the design history lives under docs/.

About

An ECS variant built around the idea that a tick is a transformation from one consistent world state to the next -- the same model that drives time-stepped simulations in physics, cellular automata.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages