OCaml Game Engine: ECS
By Edward Wibowo
I’m currently developing a game engine in OCaml called camlcade. Before I dive into how I designed camlcade’s Entity-Component-System (ECS), here are a couple of short video demos showing what the engine can currently do:
The engine is still very much in progress, and I plan to continue working on it when I have the time. It’s also a small, pet project of mine that I focused on during winter break (2024). Nevertheless, I’d like to share my thought process on how I ended up with camlcade’s current ECS design.
If you’re curious, you can check out some examples of camlcade in action.
ECS Basics
An ECS is a design pattern used in game development that splits a game into entities, components, and systems:
- Entities are objects in the game. They represent characters, enemies, projectiles, and so on.
- A component is a piece of data associated with an entity. For example, a
health
component might be afloat
representing health points, or avelocity
component could be a vector. By themselves, components do not provide functionality; they are purely data. - Systems operate on entities that have specific components. This is where most of the game logic resides. Different ECS implementations vary in how they define systems, but at their core, a system is a function that processes data in entities’ components.
Before exploring the details, it might help to note that there are different ways to store components in an ECS. Some are “archetypal,” grouping entities with the same set of components together for performance reasons. Others use different storage methods. In this post, I’ll outline my current design, which leverages unique IDs, extensible variants, and GADTs.
Establishing Entities
Each entity in the game is simply a unique identifier. Internally, an identifier is just a number:
type id = int
To ensure uniqueness whenever we create a new entity, we can do something like:
module Entity = struct
type t = int
let current = ref 0
let next () : t =
incr current;
!current
end
Every time Id.Entity.next ()
is called, it generates a fresh integer ID.
Crafting Components
Because components are just data, we can initially represent them as simple types:
type health = float
type position = { x : float; y : float; z : float }
type model = string
However, to store them in a single data structure, we need a flexible type that can hold many different component values. A naive approach is to define a variant:
type component =
| Health of float
| Position of { x : float; y : float; z : float }
| Model of string
The problem is that this limits us to the variants known at compile time. What if the user wants to add a custom component? To address this, we can use OCaml’s extensible variant types:
type component = ..
type component += Health of float
type component += Position of { x : float; y : float; z : float }
type component += Model of string
Extensible variants allow us to extend component
anywhere else in the code without modifying the original type. For instance, the user could define their own component:
type component += MyCustom of int
This is handy for flexibility. If we store components in a list, we can iterate and match on them:
let v = [ Health 1.0; Position { x = 0.; y = 0.; z = 0. }; Model "ball" ]
let print_components (components : component list) =
List.iter (function
| Health h -> Printf.printf "Health: %f\n" h
| Position { x; y; z } -> Printf.printf "Position: (%f, %f, %f)\n" x y z
| Model m -> Printf.printf "Model: %s\n" m
| _ -> Printf.printf "Unknown component\n"
) components
Identifying Components by Type
Often, it’s useful to know not just which variant a component belongs to, but also a unique identifier for the component type. For example, we might want to fetch all entities that have the Health
component. To achieve that, we can create a functor that assigns a unique ID to each component type:
type component = ..
module type S = sig
type t
val id : Id.Component.t
end
module Make (B : sig
type inner
end) : S with type t = B.inner = struct
include B
type t = inner
type component += T of t
let id = Id.Component.next () (* Unique ID for each component type *)
end
Using this mechanism, we can define a Health
component like so:
module Health = struct
type t = float
module C = Component.Make (struct
type inner = t
end)
end
Now Health.C.id
is a unique identifier. However, when we store these components in a data structure, we need a way to retrieve both the component value and its unique identifier. A neat approach is to define a Generalized Algebraic Data Type (GADT) to “pack” the component’s module and the data together:
type packed = Packed : (module S with type t = 'a) * 'a -> packed
In this constructor:
(module S with type t = 'a)
holds the module reference (which includes the unique ID),'a
is the actual component value.
We can then store a list of these:
let v = [
Packed ((module Health.C), 42.);
Packed ((module Position.C), { x = 0.; y = 0.; z = 0. })
]
Because each “packed” component includes its module, we can define helper functions to unpack or look up the component’s identifier. This lets us store multiple component types in a single structure, while still tracking each type’s identity.
So far, we’ve achieved three main things:
- Extensibility: Users can add their own components.
- Single Data Structure: Components can be stored together via type erasure.
- Identifiable Types: Each component type has a unique ID.
Structuring Systems
Different ECS designs have different ways of organizing systems. I took inspiration from the Bevy game engine for Rust, which allows a system to define precisely which components it needs:
fn rotate(mut query: Query<&mut Transform, With<Shape>>) {
for mut transform in &mut query {
// rotate the shape
}
}
fn more_complex_system(mut query: Query<(&mut Transform, &Health), With<Enemy>>) {
for (mut transform, health) in &mut query {
// do something more complex
}
}
Bevy’s approach is elegant in that it lets you specify component requirements as function parameters, and it automatically handles iteration and filtering of entities. I wanted something similar in OCaml, so I experimented with two approaches.
The Initial Approach (Lists of Packed Components)
Originally, I stored components as a list of “packed” values per entity. My query system manually unpacked components and matched them to the right type. Here’s a simplified version:
let control =
Ecs.System.Query
(function
| [|
transforms; (* Query 1 results *)
[ (entity_id, [ keyboard ]) ] (* Query 2 results *);
|] ->
let transforms =
transforms
|> List.map (function
| entity_id, [ t ] -> t |> Ecs.Component.unpack (module Transform.C)
| _ -> assert false)
in
let keyboard = keyboard |> Ecs.Component.unpack (module Input.Keyboard.C) in
failwith "TODO: handle transforms and keyboard"
| _ -> assert false)
let plugin w =
Ecs.World.add_system w Ecs.Scheduler.Update
[|
Ecs.Query.create ~filter:(Ecs.Query.Filter.With Ball.C.id)
[ Ecs.Query.Required Transform.C.id ];
Ecs.Query.create [ Ecs.Query.Required Input.Keyboard.C.id ];
|]
move_ball
While it does the job, this approach is not very type-safe or ergonomic. We rely on runtime checks (assert false
) to detect type mismatches, and we repeat component references in both the system definition and the query.
A GADT-Based Query
To improve type safety and reduce verbosity, I introduced a GADT that encodes the query result type:
module Query = struct
type _ term =
| Req : (module Component.S with type t = 'a) -> 'a term
| Opt : (module Component.S with type t = 'a) -> 'a option term
type _ t =
| Nil : unit t
| Cons : 'a term * 'b t -> ('a * 'b) t
end
Every time you add a Req
or Opt
term to the query, the type changes to reflect the new components you’re requesting. For example:
let query =
Query.(
Cons (Req (module Transform.C), Cons (Opt (module Input.Keyboard.C), Nil)))
(* val query : (Transform.t * (Input.Keyboard.t option * unit)) Query.t *)
When we evaluate such a query, we get a well-typed result:
let control =
let query w =
let result =
World.query w Query.(Req (module Transform.C) @ Opt (module Ball.C) @ Nil)
|> List.map (fun (_entity_id, (t, (b, ()))) -> (t, b))
in
let _entity_id, (keyboard, ()) =
World.query w Query.(Req (module Input.Keyboard.C) @ Nil) |> List.hd
in
(result, keyboard)
in
let logic (result, keyboard) =
failwith "TODO: do something with transforms and keyboard"
in
System.make query (System.Query logic)
Because of the GADT’s encoding, the system sees typed pairs of components with no need for unsafe unpacking or repeated assert false
. The downside is that we end up with nested tuples like (t, (b, ()))
, and we must remember to terminate the query with Nil
. I’m still experimenting with ways to flatten these tuples or automatically unpack them. A small helper function or syntax extension could make them more ergonomic.
Summary
So far, I’ve presented how camlcade’s ECS identifies entities, defines components, and structures queries/systems. Some ECS topics I haven’t discussed in detail (but plan to in the future) include archetypal storage, event channels, and system scheduling. If you’re interested, feel free to explore the source code on GitHub.