ecs

package
v0.0.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 26, 2025 License: BSD-2-Clause Imports: 10 Imported by: 0

README

ECS

ECS is a Entity Component System implementation in Go, designed for usability and performance.

Features

  • Modern API designed around modern Go features like generics, iterators and weak-refs
  • Good Performance for the vast majority of use cases (see cmd/ecs-stress)
  • Simple to use and understand, no code generation means you can easily understand the source

Example

package main

import (
	"fmt"
	"reflect"

	"github.com/plus3/ooftn/ecs"
)

// Components can be a combination of named types and structs
type Gravity float32

type Name string

type Position struct {
	X, Y float32
}

type Velocity struct {
	DX, DY float32
}

type Health struct {
	Current int
	Max     int
}

// Systems encapsulate behavior and have easy access to querying components
type GravitySystem struct {
	Gravitating ecs.Query[struct {
		ecs.EntityId
		*Position
		*Velocity
	}]
	Gravity ecs.Singleton[Gravity]
}

func (g *GravitySystem) Execute(frame *ecs.UpdateFrame) {
	// Singletons are guarenteed to exist so we can just fetch it
	gravity := *g.Gravity.Get()

	// Queries are a nice wrapper over views for systems
	for entity := range g.Gravitating.Iter() {
		entity.Position.Y -= float32(gravity)

		if entity.Position.Y < 0 {
			// We can use the command buffer to defer mutations until the end of the frame
			frame.Commands.Delete(entity.EntityId)
		}
	}
}

func main() {
	// We register all component types, this helps a lot with the internal performance of the library
	registry := ecs.NewComponentRegistry()
	ecs.RegisterComponent[Position](registry)
	ecs.RegisterComponent[Velocity](registry)
	ecs.RegisterComponent[Health](registry)
	ecs.RegisterComponent[Name](registry)
	ecs.RegisterComponent[float64](registry)

	// The storage is where our component data actually lives
	storage := ecs.NewStorage(registry)

	// Finally we can directly spawn an entity using components
	playerId := storage.Spawn(
		Position{X: 10, Y: 20},
		Velocity{DX: 1, DY: 0},
		Name("joe"),
	)
	storage.Spawn(Position{X: 100, Y: 100}, Name("rock"))

	// Reading data via an EntityId is **very** fast using the generics API
	position := ecs.ReadComponent[Position](storage, playerId)

	// We can also read data using reflect.Type's
	velocity := storage.GetComponent(playerId, reflect.TypeFor[Velocity]()).(*Velocity)

	// Components we read are pointers to the underlying data storage, meaning we can easily mutate fields
	position.X += velocity.DX
	position.Y += velocity.DY

	// An EntityId provides extremely fast access to data, but it is not guarenteed to remain stable.
	// Lets mutate the underlying "archetype" of the entity by adjusting which components it has, this
	// will cause the EntityId to change.
	playerId = storage.AddComponent(playerId, Health{Current: 5, Max: 10})

	// Since we can't trust EntityId's to remain stable, we instead use EntityRef's to keep track of
	// entities and reference them across components.
	playerRef := storage.CreateEntityRef(playerId)

	// Add a nonsense component to force the underlying archetype and entityid to change
	newPlayerId := storage.AddComponent(playerId, float64(32))

	// PlayerRef remains updated
	fmt.Printf("%d == %d (old: %d)\n", playerRef.Id, newPlayerId, playerId)

	// When dealing with groups of components, View's give us cachable performance benefits and a
	// clean generics-based API.
	named := ecs.NewView[struct {
		*Position
		*Name

		// Views can select "optional" fields
		Health *Health `ecs:"optional"`
	}](storage)

	// Views can be used to read and mutate individual entity data easily
	playerData := named.Get(newPlayerId)
	playerData.Position.X += 1
	playerData.Position.Y += 1

	// We can also easily use references
	playerData = named.GetRef(playerRef)

	// Views can also be used to "query" multiple entities
	for entity := range named.Iter() {
		fmt.Printf("Entity Name: %s\n", *entity.Name)

		// Optional fields must be nil-checked before use
		if entity.Health != nil {
			fmt.Printf("  health: %d\n", entity.Health.Current)
		}
	}

	// Singletons allow us to store global-state components easily
	ecs.NewSingleton[Gravity](storage, Gravity(8.0))

	// We can read singletons directly from storage
	var gravity *Gravity
	if storage.ReadSingleton(&gravity) {
		fmt.Printf("Gravity is %f\n", *gravity)
	}

	// Finally we can use a scheduler to execute systems
	scheduler := ecs.NewScheduler(storage)
	scheduler.Register(&GravitySystem{})
	scheduler.Once(1)
}

Safety

The following are some tips to avoid unsafe behavior with this library:

  • When storing references to an entity that might be used at a later point, always create and store a EntityRef instead of an EntityId. An EntityId is fast but can be invalidated by adding components, removing components, or calling Archetype.Compact().
  • Pointers to component data are not meant to be long lived. If you change the archetype of an entity (adding or removing components) or delete the entity these pointers are immedietly invalidated. You should always re-fetch and re-query component data on each update/frame.
  • The storage layer has no locking or concurrent-access protection. You should avoid spawning, deleting, adding components or removing components while actively querying/iterating over entities. Use a command buffer to defer these mutations properly.

Documentation

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func ReadComponent

func ReadComponent[T any](reader ComponentReader, entityId EntityId) *T

func RegisterComponent

func RegisterComponent[T any](r *ComponentRegistry)

RegisterComponent registers a new component type with the given registry. This must be called for each component type before it can be used.

Types

