TinyEcs: a reflection-free dotnet ECS library, born to meet your needs.
- Fast
- Reflection-free design
- NativeAOT & bflat support
- Zero runtime allocations
- Relationships support
Bevy systems
concept
net9.0
đźš§ Early development stage: Expect breaking changes! đźš§
var world = new World();
EntityView entity = world.Entity();
var world = new World();
EntityView entity = world.Entity();
entity.Delete(); // or world.Delete(entity);
bool exists = entity.Exists(); // or world.Exists(entity);
Components are the real data that an entity contains. An array will be allocated per component. You can access to the data using the world.Get<T>()
api.
Requirements:
- must be a
struct
- must contains one field at least
EntityView entity = world.Entity()
.Set(new Position() { X = 0, Y = 1, Z = -1 });
struct Position { public float X, Y, Z; }
Tags are used to describe an entity. No data will get allocated when adding a tag. Tags are not accessible from the world.Get<T>()
api.
Requirements:
- must be a
struct
- must be empty
EntityView entity = world.Entity()
.Add<IsFruit>();
struct IsFruit;
entity.Unset<IsFruit>()
.Unset<Position>();
bool isFruit = entity.Has<IsFruit>();
bool hasPosition = entity.Has<Position>();
Attention: you can query for a non empty component only!
ref Position pos = ref entity.Get<Position>(); // or world.Get<Position>(entity);
AddChild
will add a component called Children
to the parent entity and Parent
to each child.
Children
contains a list of all entities associated to the parent.
A child can have an unique parent only.
var root = world.Entity();
var child = world.Entity();
var anotherChild = world.Entity();
root.AddChild(child);
root.AddChild(anotherChild);
ref var children = ref root.Get<Children>();
foreach (var child in children) {
}
// Remove the child from the parent
root.RemoveChild(anotherchild);
// This will delete all children too
root.Delete();
The scheduler class is highly ispired by the bevy scheduler concept. This is the real deal for modern game engines which want to implement their game beahviour fast and easy.
A scheduler can handle one world only.
var world = new World();
var scheduler = new Scheduler(world);
Control each tick using
while (!exit) {
scheduler.RunOnce();
}
or just run until a certain condition is met.
var exitCalledFn = ExitCalled;
scheduler.Run(exitCalledFn);
bool ExitCalled() {
// handle your logic here
}
Systems are where "things" happen. You should wrap your game logic using systems!
var printSomethingFn = PrintSomething;
scheduler.OnUpdate(printSomethingFn);
// The scheduler will run all systems registered before one time
scheduler.RunOnce();
void PrintSomething() => Console.WriteLine("Hello from TinyEcs!");
The systems declaraction order matters.
scheduler.OnUpdate(() => Console.WriteLine("Foo"));
scheduler.OnUpdate(() => Console.WriteLine("Bar"));
scheduler.OnUpdate(() => Console.WriteLine("Baz"));
// This will print:
// Foo
// Bar
// Baz
scheduler.RunOnce();
Systems are organized in stages:
scheduler.OnStartup(() => Console.WriteLine("1"));
scheduler.OnFrameStart(() => Console.WriteLine("2"));
scheduler.OnBeforeUpdate(() => Console.WriteLine("3"));
scheduler.OnUpdate(() => Console.WriteLine("4"));
scheduler.OnAfterUpdate(() => Console.WriteLine("5"));
scheduler.OnFrameEnd(() => Console.WriteLine("6"));
scheduler.OnStartup(() => Console.WriteLine("7"));
// This will print:
// 1 to 7 in order
scheduler.RunOnce();
// This will print:
// 2 to 7 in order. "1" get excluded because the OnStartup are one-shot systems.
scheduler.RunOnce();
You can set 0 to 16 parameters in any order of any type per system.
scheduler.OnUpdate((
World world,
Query<Data<Position>> query1,
Query<Data<Position>, Without<Velocity>> query2,
Res<TileMap> tileMap
) => {
});
Access to the World
instance.
// Spawn an entity during the startup phase
scheduler.OnStartup((World world) => world.Entity());
Access to the World
instance, but in deferred mode.
// Spawn an entity during the startup phase in deferred mode
scheduler.OnStartup((Commands commands) => commands.Entity());
TData
constraint is a Data<T0...TN>
type which is used to express the set of components that contains data (no tags).
Queries are one of the most type used in systems. They allow you to pick entities and manipulate the data associated with them.
scheduler.OnUpdate((
Query<Data<Position, Velocity>> query
) => {
// access to the entity data
foreach ((Ptr<Position> pos, Ptr<Velocity> vel) in query) {
pos.Ref.X += vel.Ref.X;
pos.Ref.Y += vel.Ref.Y
}
// Access to the entity using the same query
foreach ((PtrRO<EntityView> entity, Ptr<Position> pos, Ptr<Velocity> vel) in query) {
pos.Ref.X += vel.Ref.X;
pos.Ref.Y += vel.Ref.Y
}
});
Filters help you to express a more granular search.
This will tell to the query to grab all entities that contains the type T
. T
can be a component or a tag.
Query<
Data<Position, Velocity>,
With<Mass>
> query
This will tell to the query to exclude from the query all entities that contains the type T
. T
can be a component or a tag.
Query<
Data<Position, Velocity>,
Without<Mass>
> query
The query will check if T
is changed from last execution.
Query<
Data<Position, Velocity>,
Changed<Position>
> query
The query will check if T
has been added from last execution.
Query<
Data<Position, Velocity>,
Added<Position>
> query
This will tell to the query to try to get all entities that contains the type T
.
Which means the query will returns entities which might not contains that T
.
Check if T
is valid using Ptr<T>::IsValid()
method.
Query<
Data<Position, Velocity>,
Optional<Position>
> query
foreach ((Ptr<Position> maybePos, Ptr<Velocity> vel) in query) {
if (maybePos.IsValid()) {
maybePos.Ref.X += 1;
}
}
Sometime you need to find entities without specifing any Data<T0...TN>
.
Query<
Empty,
Filter<With<Position>, With<Mass>, Without<Moon>>
> query
This is to mix all the filters above to create more complex queries.
Query<
Data<Position, Velocity>,
Filter<Optional<Position>, With<Mass>, Without<Moon>>
> query
Res<T>
is a special system parameter which allow you to inject singleton classes/structs of any type globally.
Here is where you gonna put your GameNetworkSocket
implementation, your super TileMap
code, the GraphicDevice
, etc.
Now guess what? Yeah you did it. They can get called in systems sign.
// Declare the resource
scheduler.AddResource(new GameNetworkSocket());
scheduler.OnUpdate((Res<GameNetworkSocket> socket) => {
socket.Value.SendAttackPacket();
});
Local<T>
are the same of Res<T>
but it exists in the declared system only.
scheduler.OnUpdate((Local<int> counter) => {
counter.Value++;
Console.WriteLine("counter system A: {0}, counter.Value);
});
scheduler.OnUpdate((Local<int> counter) => {
counter.Value++;
Console.WriteLine("counter system B: {0}, counter.Value);
});
// This will print
// counter system A: 1
// counter system B: 1
scheduler.RunOnce();
Events are used to trigger behaviours between systems. Multiple system can read the same data using EventReader<T>
. Events lives for 1 frame only.
// Register the event
scheduler.AddEvent<OnClicked>();
// Read the events
scheduler.OnUpdate((EventReader<OnClicked> reader) => {
foreach (var clickedEvent in reader) {
}
});
// Create the events
scheduler.OnUpdate((EventWriter<OnClicked> writer, Res<MouseContext> mouseCtx) => {
if (mouseCtx.Value.IsLeftClicked()) {
writer.Enqueue(new OnClicked() { MouseLeft = true });
}
});
struct OnClicked { public bool MouseLeft; }
SchedulerState
is a system parameter which expose few Scheduler behaviour into the systems.
scheduler.OnUpdate((SchedulerState sched) => {
sched.AddResource(new TileMap());
});
State are simply enums useful to run certain systems in certain conditions.
This is a special system parameter which keeps the current state of T
.
// Register the state. No systems get triggered yet
scheduler.AddState(GameState.Loading);
// OnEnter/OnExit runs only when the state changes
scheduler.OnEnter(GameState.Loading, () => Console.WriteLine("enter Loading"));
scheduler.OnExit(GameState.Loading, () => Console.WriteLine("exit Loading"));
scheduler.OnEnter(GameState.GamePlay, () => Console.WriteLine("enter GamePlay"));
scheduler.OnExit(GameState.GamePlay, () => Console.WriteLine("exit GamePlay"));
scheduler.OnUpdate((State<GameState> state, Local<int> currentStateIndex) => {
var states = Enum.GetValues<GameState>();
// Switch to the next state
state.Set(states[currentStateIndex.Value % states.Length]);
currentStateIndex.Value += 1;
});
// This will run:
// exit Loading
// enter GamePlay
schduler.RunOnce();
// This will run:
// exit GamePlay
// enter Loading
schduler.RunOnce();
enum GameState
{
Loading,
Gameplay
}
Often you need to run a system only when a condition is met.
scheduler.OnUpdate((Res<int> val) => val.Value++);
scheduler.OnUpdate((Res<int> val) => Console.WriteLine("val: {0}", val.Value))
// Run the system only when `val` is even...
.RunIf((Res<int> val) => val.Value % 2 == 0)
// and when exist entities with [Position + Velocity]
.RunIf((Query<Data<Position, Velocity>> query) => query.Count() > 0)
// and when the scheduler is in a specific state
.RunIf((SchedulerState sched) => sched.InState(GameState.Gameplay));
Plugins are a way to organize your code better.
scheduler.AddPlugin<UIPlugin>();
scheduler.AddPlugin(new GameplayPlugin(1000));
struct GameplayPlugin : IPlugin {
public GameplayPlugin(int entitiesToSpawn) {
EntitiesToSpawn = entitiesToSpawn;
}
public int EntitiesToSpawn { get; }
public void Build(Scheduler scheduler) {
// declare your logic, use properties to apply any behaviour
}
}
struct UIPlugin : IPlugin {
public void Build(Scheduler scheduler) {
// declare your logic
}
}
This is a very basic example which doens't show the whole features set of this library.
using var world = new World();
var scheduler = new Scheduler(world);
// create the Time variable accessible globally by any system which stays fixed at 60fps
scheduler.AddResource(new Time() { FrameTime = 1000.0f / 60.0f });
scheduler.AddResource(new AssetManager());
var setupSysFn = Setup;
scheduler.OnStartup(setupSysFn);
var moveSysFn = MoveEntities;
scheduler.OnUpdate(moveSysFn);
var countSomethingSysFn = CountSomething;
scheduler.OnUpdate(countSomethingSysFn);
while (true)
scheduler.RunOnce();
void Setup(World world, Res<AssetManager> assets)
{
// spawn an entity and attach some components to it
world.Entity()
.Set(new Position() { X = 20f, Y = 9f })
.Set(new Velocity() { X = 1f, Y = 1.3f });
var texture = new Texture(0, 2, 2);
texture.SetData(new byte[] { 0, 0, 0, 0 });
assets.Register("image.png", texture);
}
void MoveEntities(Query<Data<Position, Velocity>> query, Res<Time> time)
{
foreach ((Ptr<Position> pos, Ptr<Velocity> vel) in query)
{
pos.Ref.X += vel.Ref.X * time.Value.FrameTime;
pos.Ref.Y += vel.Ref.Y * time.Value.FrameTime;
}
}
void CountSomething(Local<int> localCounter, Res<Time> time)
{
localCounter.Value += 1;
}
struct Position { public float X, Y; }
struct Velocity { public float X, Y; }
class Time
{
public float FrameTime;
}
class Texture
{
public Texture(int id, int width, int height)
{
Id = id;
Width = width;
Height = height;
}
public int Id { get; }
public int Width { get; }
public int Height { get; }
public void SetData(byte[] data)
{
// ...
}
}
class AssetManager
{
private readonly Dictionary<string, Texture> _assets = new ();
public void Register(string name, Texture texture)
{
_assets[name] = texture;
}
public Texture? Get(string name)
{
_assets.TryGetValue(name, out var texture);
return texture;
}
}
cd samples/TinyEcsGame
dotnet run -c Release
Inspired by: