Skip to content

The Rift ECS

This is the hands-on companion to ECS Fundamentals. The fundamentals page explains what an Entity-Component-System is; this page shows how Rift does it - the real types, the DSL you’ll write, and the loop that runs it all.

If you’ve never touched an ECS before, read the fundamentals first. If you’re ready to build, start here.

Entities have no state and no logic - they’re just a container with a unique id. An entity represents an object in the world: a player, an npc, a tree, a fireball.

Rift ships with built-in “entity types”, to give you an idea:

  • Actor (player character)
  • Npc
  • GroundItem
  • Projectile
  • WorldObject
  • WorldSound

You instantiate these using the create() static method on each class, but this is a convenience helper only. Under the hood all entities are simply Entity.

// a player avatar
class Actor private constructor() : Entity() {
companion object {
fun create(name: String, tile: Tile): Actor {
val player = Actor()
player.addComponent(
Identity(
name = name
),
Position(
tile = Tile(2964, 3378)
),
Appearance()
)
return player
}
...
}
}

Generally speaking: an entity is defined as a sum of its assigned components. We do not discriminate by entity type when applying game logic.

For the next section, lets create an NPC

val goblin = Npc.create(id=1234)

Components are state holders. They represent the traits and attributes of an entity. A component can be a bare flag, or it can carry fields. Either way, components hold state only - never logic.

// a flag: its presence alone is the signal.
class Poisonable() : Component()
// a component with fields
data class Health(
var current: Int,
val max: Int,
) : Component()
// another, for the next example
data class Poisoned(
val damage: Int,
val remainingCycles: Int
) : Component()

Components are attached to entities to give them substance:

goblin.addComponent(
Poisonable(),
Poisoned(damage=5, remainingCycles=120),
Health(current=10, max=10)
)

Systems contain your game logic and behaviour - they act on components. A system is a pure function: data in, data out, no side effects.

A system operates on a specific set of components. Every cycle, it runs against every entity that holds that set - and keeps running for as long as the entity has a matching set. This means systems are opt-in: an entity takes part simply by carrying the right components.

val PoisonSystem = system<Components.Three<Poisonable, Poisoned, Health>> {
name = "PoisonSystem"
run { (_, poisoned, health) ->
// apply a tick of poison
health.current -= poisoned.damage
poisoned.remainingCycles--
}
cleanup { (_, poisoned) ->
if (poisoned.remainingCycles <= 0) entity.removeComponent<Poisoned>
}
}

A system lists the components it depends on. The run block receives them in the same order:

val MovementSystem = system<Components.Two<Moving, Position>> {
name = "MovementSystem"
run { (moving, position) ->
position.tile = moving.next()
}
}

A system can also declare optional dependencies with OptionalComponents - these don’t change which entities match, they’re just delivered as nullable so a system can adapt when they’re present (e.g. move faster if the entity is also Running).

val MovementSystem = system<
Components.Two<Moving, Position>,
OptionalComponents.One<Running>
> {
name = "MovementSystem"
run { (moving, position), (running) ->
var next = moving.next()
if (running != null) {
// we move two tiles this cycle
next = moving.next()
}
position.tile = next
}
}

The game loop runs your systems in serial, always in a deterministic order, and the order is defined by you.

Game logic in Rift is behaviour-atomic, not entity-atomic.

Many engines are entity-atomic: they take one entity and run all of its logic - move, heal, take damage - before touching the next. Which entity happens to go first becomes an invisible detail that quietly decides who acts first.

Rift runs the other way round. A single behaviour is applied to every entity before the next behaviour begins: HealingSystem heals everyone, then PoisonSystem damages everyone - the two are never interleaved per entity. Each behaviour is one clean, ordered pass.

You choose the order these effects happen in, to suit your game. For example, “always apply healing before poison damage”:

game_loop {
main(
// applies healing
HealingSystem,
// applies poison
PoisonSystem
)
}

Healing is registered first, so everyone heals before anyone takes a hit. Flip the two lines and the rule flips with them. Ordering is a real lever on the balance and feel of your game, and it’s explicit.

Systems run serially by default. If a behaviour can afford it, you can turn on parallelism per system with a single flag - the system fans out across threads, then joins back in before the loop proceeds:

val MovementSystem = system<...> {
name = "MovementSystem"
parallel = true
run { ... }
}

One of the foundational goals of the engine is to have a simple, declarative, user-centric API surface.

This means:

  • The interfaces are designed first, and the engine internals work around the interface’s constraints.
  • Engine internals should never leak into component APIs, or shape their design.

It’s a delicate balance to strike, but we’ll err on the side of losing performance before we lose developer experience. Fast but esoteric is not an acceptable trade-off - it’s a failure state.