type Archetype

type Archetype struct {
	// contains filtered or unexported fields
}

Archetype represents a unique combination of component types

func NewArchetype

func NewArchetype(id uint32, types []reflect.Type, registry *ComponentRegistry) *Archetype

NewArchetype creates a new archetype with the given ID and sorted component types

func (*Archetype) Compact

func (a *Archetype) Compact()

Compact reorganizes all component storage to eliminate empty slots and reduce fragmentation EntityRefs remain valid and are automatically updated to point to the new indices

Example

ExampleArchetype_Compact demonstrates archetype compaction to reclaim memory. When entities are deleted, they can leave gaps in the archetype's component arrays. Compaction moves entities to fill these gaps, improving memory locality and iteration performance. Entity IDs are updated during compaction, but EntityRefs remain valid and automatically track the new locations.

registry := ecs.NewComponentRegistry()
ecs.RegisterComponent[Position](registry)
ecs.RegisterComponent[Health](registry)
storage := ecs.NewStorage(registry)

entities := make([]ecs.EntityId, 5)
for i := range 5 {
	entities[i] = storage.Spawn(
		Position{X: float32(i * 10), Y: 0},
		Health{Current: 100, Max: 100},
	)
}

storage.Delete(entities[1])
storage.Delete(entities[3])

view := ecs.NewView[struct {
	*Position
	*Health
}](storage)

fmt.Println("Before compaction:")
count := 0
for range view.Iter() {
	count++
}
fmt.Printf("Entities: %d\n", count)

archetype := storage.GetArchetype(entities[0].ArchetypeId())
if archetype != nil {
	archetype.Compact()
}

fmt.Println("\nAfter compaction:")
count = 0
for item := range view.Iter() {
	fmt.Printf("Position: (%.0f, %.0f)\n", item.Position.X, item.Position.Y)
	count++
}
fmt.Printf("Entities: %d\n", count)
Output:

Before compaction:
Entities: 3

After compaction:
Position: (0, 0)
Position: (20, 0)
Position: (40, 0)
Entities: 3

func (*Archetype) Delete

func (a *Archetype) Delete(entityIndex uint32)

Delete marks an entity's components as deleted Indices remain stable - the slot is simply marked as empty

func (*Archetype) GetComponent

func (a *Archetype) GetComponent(entityIndex uint32, compType reflect.Type) any

GetComponent returns the component of the given type for the entity at entityIndex The entityIndex is the storage position directly

func (*Archetype) HasComponent

func (a *Archetype) HasComponent(compType reflect.Type) bool

HasComponent checks if this archetype has the given component type

func (*Archetype) ID

func (a *Archetype) ID() uint32

ID returns the archetype's unique identifier

func (*Archetype) Iter

func (a *Archetype) Iter() func(yield func(EntityId) bool)

Iter returns an iterator over all valid EntityIds in this archetype

func (*Archetype) Spawn

func (a *Archetype) Spawn(components []any) uint32

Spawn creates a new entity in this archetype with the given components Returns the storage position as the entity index

func (*Archetype) Types

func (a *Archetype) Types() []reflect.Type

Types returns the sorted component types for this archetype

type ArchetypeStats added in v0.0.6

type ArchetypeStats struct {
	ID             uint32
	ComponentTypes []string
	EntityCount    int
}

ArchetypeStats provides statistics for a single archetype.

type Commands

type Commands struct {
	// contains filtered or unexported fields
}

Commands provides a buffer for deferred ECS operations that are executed at the end of a frame. This prevents structural changes to the ECS storage during system execution.

Example

ExampleCommands demonstrates using command buffers to defer entity mutations. Commands are essential when modifying entities during iteration, as directly spawning or deleting entities while iterating can invalidate iterators and cause crashes. The Scheduler automatically flushes commands at the end of each frame, applying all deferred operations safely.

registry := ecs.NewComponentRegistry()
ecs.RegisterComponent[Position](registry)
ecs.RegisterComponent[Velocity](registry)
ecs.RegisterComponent[Health](registry)
storage := ecs.NewStorage(registry)

storage.Spawn(Position{X: 0, Y: 0}, Health{Current: 0, Max: 100})
storage.Spawn(Position{X: 10, Y: 10}, Health{Current: 50, Max: 100})
storage.Spawn(Position{X: 20, Y: 20}, Health{Current: 100, Max: 100})

scheduler := ecs.NewScheduler(storage)
scheduler.Register(&CleanupSystem{})

scheduler.Once(1.0)

view := ecs.NewView[struct{ *Position }](storage)
remaining := 0
for range view.Iter() {
	remaining++
}
fmt.Printf("Remaining entities: %d\n", remaining)
Output:

Queued 1 dead entities for deletion
Remaining entities: 2
Example (Spawning)

ExampleCommands_spawning shows using commands to spawn entities during iteration. This is common for systems that need to create projectiles, particles, or other entities based on existing entity state. Commands ensure spawning happens after iteration completes, preventing iterator invalidation.

registry := ecs.NewComponentRegistry()
ecs.RegisterComponent[Position](registry)
ecs.RegisterComponent[Velocity](registry)
ecs.RegisterComponent[ShootTimer](registry)
storage := ecs.NewStorage(registry)

storage.Spawn(
	Position{X: 10, Y: 10},
	Velocity{DX: 1, DY: 0},
	ShootTimer{TimeUntilShot: 0},
)
storage.Spawn(
	Position{X: 20, Y: 20},
	Velocity{DX: 0, DY: 1},
	ShootTimer{TimeUntilShot: 5},
)

