Creating Engine Systems
This skill guides creation of ECS systems that process entities with specific components.
System Architecture
Systems query for entities with required components and update them each frame. They implement the System interface and optionally SystemWithRender for custom rendering.
System Interface
go
type System interface {
Update(world donburi.World, scene *scene.Scene, dt float32)
Mode() SystemMode
Name() string
}
// Optional interfaces:
type SystemWithInit interface {
System
Init(world donburi.World)
}
type SystemWithRender interface {
System
Render(phase RenderPhase, world donburi.World, scene *scene.Scene)
}
type SystemWithCleanup interface {
System
Cleanup(world donburi.World)
}
System Modes
| Mode | When it runs |
|---|---|
SystemModeEdit | Editor edit mode only |
SystemModePlay | Editor play mode only |
SystemModeGame | Standalone game only |
SystemModeAlways | All modes |
SystemModeRuntime | Play + Game (most common) |
Basic System Template
Location: game/system/<name>.go
go
package system
import (
gameComponent "game/component"
engineComponent "github.com/TheLazyLemur/engine/component"
engineScene "github.com/TheLazyLemur/engine/scene"
sys "github.com/TheLazyLemur/engine/system"
"github.com/yohamta/donburi"
"github.com/yohamta/donburi/filter"
)
type <Name>System struct{}
func (s *<Name>System) Update(world donburi.World, scene *engineScene.Scene, dt float32) {
query := donburi.NewQuery(
filter.Contains(
engineComponent.Transform,
gameComponent.<ComponentName>,
),
)
for entry := range query.Iter(world) {
transform := engineComponent.Transform.Get(entry)
myComp := gameComponent.<ComponentName>.Get(entry)
// Your logic here...
// CRITICAL: Mark dirty after modifying transform
transform.MarkDirty()
}
}
func (s *<Name>System) Mode() sys.SystemMode {
return sys.SystemModeRuntime
}
func (s *<Name>System) Name() string {
return "<Name>"
}
Register the System
In game/systems.go:
go
func RegisterSystems(e *engine.Engine) {
// ... existing systems ...
e.RegisterSystem(&gameSystem.<Name>System{})
}
Common Patterns
Input Handling System
go
func (s *PlayerSystem) Update(world donburi.World, scene *engineScene.Scene, dt float32) {
query := donburi.NewQuery(filter.Contains(
engineComponent.Transform,
gameComponent.Player,
))
for entry := range query.Iter(world) {
transform := engineComponent.Transform.Get(entry)
player := gameComponent.Player.Get(entry)
moveDir := math.Vector3Zero()
if rl.IsKeyDown(rl.KeyW) {
moveDir.Z -= 1
}
if rl.IsKeyDown(rl.KeyS) {
moveDir.Z += 1
}
if rl.IsKeyDown(rl.KeyA) {
moveDir.X -= 1
}
if rl.IsKeyDown(rl.KeyD) {
moveDir.X += 1
}
if moveDir.LengthSquared() > 0 {
moveDir = moveDir.Normalize()
transform.Position = transform.Position.Add(
moveDir.Scale(player.MoveSpeed * dt),
)
transform.MarkDirty()
}
}
}
System with Initialization
go
type SpawnerSystem struct {
spawnTimer float32
}
func (s *SpawnerSystem) Init(world donburi.World) {
s.spawnTimer = 0
}
func (s *SpawnerSystem) Update(world donburi.World, scene *engineScene.Scene, dt float32) {
s.spawnTimer += dt
if s.spawnTimer >= 2.0 {
s.spawnTimer = 0
// Spawn logic...
}
}
System with Gizmo Rendering
go
func (s *DebugSystem) Render(phase sys.RenderPhase, world donburi.World, scene *engineScene.Scene) {
if phase != sys.RenderPhaseGizmos {
return
}
query := donburi.NewQuery(filter.Contains(
engineComponent.Transform,
gameComponent.Waypoint,
))
for entry := range query.Iter(world) {
transform := engineComponent.Transform.Get(entry)
pos := transform.WorldMatrix.ExtractPosition()
// Draw debug sphere at waypoint
rl.DrawSphereWires(pos.ToRaylib(), 0.5, 8, 8, rl.Green)
}
}
Entity Creation in System
go
func (s *BulletSpawnerSystem) Update(world donburi.World, scene *engineScene.Scene, dt float32) {
if rl.IsKeyPressed(rl.KeySpace) {
// Use scene builder API
bulletId := scene.Spawn("Bullet").
At(startPos).
Scale(math.NewVector3(0.1, 0.1, 0.5)).
Mesh("cube", "yellow").
With(gameComponent.Bullet, gameComponent.BulletData{
Speed: 50.0,
Direction: forward,
Lifetime: 3.0,
}).
Build()
}
}
Collision Response System
go
func (s *DamageSystem) Update(world donburi.World, scene *engineScene.Scene, dt float32) {
query := donburi.NewQuery(filter.Contains(
engineComponent.Transform,
engineComponent.Colliding, // Has collision data
gameComponent.Health,
))
for entry := range query.Iter(world) {
colliding := engineComponent.Colliding.Get(entry)
health := gameComponent.Health.Get(entry)
for _, contact := range colliding.Contacts {
if contact.State == component.CollisionStateEnter {
otherEntry := world.Entry(donburi.Entity(contact.OtherEntity))
if otherEntry.HasComponent(gameComponent.Damage) {
dmg := gameComponent.Damage.Get(otherEntry)
health.Current -= dmg.Amount
}
}
}
}
}
Query Patterns
Multiple Required Components
go
query := donburi.NewQuery(filter.Contains(
engineComponent.Transform,
gameComponent.Movement,
gameComponent.Health,
))
Optional Components
go
for entry := range query.Iter(world) {
// Required
transform := engineComponent.Transform.Get(entry)
// Optional check
if entry.HasComponent(gameComponent.Boost) {
boost := gameComponent.Boost.Get(entry)
// Apply boost...
}
}
Exclude Filter
go
query := donburi.NewQuery(filter.And(
filter.Contains(engineComponent.Transform),
filter.Not(filter.Contains(gameComponent.Dead)),
))
Update Order
Engine runs systems in this order:
- •Your registered systems (in registration order)
- •CollisionSystem (detects collisions, creates Colliding components)
- •TransformSystem (recomputes world matrices from dirty flags)
Important Rules
- •Mark Dirty: Call
transform.MarkDirty()after modifying position/rotation/scale - •Query Once: Create query at start of Update, iterate once
- •System Order: Systems that create entities should register before systems that query them
- •Collision Timing: Colliding component data reflects LAST frame's collisions
- •Render Phases: Check phase in Render() before drawing
- •Donburi Entity Creation:
world.Create()requires at least one component type argumentgo// WRONG - panics: "entity must have at least one component" entity := world.Create() // CORRECT entity := world.Create(engineComponent.Name) entry := world.Entry(entity) entry.AddComponent(MyComponent)
Render Phases
| Phase | Use Case | Inside 3D? |
|---|---|---|
RenderPhaseShadow | Custom shadow casters | Yes |
RenderPhaseGeometry | Custom 3D rendering | Yes |
RenderPhaseGizmos | Debug visualization (lines, spheres) | Yes |
RenderPhaseOverlay | 2D UI, selection boxes, HUD | No (screen space) |
RenderPhaseEditorUI | ImGui panels (editor only) | No (ImGui) |
Gizmos vs Overlay Phase
go
func (s *MySystem) Render(phase sys.RenderPhase, world donburi.World, scene *engineScene.Scene) {
// 3D debug visualization - inside 3D camera view
if phase == sys.RenderPhaseGizmos {
query := donburi.NewQuery(filter.Contains(
engineComponent.Transform,
gameComponent.MyComponent,
))
for entry := range query.Iter(world) {
transform := engineComponent.Transform.Get(entry)
pos := transform.WorldMatrix.ExtractPosition()
rl.DrawSphereWires(pos.ToRaylib(), 1.0, 8, 8, rl.Green)
}
}
// 2D overlay - draws to viewport texture (not screen)
if phase == sys.RenderPhaseOverlay {
// IMPORTANT: Overlay draws to render texture coordinates (1920x1080)
// In editor mode, viewport may be offset by panels
// Use SetViewportRectCallback to get viewport bounds
}
}
Viewport-Aware 2D Rendering (Overlay Phase)
In editor mode, the viewport is offset by panels. To draw correctly:
- •Engine callback approach (recommended for games):
go
// In main.go
selSys := &SelectionSystem{}
eng.RegisterSystem(selSys)
eng.SetViewportRectCallback(selSys.SetViewportRect)
// In SelectionSystem
type SelectionSystem struct {
viewportX, viewportY, viewportWidth, viewportHeight float32
}
func (s *SelectionSystem) SetViewportRect(x, y, width, height float32) {
s.viewportX = x
s.viewportY = y
s.viewportWidth = width
s.viewportHeight = height
}
func (s *SelectionSystem) Render(phase sys.RenderPhase, ...) {
if phase == sys.RenderPhaseOverlay && box.IsActive {
// Convert screen coordinates to render texture coordinates
startX := (float32(box.StartX) - s.viewportX) * (1920.0 / s.viewportWidth)
startY := (float32(box.StartY) - s.viewportY) * (1080.0 / s.viewportHeight)
// ... draw at startX, startY
}
}
- •Alternative: game mode only - Run with
--mode=gamewhere viewport fills screen
Example: Complete Rotation System
See existing implementation at:
- •System:
game/system/rotation.go - •Registration:
game/systems.go