I've been building my game engine in Nim for quite a while and it's coming along well. Last night it occurred to me that I could replace the component system using Nim concepts instead.
I don't have a specific question, I'm just curious what you guys think of this as I've almost never used concepts before. Is this a terrible idea?
The behaviour is defined in distinct modules:
movement.nim
type
Positionable = concept p
p.position is Vec3
p.direction is Vec3
Movable = concept m, var mv
m is Positionable
m.velocity is Vec3
IsGrounded = concept g
g.heightOffTheFloor is float32
template implementsPositionable* {.dirty.} =
position: Vec3
direction: Vec3
template implementsMovable* {.dirty.} =
position: Vec3
direction: Vec3
velocity: Vec3
template implementsGrounded* {.dirty.} =
heightOffTheFloor: float32
proc applyInputs[T: Movable](m: var T, vel: Vec3) =
m.velocity = vel
proc applyMovement[M: Movable](m: var M, dt: float) =
m.position += m.velocity * dt
proc applyGrounding*[T: Positionable and IsGrounded](entity: var T, bvh: Node) =
entity.position = bvh.getHeight(entity.position) + [0'f32, entity.heightOffTheFloor, 0].Vec3
And then I have an entity macro that allows me to create mixins of sorts:
import std/macros
import ../navigation/movement
import ../animation/state_machine
{.experimental: "dynamicBindSym".}
macro entity*(name: untyped, body: untyped): untyped =
let fields = newNimNode(nnkRecList)
for item in body:
case item.kind
of nnkIdent, nnkSym: # Behaviour name (implementsMovable)
let sym = bindSym(item.strVal)
let impl = sym.getImpl()
let templateBody = impl[^1]
for field in templateBody:
if field.kind == nnkCall:
fields.add newIdentDefs(postfix(field[0], "*"), field[1][0])
of nnkCall: # field: Type
fields.add newIdentDefs(postfix(item[0], "*"), item[1][0])
else:
error "Unexpected node in entity body: " & $item.kind, item
let objTy = newNimNode(nnkObjectTy)
objTy.add newEmptyNode()
objTy.add newEmptyNode()
objTy.add fields
let typeDef = newNimNode(nnkTypeDef)
typeDef.add postfix(name, "*")
typeDef.add newEmptyNode()
typeDef.add objTy
let typeSection = newNimNode(nnkTypeSection)
typeSection.add typeDef
result = typeSection
# echo result.repr
And the implementation of the concepts can be defined just like a type with the templates from the relevant behaviour modules:
entity Character:
implementsMovable
implementsGrounded
someOtherField: int Right now I keep my objects in different buckets based on what sets of concepts they implement so I haven't had to check them dynamically.
I'm not sure what you mean by excluding, if they musn't have some components then their entity won't implement those concepts which have these components. If you mean them not being subject to certain behaviours despite having components that enable those behaviours, I imagine I'd use a tag of some sort to distinguish acceptable behaviour. Right now I have a Pathable concept to which multiple pathing algorithms can be applied, in that case I added a PathableKind enum that selects which pathing algorithm should be applied.
Character is a god-entity for this minimal example only, I have a lot of other concepts which it doesn't implement but are implemented by other entities in the actual code but I do keep track of which entities can do what using the buckets in my main game loop.
The "when compiles" approach seems interesting, I've been a bit worried that concepts will be hard to debug if one of my implementers doesn't match the concept it's supposed to match. How do you use it? Something like:
proc applyMovement*[M](m: var M, dt: float) =
when compiles(m.position) and compiles(m.velocity):
m.position = m.position + m.velocity * dt.float32
else:
{.error: "M must be have position and velocty".}? what you mean by excluding
For example, you have MoveComponent and all works as should, but then game designer says "we need frozen debuff and no move on it". How you will do it? You can make refactoring and split move to standard MoveComponent + MoveFrozenComponent, but... its too complex. Much simpler add FrozenComponent and filter entities like "give me all entities with MoveComponent but without FrozenComponent" and keep old movement code as is. Its common thing on real projects with ecs, almost all ecs frameworks supports it.
The "when compiles" approach seems interesting, I've been a bit worried that concepts will be hard to debug if one of my implementers doesn't match the concept it's supposed to match. How do you use it?
Im using it in golang like interfaces, not in ecs filtering:
proc addScene(s: var SceneState, name: string, T: typedesc) =
znakAssert not s.items.hasKey(name),
"[scene] add '" & name &
"', name already exists"
var scene = create(SceneData)
scene.data = create(T)
scene.onLoad = proc(scn: pointer) =
when compiles((var t: T; t.onLoad())):
cast[ptr T](scn)[].onLoad()
scene.onUpdate = proc(scn: pointer) =
when compiles((var t: T; t.onUpdate())):
cast[ptr T](scn)[].onUpdate()
scene.onRender = proc(scn: pointer) =
when compiles((var t: T; t.onRender())):
cast[ptr T](scn)[].onRender()
scene.onUnload = proc(scn: pointer) =
when compiles((var t: T; t.onUnload())):
cast[ptr T](scn)[].onUnload()
cast[ptr T](scn)[] = T.default
scene.onResize = proc(scn: pointer) =
when compiles((var t: T; t.onResize())):
cast[ptr T](scn)[].onResize()
scene.onFocus = proc(scn: pointer) =
when compiles((var t: T; t.onFocus())):
cast[ptr T](scn)[].onFocus()
if s.items.len == 0:
s.first = name
s.items[name] = scene
znakDebugLog "[scene] add '" & name & "', type " & $T
And scene code with registration:
# scenes/main/main.nim
type MainScene* = object
# All methods are optional.
proc onLoad*(scene: var MainScene) =
discard
proc onUpdate*(scene: var MainScene) =
discard
proc onUnload*(scene: var MainScene) =
discard
proc onRender*(scene: var MainScene) =
discard
proc onResize*(scene: var MainScene) =
discard
proc onFocus*(scene: var MainScene) =
discard
# at startup
import scenes/main/main
ZnakInit.addScene("main", MainScene)
Right now I keep my objects in different buckets based on what sets of concepts they implement so I haven't had to check them dynamically.
So, when you add component that not in this bucket, but in another - your entity should be migrated to proper bucket? Archetype-way then, I see. I use another way - pools + bitmasks on filters, it very flexible and allows to add/remove components (make structural changes) without issues. Code looks like this:
import proton
type Vector3* = array[3, float32]
type Position* = object
value*: Vector3
type Velocity* = object
value*: Vector3
proc initSystem(w: ProtonWorld): proc() =
# request components in world.
let posPool = w.definePool(Position)
let velPool = w.definePool(Velocity)
proc() =
for i in 0 ..< 5_000:
# pos is "ptr Position" here.
let (pos, e) = posPool.newEntity()
pos.value = [i.float32, i.float32, i.float32]
# vel is "ptr Velocity" here.
let vel = velPool.add(e)
vel.value = [-1f, -1, -1]
proc runSystem(w: ProtonWorld): proc() =
# request components in world.
let posPool = w.definePool(Position)
let velPool = w.definePool(Velocity)
# request iterator for entities with Position+Velocity.
let it = w.defineIt([Position, Velocity])
proc() =
for entity in it:
let pos = posPool.get(entity)
let vel = velPool.get(entity)
pos.value[0] += vel.value[0]
pos.value[1] += vel.value[1]
pos.value[2] += vel.value[2]
let world = newProtonWorld()
world
.addSystem("initGroup", initSystem)
.addSystem("runGroup", runSystem)
.init
# run init systems.
world.run("initGroup")
# run update systems in loop.
world.run("runGroup")
# cleanup.
world.destroy Use new-styled concepts and avoid {.experimental: "dynamicBindSym".} as I currently don't know how to ever support that in Nimony.
As for the idea itself... I would avoid unless it adds type-safety and doesn't measurably affect build times.
IMO, this is over-kill.
It's simpler to use a "fat object" aka ("fat struct" in the gamedev world) that is essentially a struct that can represent all possible entity types in your game. The simplest model is the best model IMO (https://en.wikipedia.org/wiki/Occam%27s_razor)
If you want to partition your entities in different types (in the type-system), then I highly recommend reading this article: https://blog.voxagon.se/2025/03/28/thoughts-on-ecs.html - it is an obvious (hind-sight) way to do have an ECS without advanced language features (quoting the article: "If your game does not require dynamic composition at run-time")
For the fat-struct approach, this is basically it in Nim:
type
EntityKind = enum
ekPlayer
ekMonster
ekBullet
ekStaticObject
Entity = object
# shared fields
pos, vel, accel: Vec3
# every other field you need
# if you want to add type-safety, use a case to store specific fields
case kind: EntityKind
of ekBullet:
isLightSpeed: bool
of ekPlayer:
# player specific fields
# ...
else:
discard
Now, to implement a "movement system", it's apart of your update step:
proc update(w: var World, dt: float) =
# move all entities
for e in w.entities:
# filter with an if
if e.kind == ekStaticObject:
continue
e.pos += e.vel * dt
e.vel += e.accel * dt
Assuming the World type stores all of your entities.
For performance, you can:
For simplicity:
For how you store entities in World, depending on your situation, you can:
I'd do (2) if you remove entities from your game frequently. You can write some macros/templates to help you with the syntax if you really care about that.
If there are performance problems you can:
Of game dev I know noting. What's posted to me looks like something for state machines.
A different way I followed in a ray-tracer, there is a shapeSpec:
ShapeSpec* = object
shapeKind*: string
params*: seq[float32]
localBbox*: BBox[Vec]
bbox*: BBox[Vec]
materialId*: int
flags*: ShapeFlags
transform*: Mat4
invTransform*: Mat4
intersectFn*: proc(params: seq[float32], ray: Ray): seq[Intersection]
sdfFn*: proc(params: seq[float32], p: Vec3): float32
there is no hirarchy of shapes nor a big case select. The raytracer does not care about "hokjesgeest". the word sphere is there just for human use. The raytracer just cares for an intersection procedure:
import vmath
import shapetype
const
ShapeKindSphere = "sphere"
SX = 0
SY = 1
SZ = 2
SR = 3
proc bboxSphere*(params: seq[float32]): BBox[Vec] =
let c = vec3(params[SX], params[SY], params[SZ])
let r = params[SR]
BBox[Vec](min: vec3(c.x-r, c.y-r, c.z-r), max: vec3(c.x+r, c.y+r, c.z+r))
proc intersectSphere*(params: seq[float32], ray: Ray): seq[Intersection] =
let center = vec3(params[SX], params[SY], params[SZ])
let radius = params[SR]
let oc = ray.origin - center
let a = dot(ray.direction, ray.direction)
let b = 2.0f * dot(oc, ray.direction)
let c = dot(oc, oc) - radius * radius
let discriminant = b * b - 4.0f * a * c
if discriminant < 0f: return @[]
let sqrtD = sqrt(discriminant)
let t1 = (-b - sqrtD) / (2.0f * a)
let t2 = (-b + sqrtD) / (2.0f * a)
proc makeIntersection(t: float32): Intersection =
let point = ray.origin + ray.direction * t
let outwardN = (point - center) / radius
let inside = dot(ray.direction, outwardN) > 0f
let normal = if inside: -outwardN else: outwardN
let theta = arccos(-outwardN.y)
let phi = arctan2(-outwardN.z, outwardN.x) + PI
Intersection(
t: t,
point: point,
normal: normal,
uv: vec2(phi / (2f * PI), theta / PI),
inside: inside
)
if discriminant < 1e-6f:
return @[makeIntersection(t1)]
return @[makeIntersection(t1), makeIntersection(t2)]
proc sdfSphere*(params: seq[float32], p: Vec3): float32 =
let center = vec3(params[SX], params[SY], params[SZ])
let radius = params[SR]
length(p - center) - radius
proc sphere*(center: Vec3, radius: float32, matId: int = 0): ShapeSpec =
let params = @[center.x, center.y, center.z, radius]
let lb = bboxSphere(params)
let id = mat4()
ShapeSpec(
shapeKind: ShapeKindSphere,
params: params,
localBbox: lb,
bbox: lb, # identity → local == world
materialId: matId,
transform: id,
invTransform: id,
intersectFn: intersectSphere,
sdfFn: sdfSphere
)
#TODO tesselate to mesh (a transform from shapespec to shapespec).
The same way of doing things for lights, cameras etc. All is then added to sequences that form a scene:
type
Scene* = object
shapes*: seq[ShapeSpec]
# SQLite: missed rays → background_samples table
lights*: seq[LightSpec]
# SQLite: camera produces rays → rays table (origin, direction, pixel x/y)
cameras*: seq[CameraSpec]
# SQLite: material results → pixel_colours table
materials*: seq[MaterialSpec]
background*: Vec3
proc initScene*(): Scene =
Scene(
shapes: @[],
lights: @[],
cameras: @[],
materials: @[]
)
proc addShape*(scene: var Scene, spec: ShapeSpec): int =
assert spec.materialId >= 0 and spec.materialId < scene.materials.len,
"addShape: invalid materialId " & $spec.materialId &
" (materials count: " & $scene.materials.len & ")"
result = scene.shapes.len
scene.shapes.add(spec)
#etc...
State machines can be done in a similar way I think. Don't know it works for games