scheduler := ecs.NewScheduler(storage)
scheduler.Register(&ShootingSystem{})

scheduler.Once(1.0)

view := ecs.NewView[struct {
	*Position
	*Velocity
}](storage)
count := 0
for range view.Iter() {
	count++
}
fmt.Printf("Total entities with velocity: %d\n", count)
Output:

Spawned projectile at (10, 10)
Total entities with velocity: 3

func (*Commands) AddComponent

func (c *Commands) AddComponent(entity EntityId, component any)

AddComponent queues a component addition operation.

func (*Commands) Defer added in v0.0.6

func (c *Commands) Defer(fn func())

Defer queues a function execution operation.

func (*Commands) Delete

func (c *Commands) Delete(entity EntityId)

Delete queues an entity deletion operation.

func (*Commands) Flush added in v0.0.2

func (c *Commands) Flush(storage *Storage)

Flush flushes all commands to the provided storage, reseting the buffer state

func (*Commands) RemoveComponent

func (c *Commands) RemoveComponent(entity EntityId, compType reflect.Type)

RemoveComponent queues a component removal operation.

func (*Commands) Spawn

func (c *Commands) Spawn(components ...any)

Spawn queues an entity spawn operation with the given components.

type ComponentReader

type ComponentReader interface {
	GetComponent(EntityId, reflect.Type) any
}

type ComponentRegistry

type ComponentRegistry struct {
	// contains filtered or unexported fields
}

ComponentRegistry manages component type registration for an ECS instance. Each Storage instance has its own ComponentRegistry, allowing multiple independent ECS systems to coexist without interference.

func NewComponentRegistry

func NewComponentRegistry() *ComponentRegistry

NewComponentRegistry creates a new component registry.

type EntityId

type EntityId uint64

EntityId encodes both the archetype ID (upper 32 bits) and the entity index (lower 32 bits)

func NewEntityId

func NewEntityId(archetypeId uint32, index uint32) EntityId

NewEntityId creates an EntityId from an archetype ID and entity index

func (EntityId) ArchetypeId

func (e EntityId) ArchetypeId() uint32

ArchetypeId extracts the archetype ID from the entity ID

func (EntityId) Index

func (e EntityId) Index() uint32

Index extracts the entity index from the entity ID

type EntityRef

type EntityRef struct {
	Id        EntityId
	Archetype *Archetype
}

EntityRef is a stable reference to an entity

Example

ExampleEntityRef demonstrates using EntityRefs to maintain stable references to entities. Unlike EntityIds which can become invalid when entities move between archetypes or are deleted, EntityRefs remain valid and automatically track entities as they move. This makes them ideal for storing relationships between entities in components.

registry := ecs.NewComponentRegistry()
ecs.RegisterComponent[Position](registry)
ecs.RegisterComponent[Velocity](registry)
storage := ecs.NewStorage(registry)

target := storage.Spawn(Position{X: 100, Y: 100})
targetRef := storage.CreateEntityRef(target)

targetId, ok := storage.ResolveEntityRef(targetRef)
if ok {
	targetPos := ecs.ReadComponent[Position](storage, targetId)
	fmt.Printf("Target at (%.0f, %.0f)\n", targetPos.X, targetPos.Y)
}

target = storage.AddComponent(target, Velocity{DX: 0, DY: 0})

targetId, ok = storage.ResolveEntityRef(targetRef)
if ok {
	targetPos := ecs.ReadComponent[Position](storage, targetId)
	fmt.Printf("Target moved archetypes, still at (%.0f, %.0f)\n", targetPos.X, targetPos.Y)
}

storage.Delete(target)
_, ok = storage.ResolveEntityRef(targetRef)
fmt.Printf("Target deleted, ref valid: %v\n", ok)
Output:

Target at (100, 100)
Target moved archetypes, still at (100, 100)
Target deleted, ref valid: false
Example (RelationshipComponent)

ExampleEntityRef_relationshipComponent shows using EntityRefs within components to create relationships between entities. This pattern is common for AI targets, parent-child hierarchies, or any other entity-to-entity relationships that need to survive archetype changes and entity deletions.

registry := ecs.NewComponentRegistry()
ecs.RegisterComponent[Position](registry)
ecs.RegisterComponent[FollowerAI](registry)
storage := ecs.NewStorage(registry)

leader := storage.Spawn(Position{X: 50, Y: 50})
leaderRef := storage.CreateEntityRef(leader)

storage.Spawn(
	Position{X: 40, Y: 40},
	FollowerAI{Target: leaderRef},
)
storage.Spawn(
	Position{X: 60, Y: 40},
	FollowerAI{Target: leaderRef},
)

fmt.Println("Followers tracking leader:")
view := ecs.NewView[struct {
	*Position
	*FollowerAI
}](storage)

for item := range view.Iter() {
	if targetId, ok := storage.ResolveEntityRef(item.FollowerAI.Target); ok {
		targetPos := ecs.ReadComponent[Position](storage, targetId)
		fmt.Printf("Follower at (%.0f, %.0f) following target at (%.0f, %.0f)\n",
			item.Position.X, item.Position.Y, targetPos.X, targetPos.Y)
	}
}

storage.Delete(leader)

fmt.Println("\nAfter leader deleted:")
for item := range view.Iter() {
	if _, ok := storage.ResolveEntityRef(item.FollowerAI.Target); !ok {
		fmt.Printf("Follower at (%.0f, %.0f) lost its target\n",
			item.Position.X, item.Position.Y)
	}
}
Output:

Followers tracking leader:
Follower at (40, 40) following target at (50, 50)
Follower at (60, 40) following target at (50, 50)

