Components and entities
Tide is an ECS: the world is made of entities, entities are made of components, and systems run code over them. This page is about the data; Systems is about the code.
Components
A component is plain data, declared with component:
component Health
{
int value = 100;
int max = 2 * 2 * 25;
}
component Poisoned { }A component with no fields, like Poisoned, is still useful: systems can pick the entities that have it, or don't.
Defaults
Fields can have a default. It must be a constant: literals, constructors of built-in types, struct values of constants, Math functions, built-in constants like quaternion.identity, and operators on those. It can name an asset too, like Texture face = logo;. A default can't read other fields, singletons or Time.
component Turret
{
float angle = Math.Radians(45);
float3 offset = float3(0, 1.5, 0);
Color tint = Color.red;
}Fields without a written default take their type's default: zero for numbers, false for bools, empty text and collections, and a struct's own field defaults for a struct. Entity fields always start as the null entity, and can't have another default. Default values explains the default expression.
Singletons
A singleton holds state there's one of per world, like the rules, the score or a random seed. Other ECSs call them resources.
singleton Physics
{
float3 gravity = float3(0, -9.81, 0);
int collisionCount;
}Singletons start with their defaults when the world is created. Systems take them as parameters, like components (see Systems).
Time is a built-in singleton. Systems can read it, but not write it:
| Field | What it is |
|---|---|
float dt |
The length of a tick in seconds |
int tick |
The tick, which counts from 0 |
float t |
The seconds since the match started: tick * dt |
int tickRate |
The ticks in a second: 1 / dt to the nearest whole number |
float maxRewind |
Configured collider history in seconds, from settings; 0 disables it |
t is worked out from the tick each time, so it never drifts, and views see it move every frame. A float holds about 7 digits, so it gets coarser as the match goes on (after a day, its steps are about 8 milliseconds): a timer that has to be exact counts in tick.
What makes one match unlike another is its seed, Random.seed, which the match's random numbers come from.
Spawning entities
Spawn makes an entity from a list of component values:
var e = Spawn(Transform { localScale = float3(1, 1, 1) }, Player);A component value is written like Health { max = 200 }, without new. Fields left out take their defaults, and a bare type name, like Player, takes every default.
An entity has at most one component of each type. When it needs several of something, use a list inside one component, one entity per item that points back at its owner, or separate component types.
Changing entities
e.Add(Stunned { duration = 2 });
e.Remove(Stunned);
e.Destroy();Addof a component the entity already has replaces its value.Removeof a component the entity doesn't have does nothing.Add,RemoveandDestroyon an entity that's already gone do nothing.
These are structural changes, and they don't happen right away. They're recorded, and applied together at the end of the tick, in the order they were made. The handle Spawn returns can be used at once, for example to store in a component, but the entity's data can only be read once the changes apply.
In a system that splits its entities across threads, the handle is temporary until the system is done, when the entity gets its ID and every handle the system kept becomes the real one (see The schedule). Text shows it as Entity(new) meanwhile.
That's what lets systems run side by side: nothing they do changes which entities exist until they're all done.
A function can Spawn, Add, Remove and Destroy too, for the code that calls it. It takes the entity it changes as a parameter:
Entity Drop(float3 at)
{
return Spawn(Transform { localPosition = at }, Crate);
}
void Stun(Entity target)
{
target.Add(Stunned { duration = 2 });
}Each ball below is an entity with a Ball and a Health. Tap one to Add a Poisoned to it: a system that takes with Poisoned then drains its health, and destroys it when none is left. Tap it again to Remove the component, and it recovers. Tapping the ground spawns a ball. A view can't change the match itself, so each tap goes in as a command.
settings { title = "Entities"; tickRate = 30; maxPlayers = 1; }
component Ball
{
float2 position;
float radius = 0.3;
}
component Health { int value = 100; }
component Poisoned { } // No fields: it only marks an entity
scene Main { }
event(Spawned) Setup(with Main)
{
Spawn(Ball { position = float2(-1.2, 0.4) }, Health);
Spawn(Ball { position = float2(0, -0.3) }, Health, Poisoned);
Spawn(Ball { position = float2(1.2, 0.5) }, Health);
}
// What a tap asks of the match. Views can't change it themselves.
command Drop { float2 at; }
command Poison { }
command Cure { }
command(Drop drop, PlayerID sender) DropBall()
{
Spawn(Ball { position = drop.at }, Health { value = 40 });
}
// `this` is the entity the command was sent to. Add and Remove wait for the
// end of the tick, and so does Destroy.
command(Poison poison, PlayerID sender) PoisonBall(with Ball) { this.Add(Poisoned); }
command(Cure cure, PlayerID sender) CureBall(with Ball) { this.Remove(Poisoned); }
system Sicken(mut Health health, with Poisoned)
{
health.value -= 1;
if (health.value <= 0) this.Destroy();
}
system Recover(mut Health health, without Poisoned)
{
health.value = Math.Min(health.value + 1, 100);
}
// This machine's side: what the pointer is on, and the counts to show.
local singleton Pointing
{
float2 world;
bool down;
bool onBall;
int balls;
int poisoned;
}
view Begin(Devices devices, mut Pointing pointing)
{
Draw.Clear(Color(0.086, 0.086, 0.094));
Draw.SetCamera(float2(0), 2.2);
pointing.world = Draw.ScreenToWorld(Screen.Flip(devices.pointer.position));
pointing.down = devices.pointer.press.down;
pointing.onBall = false;
pointing.balls = 0;
pointing.poisoned = 0;
}
view Healthy(Ball ball, Health health, mut Pointing pointing, without Poisoned)
{
var size = ball.radius * (0.4 + health.value * 0.006);
Draw.Circle(ball.position, size, Color(0.98, 0.75, 0.14));
pointing.balls += 1;
if (Math.Distance(pointing.world, ball.position) > ball.radius) return;
pointing.onBall = true;
if (pointing.down) this.Send(Poison { });
}
view Sick(Ball ball, Health health, mut Pointing pointing, with Poisoned)
{
var size = ball.radius * (0.4 + health.value * 0.006);
Draw.Circle(ball.position, size, Color(0.18, 0.83, 0.75));
Draw.WireCircle(ball.position, ball.radius, 0.03, Color(0.18, 0.83, 0.75));
pointing.balls += 1;
pointing.poisoned += 1;
if (Math.Distance(pointing.world, ball.position) > ball.radius) return;
pointing.onBall = true;
if (pointing.down) this.Send(Cure { });
}
view Ground(Pointing pointing)
{
if (pointing.down && !pointing.onBall) Send(Drop { at = pointing.world });
Draw.SetScreen();
Draw.Text($"{pointing.balls} entities, {pointing.poisoned} with Poisoned", float2(16, 14), 18, Color.white);
Draw.Text("Tap a ball to poison or cure it. Tap the ground to spawn one.",
float2(16, Screen.height - 30), 13, Color(0.64, 0.64, 0.68));
}Transform and parenting
Transform is built in, with float3 localPosition (zero), quaternion localRotation (identity), and float3 localScale (one). Each is relative to the entity's parent; a root's values are its world placement. Add it like any other component.
event(Spawned) Assemble(with Main)
{
var parent = Spawn(Transform { localPosition = float3(10, 0, 0) });
var child = Spawn(Transform { localPosition = float3(2, 0, 0) });
child.SetParent(parent, worldPositionStays: false);
}After those changes apply, the child's world position is (12, 0, 0). SetParent(parent) preserves its world placement by adjusting its local values; false keeps its local values. SetParent(null) detaches it. These changes apply at the end of the tick or local frame, in recorded order.
Read world.LocalToWorld(entity) for its composed float4x4, and world.Parent(entity) for its parent's handle. They read the latest stored state, as other World methods do. An entity without a Transform contributes identity; a missing entity gives identity or a null parent.
With state visibility, a hidden spatial parent does not hide its public child. The projected hierarchy cuts private links and preserves placement only for subtrees containing permitted Transform state; a parent can therefore read as null on a client.
Destroying a parent destroys its descendants, children before their parent. A cycle, a dead handle or a parent from the other world is rejected. Runtime rejections print their reason and leave the hierarchy unchanged. Preserving world placement requires a Transform on the child and an invertible parent transform. Nonuniform scales combined with rotation can create shear; three local TRS values cannot preserve that matrix exactly.
The same Transform works on local entities: use local Spawn(Transform) and local Transform transform in a view's parameters. Entity and LocalEntity still prevent crossing the match/local boundary. Loaded scene entities remain independent roots. Reparenting ordinary entities into a different scene transfers their descendants into that scene; detaching retains scene ownership.
Entity handles
Entity is a handle to an entity. Components can hold them, to point at each other:
component Weapon
{
Entity owner;
}default is the null entity, so weapon.owner == default tells whether it points anywhere, and weapon.owner = default; clears it.
A handle leads to the other entity's components through the World: world.Get<Health>(weapon.owner) (see Other entities).
In a system, view or event handler that runs for an entity, this is that entity:
system Expire(mut Lifetime life)
{
life.ticks -= 1;
if (life.ticks <= 0) this.Destroy();
}In local code that runs for a local entity, like a view of local components, this is a LocalEntity. Code that runs once per tick, frame or event runs for no entity, so it has no this, and neither do functions. In a frag, this is the pixel it runs for.
A component's methods have this too: the entity whose component they're called on. Methods are specialized for the placement used by the caller, so this is an Entity for a match component and a LocalEntity for a local component. Explicit parameter and return types keep their meaning.
component Health
{
int value;
bool IsMine(Entity attacker) { return attacker == this; }
}So a method that uses this can only be called on a component the code runs for, like a system's Health health. A copy of one, in a local variable or a function's parameter, belongs to no entity.
Archetypes
An archetype is a set of components that entities share, and entities with the same set are stored together. There are no archetype declarations: the compiler finds every one from the code, from what each Spawn makes and what Add and Remove can turn it into. It follows the code as written: this.Add(Stunned) in a system only reaches the entities that system runs for, and e.Add(Stunned) on an e a Spawn in the same code made reaches that one, so toggling a component on a few kinds of entity makes a few archetypes. An Add on an entity read from a field or a parameter could be on any entity, so it reaches every archetype.
Limits
A component that entities gain and lose all the time, like Stunned or Selected, would still double the archetypes it can reach. Mark it [Sparse]: it's kept as a presence bit and its value in every archetype it can reach, so Add and Remove of it move no entity, and systems that take or filter on it skip the rows that haven't got it. A tag (a component with no fields) that any Add or Remove names is sparse by itself; one only set at Spawn isn't, so with Rock costs nothing per entity.
[Sparse]
component Stunned
{
float left;
}
system Recover(Time time, mut Stunned stunned)
{
stunned.left -= time.dt;
if (stunned.left <= 0) this.Remove(Stunned);
}For now, a game has at most 256 components and 1024 archetypes. Entities, the rows of each archetype, and the structural changes and events in a tick grow as they need, with no limit but memory (see C hosts).