Simulation
github.com/mechanical-lich/mlge/simulation
The simulation package provides the server-side (authoritative) game loop for mlge. In a Quake-style architecture the server owns the canonical world state and runs physics, AI, and all game logic at a fixed tick rate, independent of the client frame rate. The client only renders.
This package has zero Ebitengine dependencies. If you see github.com/hajimehoshi/ebiten imported by simulation code, that is a bug.
SimulationSystem
type SimulationSystem interface {
UpdateSimulation(world any) error
UpdateEntitySimulation(world any, entity *ecs.Entity) error
Requires() []ecs.ComponentType
}
The server-side counterpart to ecs.SystemInterface. Implement this for authoritative logic: physics, AI, chemistry, pathfinding, collision resolution. Never put rendering code here.
| Method | Description |
|---|---|
UpdateSimulation | Runs once per server tick for global logic (clocks, spatial indices). |
UpdateEntitySimulation | Runs once per entity per tick, for entities matching Requires(). |
Requires | Component types an entity must have. Return nil to receive every entity. |
SimulationSystemManager
type SimulationSystemManager struct{}
Holds an ordered list of SimulationSystems and drives them each server tick. Mirrors ecs.SystemManager but is typed to prevent accidentally adding render systems.
| Method | Signature | Description |
|---|---|---|
AddSystem | (s SimulationSystem) | Append a system to execution order |
UpdateSystems | (world any) error | Call UpdateSimulation on all systems |
UpdateSystemsForEntities | (world any, entities []*ecs.Entity) error | Call UpdateEntitySimulation per entity per system |
Entities with ecs.InanimateComponentType are automatically skipped, matching ecs.SystemManager behavior.
SimulationState
type SimulationState interface {
Tick(world any) SimulationState
ProcessCommand(cmd *transport.Command)
Done() bool
}
The server-side equivalent of state.StateInterface. No Draw() method. Operates inside the simulation goroutine, never touching Ebitengine APIs.
| Method | Description |
|---|---|
Tick | Advances one server tick. Return non-nil to push a new state. |
ProcessCommand | Handles one client command. Called before Tick, in arrival order. |
Done | Returns true when this state should be popped. |
SimulationStateMachine
type SimulationStateMachine struct{}
Stack-based state machine for the server. Mirrors state.StateMachine but has no Draw.
| Method | Signature | Description |
|---|---|---|
PushState | (s SimulationState) | Push a new state onto the stack |
Current | () SimulationState | Return the active state, or nil if empty |
Tick | (world any) bool | Advance current state; returns false when stack is empty |
ProcessCommands | (cmds []*transport.Command) | Route all pending commands to the current state |
ServerConfig
type ServerConfig struct {
TickRate int // ticks per second (default: 20)
SnapshotEvery int // send snapshot every N ticks (default: 1)
}
Server
type Server struct{}
Runs the authoritative simulation loop in a dedicated goroutine. Owns the game world, drives SimulationSystems at a fixed tick rate, processes client commands, and sends snapshots via the transport.
Constructor
func NewServer(
config ServerConfig,
world any,
entitySource EntitySource,
t transport.ServerTransport,
codec transport.SnapshotCodec,
) *Server
| Parameter | Description |
|---|---|
config | Tick rate and snapshot frequency |
world | The authoritative game world (passed to systems as world any) |
entitySource | Function returning the current entity slice (avoids stale pointers) |
t | Server side of a transport |
codec | Game-provided snapshot encoder |
Methods
| Method | Signature | Description |
|---|---|---|
AddSystem | (sys SimulationSystem) | Register a system. Call before Run or Step. |
SetState | (state SimulationState) | Set the initial state. Call before Run or Step. |
Run | () | Start the loop. Blocks until Stop() or state machine empties. |
Step | () bool | Advance exactly one tick. Returns false when the state machine is empty. |
Stop | () | Signal the loop to exit cleanly. |
Tick | () uint64 | Current tick counter (read-only). |
Driving Modes
The Server supports two modes of operation:
Independent (decoupled) – call Run() in a goroutine. The server ticks at its own fixed rate, independent of the client frame rate. Best for networked or multi-threaded games.
go srv.Run()
Linked (lockstep) – call Step() from the caller’s loop, typically inside Ebitengine’s Update(). The simulation ticks once per frame, locked to the engine’s TPS. Simpler architecture, no goroutines, no concurrency concerns.
func (g *Game) Update() error {
if !g.srv.Step() {
return ebiten.Termination
}
// receive snapshot, render, etc.
return nil
}
Tick Loop
Each server tick the Server performs these steps in order:
- Drain pending commands from the transport
- Route commands to the current
SimulationState - Run
SimulationSystemManager.UpdateSystems(global pass) - Run
SimulationSystemManager.UpdateSystemsForEntities(per-entity pass) - Advance the
SimulationStateMachine - If this tick is a snapshot tick, encode and send a
Snapshot
Usage
Independent mode (decoupled tick rate)
srvT, cliT := transport.NewLocalTransport()
srv := simulation.NewServer(
simulation.ServerConfig{TickRate: 20},
myWorld,
func() []*ecs.Entity { return myWorld.Entities },
srvT,
myCodec,
)
srv.AddSystem(&PhysicsSystem{})
srv.AddSystem(&AISystem{})
srv.SetState(&MainSimState{})
go srv.Run()
// Client runs in the main goroutine via Ebitengine
ebiten.RunGame(client.NewClient(cliT, myCodec, myState, myWorld, entityFunc, cfg))
See examples/client_server for a full runnable example.
Linked mode (lockstep with Ebitengine)
srvT, cliT := transport.NewLocalTransport()
srv := simulation.NewServer(
simulation.ServerConfig{TickRate: 60, SnapshotEvery: 1},
srvWorld,
func() []*ecs.Entity { return srvWorld.Entities },
srvT,
myCodec,
)
srv.AddSystem(&PhysicsSystem{DT: 1.0 / 60.0})
srv.SetState(&MainSimState{})
// No goroutine -- Step() is called from Update().
type Game struct {
srv *simulation.Server
cliT transport.ClientTransport
codec transport.SnapshotCodec
world *World
tick uint64
}
func (g *Game) Update() error {
if !g.srv.Step() {
return ebiten.Termination
}
snap := g.cliT.ReceiveSnapshot()
if snap != nil {
g.codec.Decode(snap, g.world)
g.tick = snap.Tick
}
return nil
}
See examples/linked for a full runnable example.