After leader deleted:
Follower at (40, 40) lost its target
Follower at (60, 40) lost its target

type Query

type Query[T any] struct {
	// contains filtered or unexported fields
}

Query wraps a View with caching optimizations for repeated iteration. Queries cache matching archetypes to avoid re-calculating this on every run.

Example

ExampleQuery demonstrates using queries for high-performance iteration. Unlike Views, Queries cache the list of matching archetypes, which provides a significant performance boost for repeated iteration, as the set of matching archetypes doesn't need to be re-calculated each time.

registry := ecs.NewComponentRegistry()
ecs.RegisterComponent[Position](registry)
ecs.RegisterComponent[Velocity](registry)
ecs.RegisterComponent[Health](registry)
storage := ecs.NewStorage(registry)

storage.Spawn(Position{X: 0, Y: 0}, Velocity{DX: 1, DY: 0})
storage.Spawn(Position{X: 10, Y: 10}, Velocity{DX: 0, DY: 1}, Health{Current: 100, Max: 100})
storage.Spawn(Position{X: 20, Y: 20}, Velocity{DX: -1, DY: -1})

query := ecs.NewQuery[struct {
	*Position
	*Velocity
}](storage)

type result struct {
	x, y, newX, newY float32
}
results := make([]result, 0)
for item := range query.Iter() {
	newX := item.Position.X + item.Velocity.DX
	newY := item.Position.Y + item.Velocity.DY
	results = append(results, result{item.Position.X, item.Position.Y, newX, newY})
}

for i := 0; i < len(results); i++ {
	for j := i + 1; j < len(results); j++ {
		if results[i].x > results[j].x {
			results[i], results[j] = results[j], results[i]
		}
	}
}

fmt.Println("Moving entities:")
for _, r := range results {
	fmt.Printf("Position (%.0f, %.0f) -> (%.0f, %.0f)\n", r.x, r.y, r.newX, r.newY)
}
Output:

Moving entities:
Position (0, 0) -> (1, 0)
Position (10, 10) -> (10, 11)
Position (20, 20) -> (19, 19)

func NewQuery

func NewQuery[T any](storage *Storage) *Query[T]

NewQuery creates a new Query with archetype-level caching.

func (*Query[T]) Init

func (q *Query[T]) Init(storage *Storage)

Init initializes or re-initializes the Query with a storage. Called by the Scheduler during system registration.

func (*Query[T]) Iter

func (q *Query[T]) Iter() iter.Seq[T]

Iter returns an iterator over component data.

type Scheduler

type Scheduler struct {
	// contains filtered or unexported fields
}

Scheduler manages and executes systems in order.

Example

ExampleScheduler demonstrates building a game loop with multiple systems. The Scheduler manages system execution order, automatically initializes Query fields, executes queries each frame, and flushes command buffers. Systems are executed in registration order, and all queries are synchronized before any system runs.

package main

import (
	"fmt"

	"github.com/plus3/ooftn/ecs"
)

type Transform struct {
	X, Y float32
}

type Speed struct {
	DX, DY float32
}

type Hitpoints struct {
	Current, Max int
}

type PhysicsSystem struct {
	Entities ecs.Query[struct {
		*Transform
		*Speed
	}]
}

func (s *PhysicsSystem) Execute(frame *ecs.UpdateFrame) {
	for entity := range s.Entities.Iter() {
		entity.Transform.X += entity.Speed.DX * float32(frame.DeltaTime)
		entity.Transform.Y += entity.Speed.DY * float32(frame.DeltaTime)
	}
}

type HealingSystem struct {
	Entities  ecs.Query[struct{ *Hitpoints }]
	RegenRate float32
}

func (s *HealingSystem) Execute(frame *ecs.UpdateFrame) {
	for entity := range s.Entities.Iter() {
		if entity.Hitpoints.Current < entity.Hitpoints.Max {
			entity.Hitpoints.Current += int(s.RegenRate * float32(frame.DeltaTime))
			if entity.Hitpoints.Current > entity.Hitpoints.Max {
				entity.Hitpoints.Current = entity.Hitpoints.Max
			}
		}
	}
}

func main() {
	registry := ecs.NewComponentRegistry()
	ecs.RegisterComponent[Transform](registry)
	ecs.RegisterComponent[Speed](registry)
	ecs.RegisterComponent[Hitpoints](registry)
	storage := ecs.NewStorage(registry)

	storage.Spawn(
		Transform{X: 0, Y: 0},
		Speed{DX: 10, DY: 5},
		Hitpoints{Current: 80, Max: 100},
	)
	storage.Spawn(
		Transform{X: 100, Y: 100},
		Speed{DX: -5, DY: -5},
		Hitpoints{Current: 50, Max: 100},
	)

	scheduler := ecs.NewScheduler(storage)
	scheduler.Register(&PhysicsSystem{})
	scheduler.Register(&HealingSystem{RegenRate: 10})

	scheduler.Once(1.0)

	view := ecs.NewView[struct {
		*Transform
		*Hitpoints
	}](storage)

	fmt.Println("After one frame:")
	for item := range view.Iter() {
		fmt.Printf("Position: (%.0f, %.0f), Health: %d/%d\n",
			item.Transform.X, item.Transform.Y,
			item.Hitpoints.Current, item.Hitpoints.Max)
	}

}
Output:

After one frame:
Position: (10, 5), Health: 90/100
Position: (95, 95), Health: 60/100
Example (WithSingletons)

ExampleScheduler_withSingletons demonstrates using singleton components in systems. Singleton fields are automatically initialized by the Scheduler, just like Query fields. This provides efficient access to global state without the iteration overhead of queries.

