How ECS works in Tallow
Tallow 0.2 runs on an entity component system (ECS) core, built on Flecs. You can make a whole game without thinking about it: scripts look the same, and the engine stores their data the ECS way underneath. This tutorial explains what is going on, then shows the three ways you can use the ECS yourself, from the simplest to the fastest.
Step 1: The three ideas
An ECS splits a game into three kinds of thing:
| Idea | What it is | In Tallow |
|---|---|---|
| Entity | An ID. It has no data or behaviour of its own. | Every GameObject is an entity. You can also make plain entities with no GameObject. |
| Component | A piece of plain data attached to an entity. | A struct marked [Component], plus the engine's own data such as transforms, lights and bodies. |
| System | Code that runs over every entity with a given set of components. | A Query<...> loop in a script, or World.AddStep for advanced scheduling. |
The key trick is how the data is stored. Entities with the same set of components share a table, and each component is a packed array in it:
Table A: entities with [Transform, Velocity] entity Transform Velocity #12 (0,1,0) (1,0,0) #13 (3,1,0) (0,0,2) #17 (9,1,4) (2,0,0) Table B: entities with [Transform, Velocity, Health] entity Transform Velocity Health #40 (5,0,2) (0,0,1) 100 #41 (6,0,2) (0,0,1) 80
A system that wants every entity with Transform and Velocity visits both tables and walks each array from start to end. The CPU is very good at that, because memory is read in order with no pointer chasing. It is also easy to split across threads. That is why an ECS can update hundreds of thousands of things a frame.
Step 2: What Tallow already does for you
You get most of the benefit without writing any ECS code:
- Each GameObject is a Flecs entity, and built-in data such as transforms, lights, cameras and physics bodies lives in ECS tables.
- Engine components such as
Rigidbody,Light,CameraandCharacterControllerare handles: small values pointing at that data. Check one withif (handle). - Scripts are registered with the ECS, and compatible rendering is batched automatically.
An ordinary Script is still the right place to start:
[Component]
class Spinner : Script
{
public Vector3 speed = .(0, 45, 0);
public override void Update(TickInput input, float deltaTime)
{
Transform.Rotate(speed * deltaTime);
}
}
Reach for the next steps when you have data you want to share between scripts, or when the Profiler shows many objects doing the same work.
To look inside the ECS while the editor runs, open Window > Flecs Explorer. It serves the current scene to the Flecs Explorer in your browser, where you can browse entities, components and tables.
Step 3: Data components
A data component is a struct marked [Component]. In the Project panel, choose Script > ECS > Data Component (struct) and name it Wobble:
using System;
using Tallow;
namespace MyFirstGame;
/// Packed ECS data. Attach to a GameObject or add to a World entity.
[Component]
struct Wobble
{
public float speed;
public float amount;
public float time;
}
Save it. Data components behave like any other component in the editor: add one to a GameObject from Add Component > Data, edit its fields in the Inspector, and it is saved with the scene. Mark fields [Sync] to network them on objects with a Network Identity. Script > ECS > Synced Data Component starts you off.
A script can read and change the data on its own object. GetComponent returns a pointer to the row in the table:
[Component]
class WobbleScript : Script
{
public override void Update(TickInput input, float deltaTime)
{
let wobble = GetComponent<Wobble>();
if (wobble == null)
return;
wobble.time += deltaTime;
Transform.Rotation = Quaternion.Euler(0, 0, Math.Sin(wobble.time * wobble.speed) * wobble.amount);
}
}
Keep values and engine references (GameObjects, handles, entities) in data components, not objects the struct would own and have to delete.
Script + Data in the same menu makes a script and its data struct together.
Step 4: One query for many objects
With one WobbleScript per object, a hundred wobbling objects means a hundred script calls a frame. A query does the same work in one loop over the packed data.
Choose Script > ECS > GameObject Query Processor and name it WobbleSystem, then change it to:
[Component]
class WobbleSystem : Script
{
public override void Update(TickInput input, float deltaTime)
{
// Every scene GameObject that has Wobble. The query is cached by the World: don't delete it.
let query = Scene.World.ObjectsWith<Wobble>();
for (var chunk in query)
{
let wobbles = chunk.Write<Wobble>(); // packed array of Wobble
let objects = chunk.Read<ObjectLink>(); // the GameObject of each row
for (int i < chunk.Count)
{
let gameObject = objects[i].gameObject;
if (!gameObject.ActiveInHierarchy)
continue;
wobbles[i].time += deltaTime;
let angle = Math.Sin(wobbles[i].time * wobbles[i].speed) * wobbles[i].amount;
gameObject.Transform.Rotation = Quaternion.Euler(0, 0, angle);
}
}
}
}
To try it:
- Remove
WobbleScriptfrom your objects (or don't add it), but keep the Wobble data on them. Set Speed to about3and Amount to about20. - Make one empty object (GameObject > Create Empty) named
Systemsand add WobbleSystem to it. - Duplicate a wobbling cube many times with Ctrl+D and press Play.
One script now drives every object that has the data. Behaviour is decided by which data an object has. To stop an object wobbling, remove its Wobble.
Things to know about chunks:
chunk.Read<T>()andchunk.Write<T>()are only valid inside that chunk's loop. Never keep a pointer to a row.- Rows can move between tables when components are added or removed, so don't rely on the order you see them in for gameplay order, spawning or random numbers.
Step 5: Plain entities for huge numbers
A GameObject has a transform, a name and a place in the Hierarchy. For particles, crowds or simulations of a hundred thousand things, you can skip all that and make plain entities that only hold your data.
Choose Script > ECS > Pure ECS Bulk Entities and name it Swarm. Change it to move points around:
using System;
using System.Collections;
using Tallow;
namespace MyFirstGame;
[Component]
struct Mover
{
public Vector3 position;
public Vector3 velocity;
}
[Component]
class Swarm : Script
{
public int32 count = 100000;
List<Entity> mEntities = new .() ~ delete _;
Query<Mover> mQuery ~ delete _; // a query you create is yours to delete
public override void Start()
{
Scene.World.CreateMany<Mover>(count, mEntities); // no GameObject each
mQuery = new .(Scene.World);
var random = scope Random(1);
for (var chunk in mQuery)
{
let movers = chunk.Write<Mover>();
for (int i < chunk.Count)
movers[i].velocity = .((float)random.NextDouble() - 0.5f, 0, (float)random.NextDouble() - 0.5f);
}
}
public override void Update(TickInput input, float deltaTime)
{
// Spread over the job threads. Each chunk is a packed array.
mQuery.ParallelFor(scope (chunk) =>
{
let movers = chunk.Write<Mover>();
for (int i < chunk.Count)
movers[i].position += movers[i].velocity * deltaTime;
});
}
public override void OnDestroy()
{
for (let entity in mEntities)
if (Scene.World.IsAlive(entity))
Scene.World.Delete(entity);
mEntities.Clear();
}
}
Plain entities don't draw anything by themselves. Use them for the simulation, and draw the results with something built for many instances, such as a particle system or lines.
Rules for ParallelFor, because the work runs on several threads at once:
- Only change the row you are given.
- Don't create or delete entities, add or remove components, or touch shared objects or the editor inside it.
- Each result should depend only on its own row and on values that don't change during the loop.
Step 6: Which one should I use?
| You have | Use |
|---|---|
| Behaviour on a few objects | An ordinary Script with Update. |
| Data several scripts share, or tuned in the Inspector | A data component ([Component] struct). |
| Many objects doing the same thing | A query: Scene.World.ObjectsWith<T>() from one script. |
| Tens of thousands of things with no need for a GameObject | Plain entities with World.CreateMany and your own Query<...>, using ParallelFor where it helps. |
Start simple and move down the table only when the Profiler says so. The Tallow Gallery's courtyard shows the first and third side by side: the blue crowd runs one script each, and the orange crowd is driven by one query.
Step 7: Visual scripting and going further
Graphs can use your data components too. After the project compiles, the graph search has an ECS category with Has, Add and Remove for data components, Get and Set for their fields, and a For Each loop over every object with a given data type.
To read more:
- Writing scripts in 0.1 and 0.2: the script model side by side.
- Advanced systems and ordinary scripts:
World.AddStep, ordering, parallel work and measuring. - The 0.2 API reference:
World,Query,EntityandObjectLink.