A "disappearing" ECS (entity component system) library for Nim. Necsus uses Nim macros to generate code for creating and executing an ECS-based application. Components are just regular objects, systems are regular procs, and everything related to entities is handled for you.
More details about how ECS architectures work can be found here:
Necsus was born out of the idea that Nim's macros could drastically reduce the boilerplate required for building an ECS based application, while still being approachable and keeping the cognitive overhead low.
- Entities are managed on your behalf. Under the covers, they're represented as an
int32
- Components are regular Nim types. Just about any type can be used as a component.
- Systems are
proc
s. They get access to the broader application state through arguments with special types; called directives - Systems and components are wired together automatically into a
proc
called an "app". Running your app is as simple as calling the generatedproc
.
import necsus, random
type
Position = object
x*, y*: float
Velocity = object
dx*, dy*: float
proc create(spawn: Spawn[(Position, Velocity)]) {.startupSys.}=
## Creates a handful of entities at random positions with random velocities
for _ in 1..10:
spawn.with(
Position(x: rand(0.0..100.0), y: rand(0.0..100.0)),
Velocity(dx: rand(0.0..10.0), dy: rand(0.0..10.0))
)
proc move(dt: TimeDelta, entities: Query[(ptr Position, Velocity)]) =
## Updates the positions of each component
for (position, velocity) in entities:
position.x += dt() * velocity.dx
position.y += dt() * velocity.dy
proc report(entities: FullQuery[(Position, )]) =
## Prints the position of each entity
for eid, comp in entities:
echo eid, " is at ", comp[0]
proc exiter(iterations: Local[int], exit: Shared[NecsusRun]) =
## Keeps track of the number of iterations through the system and eventually exits
if iterations.get(0) >= 100:
exit := ExitLoop
else:
iterations := iterations.get(0) + 1
proc app() {.necsus([~create, ~move, ~report, ~exiter], newNecsusConf()).}
## The skeleton into which the ECS code will be injected
# Run your app
app()
API Documentation is available here:
https://necsusecs.github.io/Necsus/
To get started with Necsus, you define your app by adding the necsus
pragma onto a function declaration:
import necsus
proc myApp() {.necsus([], newNecsusConf()).}
That's it. At this point you have a functioning ECS setup, though it won't do much without systems attached. If you
were to call myApp
it would just loop infinitely.
To make your application do useful work, you need to wire up systems. Creating a system is easy -- it's just a proc
.
The name of that proc
is then prefixed with a ~
and passed into the necsus
pragma:
import necsus
proc helloWorld() =
echo "hello world"
proc myApp() {.necsus([~helloWorld], newNecsusConf()).}
In the above example, if you called myApp
, it would print hello world
to the console in an infinite loop.
If you're curious about the tilde prefix, it's used to convince the Nim type checker that all the give systems
are compatible, despite being proc
s with different arguments.
When given multiple systems, they will be executed in the same order in which they are passed in:
import necsus
proc first() =
discard
proc second() =
discard
proc myApp() {.necsus([~first, ~second], newNecsusConf()).}
Within the lifecycle of an app, there are three phases in which a system can be executed:
- Startup: The system is executed once when the app is started. Systems opt-in to this phase by adding the
startupSys
pragma. - Loop: The system is executed for every loop. Systems naturally exist in this phase, though you can also be
explicit by adding the
loopSys
pragma to them. - Teardown: The system is executed once after the loop exits. Systems opt-in to this phase by adding the
teardownSys
pragma.
There are also callback systems that are only invoked when a specific situation is engaged:
- Save callback: The system is executed anytime the 'Save' directive is invoked. Systems opt into this phase
by adding the
saveSys
pragma. For these systems, the return type of the system is used as the value being saved. More on this below. - Restore callback: The system is executed anytime the
Restore
directive is invoked. Systems opt-in to this phase with therestoreSys
pragma. It is expected that the first parameter of these systems is not a directive, but is instead the value being decoded. - Event callback: The system is executed whenever an event is triggered by another system. The first argument of
the system represents the type of event it listens to. Systems opt-in to this phase with the
eventSys
pragma.
import necsus
proc startupSystem() {.startupSys.} =
discard
proc loopSystem() {.loopSys.} =
discard
proc teardownSystem() {.teardownSys.} =
discard
proc savingSystem(): string {.saveSys.} =
"Value to save"
proc restoringSystem(value: string) {.restoreSys.} =
echo "Restored value: ", value
proc eventSystem(event: int) {.eventSys.} =
echo "Event triggered: ", event
proc myApp(input: string) {.necsus([
~startupSystem,
~loopSystem,
~teardownSystem,
~savingSystem,
~restoringSystem,
~eventSystem,
], newNecsusConf()).}
Systems interact with the rest of an app using special method arguments, called Directives
. These directives are
just regular types that Necsus knows how to wire up in special ways.
Systems can't have any other type of argument. If Necsus doesn't recognize how to wire up an argument, the compile will fail.
To create new entities, use a Spawn
directive. This takes a single argument, which is the initial set of components
to attach to the newly minted entity.
There are two ways to spawn an entity, Spawn
and FullSpawn
. Under the covers, they do the same thing. The difference
is that FullSpawn
returns the generated EntityId
, and Spawn
does not. In general, you should use Spawn
over
FullSpawn
whenever possible.
import necsus
type
A = object
B = object
proc spawningSystem(spawn: Spawn[(A, B)]) {.startupSys.} =
for i in 1..10:
spawn.with(A(), B())
echo "Spawned a new entity!"
proc myApp() {.necsus([~spawningSystem], newNecsusConf()).}
During a build, Necsus automatically generates a set of all possible archetypes that could exist at
runtime. It does this by examining systems with FullQuery
, FullSpawn
, Lookup
, and Attach
directives then uses
that to calculate all the combinatorial possibilities. Naively, this is an exponential algorithm. This is important
because archetypes themselves aren't free. Each archetype that exists increases build times and slows down queries.
Using Spawn
instead of FullSpawn
allows the underlying algorithm to ignore those directives when calculating the
final set of archetypes. Because your system doesn't have access to the EntityId
, it can't use the output of a
Spawn
call as the input to an Attach
directive, which means it can't contribute to the list of archetypes.
Queries allow you to iterate over entities that have a specific set of components attached to them. Queries are the primary mechanism for interacting with entities and components.
There are two kinds of queries, Query
and FullQuery
. Query
gives you access to the components, while FullQuery
gives you access to the components and the EntityId
. You should use Query
wherever possible, then only use
FullQuery
when you explicitly need the EntityId
. For details about why the two mechanisms exist, see the section
above about Spawn
versus FullSpawn
.
import necsus
type
A = object
B = object
proc reportingSystem(query: Query[(A, B)]) =
for components in query:
echo "Found entity with ", components[0], " and ", components[1]
proc reportingSystemWithEntity(query: FullQuery[(A, B)]) =
for eid, components in query:
echo "Found entity ", eid, " with ", components[0], " and ", components[1]
proc myApp() {.necsus([~reportingSystem, ~reportingSystemWithEntity], newNecsusConf()).}
If you want to loop through a set of entities and update the values of their components, the most efficient mechanism available is to update those values in place. This is accomplished by requesting pointers when doing a query:
import necsus
type
A = object
value: int
proc inPlaceUpdate(query: Query[(ptr A, )]) =
for (a) in query:
a.value += 1
proc myApp() {.necsus([~inPlaceUpdate], newNecsusConf()).}
There will be times you want to query for entities that exclude a set of
components. This can be accomplished with the Not
type:
import necsus
type
A = object
a: string
B = object
b: string
C = object
proc excludingC(query: Query[(A, B, Not[C])]) =
for (a, b, _) in query:
echo "Found a with ", a.a, " and b with ", b.b
proc myApp() {.necsus([~excludingC], newNecsusConf()).}
If you would like a query to include a component if it exists, but still return the entity if it doesn't exist, you can use an optional in the component query:
import necsus, options
type
A = object
a: string
B = object
b: string
proc optionalB(query: Query[(A, Option[B])]) =
for (a, b) in query:
echo "Found a with ", a.a
if b.isSome: echo "Component B exists: ", b.get().b
proc myApp() {.necsus([~optionalB], newNecsusConf()).}
For situations where you have a singleton instance, you can use the single
method to pull it from a query:
import necsus, options
type A = object
proc oneInstance(query: Query[(A, )]) =
let (a) = query.single.get
echo a
proc myApp() {.necsus([~oneInstance], newNecsusConf()).}
Deleting is the opposite of spawning -- it deletes an entity and all the associated components:
import necsus
type
A = object
B = object
proc deletingSystem(query: FullQuery[(A, B)], delete: Delete) =
for eid, _ in query:
delete(eid)
proc myApp() {.necsus([~deletingSystem], newNecsusConf()).}
The DeleteAll
directive is a shortcut for deleting all entities that match a specific component pattern:
import necsus
type
A = object
B = object
proc deletingSystem(delete: DeleteAll[(A, Not[B])]) =
delete()
proc myApp() {.necsus([~deletingSystem], newNecsusConf()).}
Lookup
allows you to get components for an entity when you already have the entity ID. It returns an Option
, which
will be a Some
if the entity has the exact requested components:
import necsus, options
type
A = object
B = object
C = object
D = object
proc lookupSystem(query: FullQuery[(A, B)], lookup: Lookup[(C, D)]) =
for eid, _ in query:
let (c, d) = lookup(eid).get()
echo "Found entity ", eid, " with ", c, " and ", d
proc myApp() {.necsus([~lookupSystem], newNecsusConf()).}
Attaching and detaching allow you to add new components or remove existing components from an entity:
import necsus, options
type
A = object
B = object
C = object
proc attachDetach(query: FullQuery[(A, )], attachB: Attach[(B, )], detachC: Detach[(C, )]) =
for eid, _ in query:
eid.attachB((B(), ))
eid.detachC()
proc myApp() {.necsus([~attachDetach], newNecsusConf()).}
Note that when detaching, an entity must have all of the listed components for any of them to be detached.
If you need to attach a new component at the same time you need to detach other components, you can use the Swap
directive. It takes two parameters: (1) a set of components to attach, and (2) a set of components to detach.
import necsus, options
type
A = object
B = object
C = object
proc swapComponents(query: FullQuery[(A, )], replace: Swap[(B, ), (C, )]) =
for eid, _ in query:
eid.replace((B(), ))
proc myApp() {.necsus([~swapComponents], newNecsusConf()).}
One of the benefits of using Swap
over combining Detach
and Attach
is that it allows Necsus to more intelligently
create the list of archetypes that are needed. This will speed up build as well as runtime execution.
TimeDelta
is a proc(): float
filled with the amount of time since the last execution of a system
import necsus
proc showTime(dt: TimeDelta) =
echo "Time since last system execution: ", dt()
proc myApp() {.necsus([~showTime], newNecsusConf()).}
TimeElapsed
is a proc(): float
that tracks the amount of time spent executing the current application
import necsus
proc showTime(elapsed: TimeElapsed) =
echo "Time spent executing app: ", elapsed()
proc myApp() {.necsus([~showTime], newNecsusConf()).}
Local variables are a way to manage state that is specific to one system. Local variables will only be visible to the system that declares them as arguments.
import necsus
proc localVars(executionCount: Local[int]) =
echo "Total executions so far: ", executionCount.get(0)
executionCount := executionCount.get(0) + 1
proc myApp() {.necsus([~localVars], newNecsusConf()).}
Shared variables are shared across all systems. Any shared variable with the same type will have access to the same underlying value.
import necsus
proc updateCount(count: Shared[int]) =
count := count.get(0) + 1
proc printCount(count: Shared[int]) =
echo "Total executions so far: ", count.get(0)
proc myApp() {.necsus([~updateCount, ~printCount], newNecsusConf()).}
Inbox
and Outbox
represent the eventing system in Necsus. Events are published using the Outbox
and read using
the Inbox
. Any Inbox
or Outbox
with the same type will share the same underlying mailbox.
import necsus
type SomeEvent = distinct string
proc publish(sender: Outbox[SomeEvent]) =
sender(SomeEvent("This is a message"))
proc receive(receiver: Inbox[SomeEvent]) =
for event in receiver:
echo event.string
proc myApp() {.necsus([~publish, ~receive], newNecsusConf()).}
Aside from an Inbox
, the other way to listen for events sent to an Outbox
is by marking a system with the eventSys
pragma. Events with this pragma are executed immediately when an Outbox
emits a message. The same code above can
also be represented like this:
import necsus
type SomeEvent = distinct string
proc publish(sender: Outbox[SomeEvent]) =
sender(SomeEvent("This is a message"))
proc receive(event: SomeEvent) {.eventSys.} =
echo event.string
proc myApp() {.necsus([~publish, ~receive], newNecsusConf()).}
It's worth noting that event systems are executed immediately when an event is triggered. They will be directly invoked
by the Outbox
, within the call stack of the system that triggers the event.
Bundle
s are a way of grouping multiple directives into a single object to make them easier to pass around. They
are useful when you want to encapsulate a set of logic that needs to operate on multiple directives.
import necsus
type
A = object
B = object
MyBundle = object
spawn*: FullSpawn[(A, )]
attach*: Attach[(B, )]
proc useBundle(bundle: Bundle[MyBundle]) =
let eid = bundle.spawn.with(A())
bundle.attach.exec(eid, (B(), ))
proc myApp() {.necsus([~useBundle], newNecsusConf()).}
The Save
directive is a proc
that performs the following actions:
- Invokes all the systems tagged with the
saveSys
pragma - Serializes the results as a JSON object, where the names of the keys are the names of the types returned from the save systems
- Writes the serialized JSON to a stream
import necsus
type MyStrings = seq[string]
proc saveValues(): MyStrings {.saveSys.} =
@[ "a", "b", "c" ]
proc doSave(save: Save) =
echo save()
# The above statement prints: { "MyStrings": [ "a", "b", "c" ] }
proc myApp() {.necsus([~saveValues, ~doSave], newNecsusConf()).}
The benefit of using the Save
pragma is that it lets you separate your logic for what needs to be serialized from
your logic that defines how to serialize. You can scatter your saveSys
systems throughout your project so they
are co-located with the other systems they are associated with.
The Restore
directive is the opposite of Save
. It accepts a JSON stream, deserializes it, then invokes any systems
marked with restoreSys
. The key names of the JSON are expected to be the type names that get passed into the
restoreSys
systems.
import necsus
type MyStrings = seq[string]
proc restoreValues(values: MyStrings, spawn: Spawn[(string, )]) {.restoreSys.} =
## Called with decoded values with the `Restore` directive is used
for value in values:
spawn.with(value)
proc doRestore(restore: Restore) =
restore("""{ "MyStrings": [ "a", "b", "c" ] }""")
proc myApp() {.necsus([~restoreValues, ~doRestore], newNecsusConf()).}
TickId
gives you an auto-incrementing ID for each time a tick
is executed. This is useful, for example, if you need
to track whether an operation has already been performed for the current tick.
import necsus
proc printTickId(tickId: TickId) =
echo "Current tick ID is ", tickId()
proc myApp() {.necsus([~printTickId], newNecsusConf()).}
When you find yourself in a position that you need to see the exact state that an entity is in, you can get a string
dump of that entity by using the EntityDebug
directive:
import necsus
type
A = object
proc debuggingSystem(query: FullQuery[(A, )], debug: EntityDebug) =
for eid, _ in query:
echo debug(eid)
proc myApp() {.necsus([~debuggingSystem], newNecsusConf()).}
Beyond the basics of declaring systems and using directives, there are a few more advanced system use cases worth understanding.
A system may require that another system always be paired with it. This can be accomplished by adding the depends
pragma, which declares that relationship:
import necsus
proc runFirst() =
## No-op system, but it gets run first
discard
proc runSecond() {.depends(runFirst).} =
## No-op system that gets run second
discard
proc myApp() {.necsus([~runSecond], newNecsusConf()).}
For systems that need to maintain state, it can be convenient to hold on to an instance between invocations. You've got two options:
Option 1: Return a Proc
If your system returns a proc
, set the return type of your system to SystemInstance
. Necsus will invoke your
parent proc
during setup, and then the returned proc
will be invoked for every tick. The proc
itself that gets
returned here cannot take any arguments. For example:
import necsus
proc someSystem(create: Spawn[(string, )], query: Query[(string,)]): SystemInstance =
create.with("foo")
return proc() =
for (str,) in query:
echo str
proc myApp() {.necsus([~someSystem], newNecsusConf()).}
This makes it easier to capture the pragmas from your parent system as closure variables, which can then be freely used.
Option 2: Return an Object
Your other option is to return an object from the parent proc
. First, mark your parent proc
with the instanced
pragma. The parent proc
will get invoked once during the startup phase, and then a tick
proc will get invoked as
part of the main loop. This also allows you to create a =destroy
proc that gets invoked during teardown:
import necsus
type MySystem = object
query: Query[(string,)]
proc someSystem(create: Spawn[(string, )], query: Query[(string,)]): MySystem {.instanced.} =
create.with("foo")
result.query = query
proc tick(system: var MySystem) =
for (str,) in system.query:
echo str
proc `=destroy`(system: var MySystem) =
echo "Destroying system"
proc myApp() {.necsus([~someSystem], newNecsusConf()).}
Reusing code is obviously a fundamental aspect of programming, and using generics is a fundamental aspect of that in Nim. Necsus, however, can't resolve generic parameters by itself. It needs to know exactly what components need to be passed to each system at compile time.
To work around this, you can assign systems to variables, and then pass those variables into your app:
import necsus
type
SomeComponent = object
AnotherComponent = object
proc genericSpawner[T](): auto =
return proc (create: Spawn[(T, )]) =
create.with(T())
let spawnSomeComponent = genericSpawner[SomeComponent]()
let spawnAnotherComponent = genericSpawner[AnotherComponent]()
proc myApp() {.necsus([~spawnSomeComponent, ~spawnAnotherComponent], newNecsusConf()).}
It's worth mentioning that if you start using type aliases, Nim's type system tends to hide those from the macro system -- they generally get resolved directly down to the type they are aliasing. To work around that, you can add explicit type declarations or use templates to define your systems.
Oftentimes, games will have various states they can be in at a high level. For example, your game may have states to
represent "loading", "playing", "won" or "lost". For this, you can annotate a system with the active
pragma so it
only executes when the game is in a specific state. Shared
directives are then used for changing between states.
import necsus
type GameState = enum Loading, Playing, Won, Lost
proc showWon() {.active(Won).} =
echo "Game won!"
proc switchGameState(state: Shared[GameState]) =
## System that changes the game state to "won"
state := Won
proc myApp() {.necsus([~showWon, ~switchGameState], newNecsusConf()).}
At an app level, there are a few more features worth discussing.
Any arguments passed into your app will be available as Shared
arguments to your systems:
import necsus
proc exampleSystem(input: Shared[string]) =
echo input.get()
proc myApp(input: string) {.necsus([~exampleSystem], newNecsusConf()).}
If an app has a return value, it can be set in a system via a Shared
argument:
import necsus
proc setAppReturn(appReturns: Shared[string]) =
appReturns.set("Return value from app")
proc myApp(): string {.necsus([~setAppReturn], newNecsusConf()).}
Runtime configuration for the execution environment can be controlled through the newNecsusConf()
call passed to the
necsus
pragma. For example, the number of entities to reserve at startup can be configured as follows:
import necsus
proc myApp() {.necsus([], newNecsusConf(entitySize = 100_000)).}
With the default tick runner, exiting the app is done through a Shared
directive:
import necsus
proc immediateExit(exit: Shared[NecsusRun]) =
## Immediately exit the first time this system is called
exit := ExitLoop
proc myApp() {.necsus([~immediateExit], newNecsusConf()).}
myApp()
As the number of archetypes in your app creeps up, it increases the build time of your app and the size of the
generated binary. One way to combat this is with accessory components. When a component type is marked with the
{.accessory.}
it will no longer trigger the generation of a new archetype. Instead, it is stored as an optional
value attached to existing archetypes. This is an entirely internal change. For the rest of the app, an accessory
components looks like any other component in the system.
The downside of accessory components is that they incur a runtime overhead for queries. Instead of being able to blindly iterate of the values in an archetype, Necsus now needs to check whether an accessory exists for every row in an archetype.
import necsus
type
ItemName = string
IsEquiped {.accessory.} = object
proc createInventory(create: Spawn[(ItemName, )]) =
create.with("Sword")
proc equip(items: FullQuery[(ItemName, Not[IsEquiped])], equip: Attach[(IsEquiped, )]) =
for eid, _ in items:
eid.equip((IsEquiped(), ))
proc myApp() {.necsus([~createInventory, ~equip], newNecsusConf()).}
The maxCapacity
pragma can be added to a component to limit how much memory Necsus allocates for any
archetypes that contain that component. It's a way of telling Necsus that there will never be more than a given
number of entities that have this component attached.
import necsus
type
Enemy {.maxCapacity(50).} = object
## Never more than 50 enemies at a time
EnemyKind = enum Knight
HitPoints = int
proc createEnemies(
enemyCount: Query[(Enemy, )],
spawnEnemy: Spawn[(Enemy, EnemyKind, HitPoints)]
) =
if enemyCount.len < 50:
spawnEnemy.with(Enemy(), Knight, 50)
proc myApp() {.necsus([~createEnemies], newNecsusConf()).}
Note that if two components are both flagged with the maxCapacity
pragma, Necsus will use the higher value.
If you want to enforce that every archetype has an explicit capacity, you can set the -d:requireMaxCapacity
build flag.
The runner
is the function used to execute the primary system loop. The default runner is fairly simple -- it
executes the loop systems repeatedly until the Shared NecsusRun
variable flips over to ExitLoop
. If you need more
control over your game loop, you can pass in your own.
The last argument for a custom runner must be the tick
callback. Any other arguments will be processed in the same
manner as a system. This allows your runner to access entities, shared values, or events.
import necsus
proc customRunner*(count: Shared[int], tick: proc(): void) =
# Loop until 1000 iterations completed
while count.get(0) < 1_000:
tick()
proc incrementer(count: Shared[int]) =
count.set(count.get(0) + 1)
proc myApp() {.necsus(customRunner, [~incrementer], newNecsusConf()).}
myApp()
There are situations where you may not want Necsus to be in charge of executing the loop. For example, if you are
integrating with an SDK that uses a callback mechanism for controlling the main game loop. In those situations,
you can manually initialize your app and invoke the tick
function that Necsus generates:
import necsus
proc myExampleSystem() =
discard
proc myApp() {.necsus([~myExampleSystem], newNecsusConf()).}
# Initialize the app and execute the main loop 3 times
var app: myAppState
app.initMyApp()
app.tick()
app.tick()
app.tick()
When using Necsus in this setup, you can send data and events in from the outside world either through the initialization arguments, or using an inbox:
import necsus
type
MyData = object
value: string
MyEvent = object
value: string
proc printData(data: Shared[MyData]) =
echo data.get.value
proc printEvent(events: Inbox[MyEvent]) =
for event in events:
echo event.value
proc myApp(data: MyData) {.necsus([~printData, ~printEvent], newNecsusConf()).}
var app: myAppState
app.initMyApp(MyData(value: "some data"))
app.sendMyEvent(MyEvent(value: "some event"))
app.sendMyEvent(MyEvent(value: "another event"))
app.tick()
There are a few useful design patterns that are useful when using Necsus
There will be times when you want to create similar entities, but with slightly different overall
sets of components. You could use generics for this purpose, but Necsus has rules around the sort
order of entities, so this doesn't always work. Instead, you can use the extend
macro to combine
two tuples into one. You can also use the join
macro to create actual instances of a tuple that
was defined using extend
:
import necsus
type
EnemyState = enum Alive, Dead
Health = int
GroundUnit = object
SkyUnit = object
BaseEnemyComponents = (EnemyState, Health)
proc base(health: int): BaseEnemyComponents = (Alive, health)
proc createEnemies*(
createGround: Spawn[extend(BaseEnemyComponents, (GroundUnit, ))],
createSky: Spawn[extend(BaseEnemyComponents, (SkyUnit, ))],
) {.startupSys.} =
createGround.set(join(base(200) as BaseEnemyComponents, (GroundUnit(), ) as (GroundUnit, )))
createSky.set(join(base(100) as BaseEnemyComponents, (SkyUnit(), ) as (SkyUnit, )))
proc myApp() {.necsus([~createEnemies], newNecsusConf()).}
Bundles provide a way to put all your entity state logic in one place, then call it from other places. For example, you might have a state machine for an enemy that can change in various ways. You can put that logic in a single file:
import necsus, options
type
EnemyState = enum Alive, Attacking, KnockedBack, Dead
EnemyData = object
state: EnemyState
hitPoints: int
EnemyControl* = object
createEnemy: Spawn[(EnemyData, )]
findEnemy: Lookup[(ptr EnemyData, )]
proc createEnemy*(control: Bundle[EnemyControl], hitPoints: int) =
control.createEnemy.with(EnemyData(state: Alive, hitPoints: hitPoints))
proc damage*(control: Bundle[EnemyControl], enemy: EntityId, damage: int) =
control.findEnemy(enemy).map do (data: auto) -> void:
data[0].hitPoints -= damage
if data[0].hitPoints <= 0:
data[0].state = Dead
else:
data[0].state = KnockedBack
When you have an action that needs to be executed once when a state changes, you can encapsulate your state changes
into a Bundle
, then publish an event into an Outbox
. For example, imagine a project laid out in a few files
like this:
##
## gameState.nim
##
import necsus
type
GameState* = enum Loading, Playing, Won, Lost
StateManager* = object
state: Shared[GameState]
stateChange: Outbox[GameState]
proc change*(manager: Bundle[StateManager], newState: GameState) =
## Central entry point when the game state needs to be changed
manager.state := newState
manager.stateChange(newState)
##
## customSystem.nim
##
proc customSystem*(stateChanges: Inbox[GameState]) =
for newState in stateChanges:
echo "State changed to ", newState
##
## changeStateSystem.nim
##
proc changeStateSystem(manager: Bundle[StateManager], winConditionMet: Shared[bool]) =
if winConditionMet.get(false):
manager.change(Won)
##
## app.nim
##
proc app() {.necsus([~customSystem, ~changeStateSystem], newNecsusConf()).}
To test a system, you can use the runSystemOnce
macro. It accepts a single lambda as an
argument and will invoke that lambda as if it were a system. You can then pass those
directives to other systems, or interact with them directly.
import unittest, necsus
proc myExampleSystem(str: Shared[string]) =
str := "foo"
runSystemOnce do (str: Shared[string]) -> void:
test "Execute myExampleSystem":
myExampleSystem(str)
check(str.get == "foo")
To get a quick and dirty idea of how your app is performing, you can compile with the -d:profile
flag set. This
will cause Necsus to add profiling code that will report how long each system is taking. It takes measurements,
and then outputs the timings to the console.
To understand what is happening in various systems over time, various flags can be enabled to emit trace-level logs. They are:
-d:necsusSystemTrace
: Logs before and after a system is executed-d:necsusEntityTrace
: Logs whenever an entity is spawned, deleted, or modified in some way-d:necsusEventTrace
: Logs whenever an event is sent-d:necsusQueryTrace
: Logs when a query is executed-d:necsusSaveTrace
: Logs whenever save or restore is triggered
If Necsus isn't behaving as you would expect, the best tool you've got in your toolbox is the ability to dump the code
that it generates. This allows you to walk through what is happening, or even substitute the generated code into your
app and execute it. This can be enabled by compiling with the -d:dump
flag set.
If you find that builds are slowing down, it's often caused by archetypes being created that are never used. You can
set the -d:archetypes
flag as part of your build to do a dump of all the archetypes that exist. This gives you
a jumping-off point to see which archetypes are never actually used, then update your project so they are never
created in the first place. For example, with targeted usage of Query
versus FullQuery
or Spawn
vs FullSpawn
.
Code released under the Apache 2.0 license