package main

import (
	"fmt"

	"github.com/plus3/ooftn/ecs"
)

type Transform struct {
	X, Y float32
}

type GameTime struct {
	TotalFrames int
	TotalTime   float64
}

type TimeTracker struct {
	Entities ecs.Query[struct{ *Transform }]
	GameTime ecs.Singleton[GameTime]
}

func (s *TimeTracker) Execute(frame *ecs.UpdateFrame) {
	gameTime := s.GameTime.Get()
	gameTime.TotalFrames++
	gameTime.TotalTime += frame.DeltaTime
}

type ScoreTracker struct {
	Points int
}

type ScoreSystem struct {
	Entities ecs.Query[struct{ *Transform }]
	Score    ecs.Singleton[ScoreTracker]
}

func (s *ScoreSystem) Execute(frame *ecs.UpdateFrame) {
	count := 0
	for range s.Entities.Iter() {
		count++
	}
	s.Score.Get().Points += count * 10
}

func main() {
	registry := ecs.NewComponentRegistry()
	ecs.RegisterComponent[Transform](registry)
	storage := ecs.NewStorage(registry)

	// Initialize singletons
	ecs.NewSingleton[GameTime](storage, GameTime{TotalFrames: 0, TotalTime: 0})
	ecs.NewSingleton[ScoreTracker](storage, ScoreTracker{Points: 0})

	// Spawn entities
	storage.Spawn(Transform{X: 0, Y: 0})
	storage.Spawn(Transform{X: 10, Y: 10})
	storage.Spawn(Transform{X: 20, Y: 20})

	// Create scheduler with systems that use singletons
	scheduler := ecs.NewScheduler(storage)
	scheduler.Register(&TimeTracker{})
	scheduler.Register(&ScoreSystem{})

	// Run for 3 frames
	scheduler.Once(0.016)
	scheduler.Once(0.016)
	scheduler.Once(0.016)

	// Check singleton values
	var gameTime *GameTime
	storage.ReadSingleton(&gameTime)
	fmt.Printf("Frames: %d, Time: %.3f\n", gameTime.TotalFrames, gameTime.TotalTime)

	var score *ScoreTracker
	storage.ReadSingleton(&score)
	fmt.Printf("Score: %d points\n", score.Points)

}
Output:

Frames: 3, Time: 0.048
Score: 90 points

func NewScheduler

func NewScheduler(storage *Storage) *Scheduler

NewScheduler creates a new scheduler for the given storage.

func (*Scheduler) GetStats added in v0.0.6

func (s *Scheduler) GetStats() *SchedulerStats

GetStats returns statistics about system execution.

func (*Scheduler) Once

func (s *Scheduler) Once(dt float64)

Once executes all registered systems once with the given delta time.

func (*Scheduler) Register

func (s *Scheduler) Register(system System)

Register adds a system to the scheduler and initializes its Query fields.

func (*Scheduler) Run

func (s *Scheduler) Run(ctx context.Context, interval time.Duration)

Run executes all systems repeatedly at the given interval until the context is cancelled.

Example

ExampleScheduler_Run demonstrates running a continuous game loop. The Run method blocks and executes all systems at a fixed interval until the context is cancelled. This is the typical pattern for a real-time game or simulation.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/plus3/ooftn/ecs"
)

type Transform struct {
	X, Y float32
}

type Speed struct {
	DX, DY float32
}

type PhysicsSystem struct {
	Entities ecs.Query[struct {
		*Transform
		*Speed
	}]
}

func (s *PhysicsSystem) Execute(frame *ecs.UpdateFrame) {
	for entity := range s.Entities.Iter() {
		entity.Transform.X += entity.Speed.DX * float32(frame.DeltaTime)
		entity.Transform.Y += entity.Speed.DY * float32(frame.DeltaTime)
	}
}

func main() {
	registry := ecs.NewComponentRegistry()
	ecs.RegisterComponent[Transform](registry)
	ecs.RegisterComponent[Speed](registry)
	storage := ecs.NewStorage(registry)

	storage.Spawn(Transform{X: 0, Y: 0}, Speed{DX: 1, DY: 1})

	scheduler := ecs.NewScheduler(storage)
	scheduler.Register(&PhysicsSystem{})

	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()

	scheduler.Run(ctx, 16*time.Millisecond)

	fmt.Println("Scheduler stopped")
}
Output:

Scheduler stopped

type SchedulerStats added in v0.0.6

type SchedulerStats struct {
	SystemCount     int
	TotalExecutions int64
	Systems         []SystemStats
}

SchedulerStats provides statistics about scheduler execution.

type Singleton added in v0.0.4

type Singleton[T any] struct {
	// contains filtered or unexported fields
}

Singleton provides efficient access to a single component instance that is not associated with any entity. Use this for global game state, configuration, or other singleton data.

Example (MultipleReferences)

ExampleSingleton_multipleReferences shows that multiple Singleton instances reference the same underlying data.

package main

import (
	"fmt"

	"github.com/plus3/ooftn/ecs"
)

type GameScore struct {
	Points int
	Level  int
}

func main() {
	registry := ecs.NewComponentRegistry()
	storage := ecs.NewStorage(registry)

	// Create first singleton reference
	score1 := ecs.NewSingleton[GameScore](storage, GameScore{Points: 0, Level: 1})
	fmt.Printf("Score1: %d points, Level %d\n", score1.Get().Points, score1.Get().Level)

	// Modify via first reference
	score1.Get().Points = 100
	score1.Get().Level = 2

	// Create second reference to same singleton
	score2 := ecs.NewSingleton[GameScore](storage)
	fmt.Printf("Score2: %d points, Level %d\n", score2.Get().Points, score2.Get().Level)

	// Both references point to the same data
	score2.Get().Points = 250
	fmt.Printf("Score1 after Score2 update: %d points\n", score1.Get().Points)

}
Output:

