Volt is an ECS(entity-component-system) oriented for games development with Go. It is inspired by the documentation available here: https://github.com/SanderMertens/ecs-faq
There is many ways to write an ECS, and Volt is based on the Archetype paradigm.
An entity is the end object in a game (e.g. a character). It is only defined by its identifier called EntityId. This identifier is generated, its type uint64 avoiding to generate twice the same id. When an entity is removed, this identifier can be used again for a new one.
Looking at the benchmark, a scene can handle between 100.000 to 1.000.000 depending on your machine and the complexity of the project. But of course, the lower the better, as it will allow the project to run on slower computers.
An entity is composed from 1 to N Component(s). It is a structure of properties, and should not contain any logic by itself (meaning no functions). The Components are manipulated by Systems.
A Component is defined by its ComponentId, ranging between [0;2048].
A system is a specialized tool that fetches entities, filtered by their Components, and transforms the datas. For example: the audio could be managed by a system, or the graphics managed by a render system.
Volt does not directly implements Systems, but allows you to create Queries that you can use in your own specific tools.
A Query is a search tool for the set of entities that possess (at least) the list of ComponentId provided. It is then possible to iterate over the result of this search within a System, in order to manipulate the Components.
In an ECS (Entity-Component-System), an Archetype is the set of Entities that share the same ComponentId. The Archetype itself is not publicly exposed, but is instead managed internally and represents a major structure within Volt.
Using the Structure Of Arrays (SoA) paradigm, Components are persisted in a dedicated storage for each ComponentId. This allows for cache hits during read phases within Query iterations, resulting in significantly improved performance compared to an Array of Structures (AoS) model.
- Create a World to contain all the datas
world := volt.CreateWorld()- Create your components, and implement the ComponentInterface with GetComponentId(). Your ComponentId should range between [0;2048].
const (
transformComponentId = iota
)
type transformComponent struct {
x, y, z float64
}
func (t transformComponent) GetComponentId() volt.ComponentId {
return transformComponentId
}
type transformConfiguration struct {
x, y, z float64
}- Register the component for the world to use it. The BuilderFn is optional, it allows to initialize and customize the component at its creation.
volt.RegisterComponent[transformComponent](world, &ComponentConfig[transformComponent]{BuilderFn: func(component any, configuration any) {
conf := configuration.(*transformConfiguration)
transformComponent := component.(*transformComponent)
transformComponent.x = conf.x
transformComponent.y = conf.y
transformComponent.z = conf.z
}})- Create the entity
entityId := world.CreateEntity()Important: the entity will receive a unique identifier. When the entity is removed, this id can be used again and assigned to a new entity.
- Add the component to the entity
component := volt.ConfigureComponent[transformComponent](&world, transformConfiguration{x: 1.0, y: 2.0, z: 3.0})
volt.AddComponent(&world, entity, component)- Remove the component to the entity
err := RemoveComponent[testTransform](world, entityId)
if err != nil {
fmt.Println(err)
}- Delete the entity
world.RemoveEntity(entityId)The most powerful feature is the possibility to query entities with a given set of Components. For example, in the Rendering system of the game engine, a query will fetch only for the entities having a Mesh & Transform:
query := volt.CreateQuery2[transformComponent, meshComponent](world, volt.QueryConfiguration{OptionalComponents: []volt.OptionalComponent{meshComponentId}})
for result := range query.Foreach(nil) {
transformData(result.A)
}DEPRECATED: The Foreach function receives a function to pre-filter the results, and returns an iterator.
For faster performances, you can use concurrency with the function ForeachChannel:
query := volt.CreateQuery2[transformComponent, meshComponent](world, volt.QueryConfiguration{OptionalComponents: []volt.OptionalComponent{meshComponentId}})
queryChannel := query.ForeachChannel(1000, nil)
runWorkers(4, func(workerId int) {
for results := range queryChannel {
for result := range results {
transformData(result.A)
}
}
})
func runWorkers(workersNumber int, worker func(int)) {
var wg sync.WaitGroup
for i := range workersNumber {
i := i
wg.Add(1)
go func(worker func(int)) {
defer wg.Done()
worker(i)
}(worker)
}
wg.Wait()
}The Task function replaces the previous ForeachChannel function: it gives a simpler API with smaller memory footprint, for similar performances.
It is useful for executing code on all queried entities, by dispatching the work across a defined number of workers. Internally, this function leverages the iterator typically obtained through the Foreach function.
The number of workers should be considered based on the task's complexity: dispatching has a minmal overhead that might outweigh the benefits if the entity count to worker count ratio is not appropriate.
query := volt.CreateQuery2[transformComponent, meshComponent](world, volt.QueryConfiguration{OptionalComponents: []volt.OptionalComponent{meshComponentId}})
query.Task(4, nil, func(result volt.QueryResult2[transformComponent, meshComponent]) {
transformData(result.A)
})Queries exist for 1 to 8 Components.
You can also get the number of entities, without looping on each:
total := query.Count()Or get the entities identifiers as a slice:
entitiesIds := query.FetchAll()Tags are considered like any other Component internally, except they have no structure/value attached. They cannot be fetched using functions like GetComponent. Due to their simpler form, they do not need to be registered.
Tags are useful to categorize your entities.
e.g. "NPC", "STATIC", "DISABLED". For example, if you want to fetch only static content, you can query through the tag "STATIC". The Query will return only the entities tagged, in a faster way than applying the filter function in Query.Foreach to check on each entities if they are static.
e.g. to fetch only static entities:
const TAG_STATIC_ID = iota + volt.TAGS_INDICES
query := volt.CreateQuery2[transformComponent, meshComponent](world, volt.QueryConfiguration{Tags: []volt.TagId{TAG_STATIC_ID}})
for result := range query.Foreach(nil) {
transformData(result.A)
}Important: the TagIds should start from volt.TAGS_INDICES, allowing a range from [2048; 65535] for TagIds.
You can Add a Tag, check if an entity Has a Tag, or Remove it:
world.AddTag(TAG_STATIC_ID, entityId)
world.HasTag(TAG_STATIC_ID, entityId)
world.RemoveTag(TAG_STATIC_ID, entityId)The lifecycle (creation/deletion) of entities and components can trigger events. You can configure a callback function for each of these events, to execute your custom code:
world := volt.CreateWorld(100)
world.SetEntityAddedFn(func(entityId volt.EntityId) {
fmt.Println("A new entity has been created", entityId)
})
world.SetEntityRemovedFn(func(entityId volt.EntityId) {
fmt.Println("An entity has been deleted", entityId)
})
world.SetComponentAddedFn(func(entityId volt.EntityId, componentId volt.ComponentId) {
fmt.Println("The component", componentId, "is attached to the entity", entityId)
})
world.SetComponentRemovedFn(func(entityId volt.EntityId, componentId volt.ComponentId) {
fmt.Println("The component", componentId, "is removed from the entity", entityId)
})Volt managed the naming of entities up to the version 1.6.0. For performances reasons, this feature is removed from the v1.7.0+. You now have to keep track of the names by yourself in your application:
- Having a simple map[name string]volt.EntityId, you can react to the events and register these. Keep in mind that if your scene has a lot of entities, it will probably have a huge impact on the garbage collector.
- Add a MetadataComponent. To fetch an entity by its name can be very slow, so you probably do not want to name all your entities. For example:
const MetadataComponentId = 0
type MetadataComponent struct {
Name string
}
func (MetadataComponent MetadataComponent) GetComponentId() volt.ComponentId {
return MetadataComponentId
}
volt.RegisterComponent[MetadataComponent](&world, &volt.ComponentConfig[MetadataComponent]{BuilderFn: func(component any, configuration any) {}})
func GetEntityName(world *volt.World, entityId volt.EntityId) string {
if world.HasComponents(entityId, MetadataComponentId) {
metadata := volt.GetComponent[MetadataComponent](world, entityId)
return metadata.Name
}
return ""
}
func (scene *Scene) SearchEntity(name string) volt.EntityId {
q := volt.CreateQuery1[MetadataComponent](&world, volt.QueryConfiguration{})
for result := range q.Foreach(nil) {
if result.A.Name == name {
return result.EntityId
}
}
return 0
}Few ECS tools exist for Go. Arche and unitoftime/ecs are probably the most looked at, and the most optimized. In the benchmark folder, this module is compared to both of them.
- Go - v1.25.3
- Volt - v1.7.0
- Arche - v0.15.3
- UECS - v0.0.3
The given results were produced by a ryzen 7 5800x, with 100.000 entities:
goos: linux goarch: amd64 pkg: benchmark cpu: AMD Ryzen 7 5800X 8-Core Processor
| Benchmark | Iterations | ns/op | B/op | Allocs/op |
|---|---|---|---|---|
| BenchmarkCreateEntityArche-16 | 171 | 7138387 | 11096954 | 61 |
| BenchmarkIterateArche-16 | 2798 | 429744 | 354 | 4 |
| BenchmarkAddArche-16 | 253 | 4673362 | 122153 | 100000 |
| BenchmarkRemoveArche-16 | 247 | 4840772 | 100000 | 100000 |
| BenchmarkCreateEntityUECS-16 | 27 | 38852089 | 49119503 | 200146 |
| BenchmarkIterateUECS-16 | 4892 | 235333 | 128 | 3 |
| BenchmarkAddUECS-16 | 28 | 38982533 | 4721942 | 100005 |
| BenchmarkRemoveUECS-16 | 30 | 40290316 | 3336712 | 100000 |
| BenchmarkCreateEntityVolt-16 | 63 | 18836136 | 35181458 | 100101 |
| BenchmarkIterateVolt-16 | 3619 | 337764 | 256 | 8 |
| (DEPRECATED) BenchmarkIterateConcurrentlyVolt-16 | 9164 | 121653 | 3324 | 91 |
| BenchmarkTaskVolt-16 | 9859 | 119525 | 1847 | 38 |
| BenchmarkAddVolt-16 | 103 | 11379690 | 4313182 | 300000 |
| BenchmarkRemoveVolt-16 | 146 | 7647252 | 400001 | 100000 |
These results show a few things:
- Arche is the fastest tool for writes operations. In our game development though we would rather lean towards fastest read operations, because the games loops will read way more often than write.
- Unitoftime/ecs is the fastest tool for read operations on one thread only, but the writes are currently way slower than Arche and Volt (except on the Create benchmark).
- Volt is a good compromise, an in-between: fast enough add/remove operations, and almost as fast as Arche and UECS for reads on one thread. Volt uses the new iterators from go1.23, which in their current implementation are slower than using a function call in the for-loop inside the Query (as done in UECS). This means, if the Go team finds a way to improve the performances from the iterators, we can hope to acheive near performances as UECS.
- Thanks to the iterators, Volt provides a simple way to use goroutines for read operations. The data is received through a channel of iterator. As seen in the results, though not totally comparable, this allows way faster reading operations than any other implementation, and to use all the CPU capabilities to perform hard work on the components.
- It might be doable to use goroutines in Arche and UECS, but I could not find this feature natively? Creating chunks of the resulted slices would generate a lot of memory allocations and is not desirable.
The creator and maintainer of Arche has published more complex benchmarks available here: https://github.com/mlange-42/go-ecs-benchmarks
- For now the system is not designed to manage writes on a concurrent way: it means it is not safe to add/remove components in queries using multiples threads/goroutines. I need to figure out how to implement this, though I never met the need for this feature myself.
- https://github.com/SanderMertens/ecs-faq
- https://skypjack.github.io/2019-02-14-ecs-baf-part-1/
- https://ajmmertens.medium.com/building-an-ecs-1-where-are-my-entities-and-components-63d07c7da742
- https://github.com/unitoftime/ecs
See how to contribute.
This project is distributed under the Apache 2.0 licence.