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 a float representing health points, or a velocity 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:

  1. Extensibility: Users can add their own components.
  2. Single Data Structure: Components can be stored together via type erasure.
  3. 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.