Score1: 0 points, Level 1
Score2: 100 points, Level 2
Score1 after Score2 update: 250 points

func NewSingleton added in v0.0.4

func NewSingleton[T any](storage *Storage, initializer ...T) *Singleton[T]

NewSingleton creates a new Singleton accessor for the given storage. If initializer is provided and the singleton doesn't exist in storage, it will be created with the initializer value. Otherwise, a zero value is used. This guarantees the singleton exists in storage after the call.

Example

ExampleNewSingleton demonstrates creating and accessing singleton components. Singletons are global components not associated with any entity, useful for game state, configuration, or other application-wide data.

package main

import (
	"fmt"

	"github.com/plus3/ooftn/ecs"
)

type GameConfig struct {
	MaxPlayers int
	Difficulty string
}

func main() {
	registry := ecs.NewComponentRegistry()
	storage := ecs.NewStorage(registry)

	// Create singleton with initializer
	config := ecs.NewSingleton[GameConfig](storage, GameConfig{
		MaxPlayers: 4,
		Difficulty: "Normal",
	})

	fmt.Printf("Config: %d players, %s difficulty\n", config.Get().MaxPlayers, config.Get().Difficulty)

	// Modify the singleton
	config.Get().Difficulty = "Hard"
	fmt.Printf("Updated difficulty: %s\n", config.Get().Difficulty)

	// Create another reference to the same singleton
	sameConfig := ecs.NewSingleton[GameConfig](storage)
	fmt.Printf("Same config: %s difficulty\n", sameConfig.Get().Difficulty)

}
Output:

Config: 4 players, Normal difficulty
Updated difficulty: Hard
Same config: Hard difficulty

func (*Singleton[T]) Get added in v0.0.4

func (s *Singleton[T]) Get() *T

Get returns a pointer to the singleton component. The singleton is guaranteed to exist (it's created automatically if needed).

func (*Singleton[T]) Init added in v0.0.4

func (s *Singleton[T]) Init(storage *Storage)

Init initializes the Singleton with a storage reference. This is called automatically by the Scheduler during system registration. It ensures the singleton exists in storage, creating it with a zero value if needed.

type Storage

type Storage struct {
	// contains filtered or unexported fields
}

Storage is the main ECS storage interface

Example

ExampleStorage demonstrates the basic API for managing entities and components. Storage is the core container for all entities and their component data. Components are organized by archetype - entities with the same component types share the same archetype for efficient memory layout and iteration.

registry := ecs.NewComponentRegistry()
ecs.RegisterComponent[Position](registry)
ecs.RegisterComponent[Velocity](registry)
ecs.RegisterComponent[Health](registry)
storage := ecs.NewStorage(registry)

player := storage.Spawn(
	Position{X: 10, Y: 20},
	Velocity{DX: 1, DY: 0},
	Health{Current: 100, Max: 100},
)

pos := ecs.ReadComponent[Position](storage, player)
fmt.Printf("Player spawned at (%.0f, %.0f)\n", pos.X, pos.Y)

pos.X = 15
pos.Y = 25
fmt.Printf("Player moved to (%.0f, %.0f)\n", pos.X, pos.Y)

storage.Delete(player)
fmt.Println("Player deleted")
Output:

Player spawned at (10, 20)
Player moved to (15, 25)
Player deleted
Example (AddRemoveComponents)

ExampleStorage_addRemoveComponents shows how entity archetypes change when components are added or removed. When an entity's components change, it moves to a different archetype that matches its new component set.

registry := ecs.NewComponentRegistry()
ecs.RegisterComponent[Position](registry)
ecs.RegisterComponent[Velocity](registry)
ecs.RegisterComponent[Health](registry)
storage := ecs.NewStorage(registry)

entity := storage.Spawn(Position{X: 0, Y: 0})

hasVel := storage.HasComponent(entity, reflect.TypeOf(Velocity{}))
fmt.Printf("Has velocity: %v\n", hasVel)

entity = storage.AddComponent(entity, Velocity{DX: 5, DY: 3})
vel := ecs.ReadComponent[Velocity](storage, entity)
fmt.Printf("Has velocity: %v (%.0f, %.0f)\n", vel != nil, vel.DX, vel.DY)

entity = storage.AddComponent(entity, Health{Current: 50, Max: 50})
health := ecs.ReadComponent[Health](storage, entity)
fmt.Printf("Has health: %v (%d/%d)\n", health != nil, health.Current, health.Max)

entity = storage.RemoveComponent(entity, reflect.TypeOf(Velocity{}))
hasVel = storage.HasComponent(entity, reflect.TypeOf(Velocity{}))
fmt.Printf("Has velocity: %v\n", hasVel)
Output:

Has velocity: false
Has velocity: true (5, 3)
Has health: true (50/50)
Has velocity: false

func NewStorage

func NewStorage(registry *ComponentRegistry) *Storage

NewStorage creates a new ECS storage system with the given component registry

func (*Storage) AddComponent

func (s *Storage) AddComponent(id EntityId, component any) EntityId

func (*Storage) AddSingleton added in v0.0.4

func (s *Storage) AddSingleton(component any) unsafe.Pointer

AddSingleton adds or updates a singleton component in storage. Singleton components are not associated with any entity and provide efficient global state access. Returns a pointer to the stored component.

func (*Storage) CollectStats added in v0.0.6

func (s *Storage) CollectStats() *StorageStats

CollectStats gathers statistics about the current storage state.

func (*Storage) CreateEntityRef

func (s *Storage) CreateEntityRef(id EntityId) *EntityRef

func (*Storage) Delete

func (s *Storage) Delete(id EntityId)

Delete removes all data related to the entity ID

func (*Storage) GetArchetype

func (s *Storage) GetArchetype(components ...any) *Archetype

GetArchetype returns an archetype storage (if one exists)

func (*Storage) GetArchetypeById added in v0.0.6

func (s *Storage) GetArchetypeById(id uint32) *Archetype

GetArchetypeById returns an archetype by its ID

func (*Storage) GetArchetypeByTypes

func (s *Storage) GetArchetypeByTypes(types []reflect.Type) *Archetype

GetArchetypeByTypes returns an archetype storage (if one exists) based on reflect.Type

func (*Storage) GetArchetypes added in v0.0.6

func (s *Storage) GetArchetypes() map[uint32]*Archetype

GetArchetypes returns all archetypes in storage

func (*Storage) GetComponent

func (s *Storage) GetComponent(id EntityId, compType reflect.Type) any

GetComponent returns the component for the given entity ID and component type

func (*Storage) GetSingleton added in v0.0.4

func (s *Storage) GetSingleton(componentType reflect.Type) any

GetSingleton returns a pointer to a singleton component, or nil if it doesn't exist.

func (*Storage) HasComponent

func (s *Storage) HasComponent(id EntityId, compType reflect.Type) bool

HasComponent checks if an entity has a specific component type

func (*Storage) InvalidateEntityRef

func (s *Storage) InvalidateEntityRef(ref *EntityRef) bool

func (*Storage) ReadSingleton added in v0.0.4

func (s *Storage) ReadSingleton(ptr any) bool

ReadSingleton reads a singleton component into the provided pointer. The ptr parameter must be a pointer to a pointer (e.g., &gameState where gameState is *GameState). Returns true if the singleton exists and was successfully read, false otherwise.

Example usage:

var gameState *GameState
if storage.ReadSingleton(&gameState) {
    // use gameState here
}
Example

ExampleStorage_ReadSingleton demonstrates the ReadSingleton API for convenient singleton access outside of systems.

package main

import (
	"fmt"

	"github.com/plus3/ooftn/ecs"
)

type GameConfig struct {
	MaxPlayers int
	Difficulty string
}

type GameScore struct {
	Points int
	Level  int
}

func main() {
	registry := ecs.NewComponentRegistry()
	storage := ecs.NewStorage(registry)

	// Create singleton
	ecs.NewSingleton[GameConfig](storage, GameConfig{
		MaxPlayers: 8,
		Difficulty: "Expert",
	})

	// Read singleton using pointer pattern
	var config *GameConfig
	if storage.ReadSingleton(&config) {
		fmt.Printf("Game: %d players, %s mode\n", config.MaxPlayers, config.Difficulty)
	}

	// Try reading non-existent singleton
	var score *GameScore
	if storage.ReadSingleton(&score) {
		fmt.Println("Score exists")
	} else {
		fmt.Println("Score not found")
	}

}
Output:

Game: 8 players, Expert mode
Score not found

func (*Storage) RemoveComponent

func (s *Storage) RemoveComponent(id EntityId, compType reflect.Type) EntityId

func (*Storage) ResolveEntityRef

func (s *Storage) ResolveEntityRef(ref *EntityRef) (EntityId, bool)

func (*Storage) Spawn

func (s *Storage) Spawn(components ...any) EntityId

Spawn creates a new entity with the provided components

type StorageStats added in v0.0.6

type StorageStats struct {
	ArchetypeCount     int
	TotalEntityCount   int
	ArchetypeBreakdown []ArchetypeStats
	SingletonCount     int
	SingletonTypes     []string
	TotalStorageSlots  int
	EmptyStorageSlots  int
	StorageUtilization float32
}

StorageStats provides statistics about ECS storage state.

type System

type System interface {
	Execute(frame *UpdateFrame)
}

System represents a behavior that operates on entities with specific components. User-defined systems should implement this interface and can include Query fields for accessing entities, as well as custom state fields that persist between frames.

type SystemStats added in v0.0.6

type SystemStats struct {
	Name           string
	ExecutionCount int64
	MinDuration    time.Duration
	MaxDuration    time.Duration
	AvgDuration    time.Duration
	LastDuration   time.Duration
	TotalDuration  time.Duration
}

SystemStats provides execution statistics for a single system.

type UpdateFrame

type UpdateFrame struct {
	DeltaTime float64
	Commands  *Commands
	Storage   *Storage
}

type View

type View[T any] struct {
	// contains filtered or unexported fields
}

View represents a query for entities with a specific combination of components The type T should be a struct with embedded pointer fields for each component type Named fields can be marked as optional using the `ecs:"optional"` struct tag

Example

ExampleView demonstrates using Views for flexible entity queries and spawning. Views provide a way to query entities with specific component combinations and optionally spawn new entities. Unlike Queries, Views don't require a Scheduler and perform iteration on-demand, making them ideal for one-off queries, tools, or situations where you need to query entities outside of a system.

registry := ecs.NewComponentRegistry()
ecs.RegisterComponent[Position](registry)
ecs.RegisterComponent[Velocity](registry)
ecs.RegisterComponent[Health](registry)
storage := ecs.NewStorage(registry)

player := storage.Spawn(
	Position{X: 10, Y: 20},
	Velocity{DX: 1, DY: 0},
	Health{Current: 100, Max: 100},
)

view := ecs.NewView[struct {
	*Position
	*Velocity
}](storage)

if item := view.Get(player); item != nil {
	fmt.Printf("Player at (%.0f, %.0f) moving (%.0f, %.0f)\n",
		item.Position.X, item.Position.Y, item.Velocity.DX, item.Velocity.DY)
}
Output:

Player at (10, 20) moving (1, 0)
Example (Optional)

ExampleView_optional demonstrates using optional components in views. Optional components allow a single view to match entities that may or may not have certain components. This is useful for systems that need to handle both cases, like rendering entities with optional visual effects or processing entities with optional AI components.

registry := ecs.NewComponentRegistry()
ecs.RegisterComponent[Position](registry)
ecs.RegisterComponent[Velocity](registry)
ecs.RegisterComponent[Health](registry)
storage := ecs.NewStorage(registry)

storage.Spawn(Position{X: 10, Y: 10}, Velocity{DX: 1, DY: 0}, Health{Current: 50, Max: 100})
storage.Spawn(Position{X: 20, Y: 20}, Velocity{DX: 0, DY: 1}, Health{Current: 75, Max: 100})
storage.Spawn(Position{X: 30, Y: 30}, Velocity{DX: -1, DY: 0})

view := ecs.NewView[struct {
	Position *Position
	Velocity *Velocity
	Health   *Health `ecs:"optional"`
}](storage)

fmt.Println("All moving entities:")
for item := range view.Iter() {
	if item.Health != nil {
		fmt.Printf("Entity at (%.0f, %.0f) with health %d/%d\n",
			item.Position.X, item.Position.Y, item.Health.Current, item.Health.Max)
	} else {
		fmt.Printf("Invulnerable entity at (%.0f, %.0f)\n", item.Position.X, item.Position.Y)
	}
}
Output:

All moving entities:
Entity at (10, 10) with health 50/100
Entity at (20, 20) with health 75/100
Invulnerable entity at (30, 30)

func NewView

func NewView[T any](storage *Storage) *View[T]

NewView creates a new view for the given struct type The struct T should have embedded or named fields that are pointers to component types Embedded fields are always required Named fields can be marked as optional using the `ecs:"optional"` struct tag

func (*View[T]) Fill

func (v *View[T]) Fill(id EntityId, ptr *T) bool

Fill populates the provided struct pointer with component data for the given entity Returns false if the entity is missing any required components Optional components are set to nil if not present

func (*View[T]) Get

func (v *View[T]) Get(id EntityId) *T

Get returns a populated view struct for the given entity, or nil if the entity doesn't have all the required components

func (*View[T]) GetRef

func (v *View[T]) GetRef(ref *EntityRef) *T

GetRef returns a populated view struct for the given entity ref, or nil if invalid

func (*View[T]) Iter

func (v *View[T]) Iter() iter.Seq[T]

Iter returns an iterator over all entities that have all the required components for this view The iterator yields T where T is the populated view struct Optional components are set to nil if not present

Example

ExampleView_Iter shows iterating over all entities matching a view. Views automatically match entities across all archetypes that contain the required components, making it easy to process entities without worrying about their specific archetype layout.

By including an EntityId field in the view struct, you can access each entity's ID during iteration. This is useful for storing references, deleting entities, or performing operations that require the entity ID.

registry := ecs.NewComponentRegistry()
ecs.RegisterComponent[Position](registry)
ecs.RegisterComponent[Velocity](registry)
ecs.RegisterComponent[Health](registry)
storage := ecs.NewStorage(registry)

storage.Spawn(Position{X: 0, Y: 0}, Velocity{DX: 1, DY: 0})
storage.Spawn(Position{X: 10, Y: 10}, Velocity{DX: 0, DY: 1}, Health{Current: 50, Max: 100})
storage.Spawn(Position{X: 20, Y: 20}, Velocity{DX: -1, DY: -1})
storage.Spawn(Position{X: 100, Y: 100})

view := ecs.NewView[struct {
	Id ecs.EntityId
	*Position
	*Velocity
}](storage)

type result struct {
	x, y float32
}
results := make([]result, 0)
entityIds := make([]ecs.EntityId, 0)
for item := range view.Iter() {
	item.Position.X += item.Velocity.DX
	item.Position.Y += item.Velocity.DY
	results = append(results, result{item.Position.X, item.Position.Y})
	entityIds = append(entityIds, item.Id)
}

for i := 0; i < len(results); i++ {
	for j := i + 1; j < len(results); j++ {
		if results[i].x > results[j].x {
			results[i], results[j] = results[j], results[i]
		}
	}
}

fmt.Println("Entities with position and velocity:")
for _, r := range results {
	fmt.Printf("New position: (%.0f, %.0f)\n", r.x, r.y)
}
fmt.Printf("Total entities with IDs: %d\n", len(entityIds))
Output:

Entities with position and velocity:
New position: (1, 0)
New position: (10, 11)
New position: (19, 19)
Total entities with IDs: 3

func (*View[T]) Spawn

func (v *View[T]) Spawn(data T) EntityId

Spawn creates a new entity with components extracted from the view struct

Directories

Path Synopsis
Package debugui provides immediate-mode GUI integration for ECS applications using Dear ImGui.
Package debugui provides immediate-mode GUI integration for ECS applications using Dear ImGui.
ebiten
Package ebiten provides Dear ImGui backend integration for the Ebiten game engine.
Package ebiten provides Dear ImGui backend integration for the Ebiten game engine.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL