|
libecs-cpp 2.1.0
Entity Component System library in C++
|
This document states how the library behaves, topic by topic. Read the guide first if you are new to the library. The description of each class and member is in the API documentation, which make doxygen builds and which is published at https://metaverse-systems.github.io/libecs-cpp/.
| Term | Meaning |
|---|---|
| Container | An ecs::Container: the entities, components, systems and resources that are updated together. |
| Handle | The text name of a container, an entity or a system: its Handle member. The program chooses it, or the library generates one from an identifier. |
| Type name | The text a component is stored and looked up under: its Type member. |
| Pass | One call of Container::Update(). The deferred functions run, the start-up step runs, and then each system that is due is updated once. |
| Start-up step | The part of a pass that calls Initialize() on every system not yet started. Container::SystemsInitialize() runs the same step by itself. |
| Started, shut down | A system is started once Initialize() has been called on it, and shut down once Shutdown() has. |
| Threaded container | A container on which Start() was called. It has its own thread, which runs passes at a fixed interval. |
| Application-driven container | A container without a thread. The application calls Update() itself. |
| Container thread | The thread that runs a container's passes: the thread of a threaded container, or the thread the application uses to call Update(). |
| Container code | Code that the library calls during a pass or a shutdown: system methods, timer callbacks and deferred functions. |
| Application thread | A thread that is not running container code, for example the main thread while it waits. |
Message submission, system registration, component attachment and the Entity constructors check their input before they change anything. A rejected call:
std::runtime_error, which can be caught;The error text has the form <function>: <condition>. The function is written like ecs::Manager::MessageSubmit() or ecs::Container("main")::System(), where main is the container's handle. A value of the wrong JSON type adds , got <type>.
A rejection inside a system's Update() can be caught there. If it is not caught, it is handled like any other exception from a system: see When a system throws.
The rejected inputs are listed with each operation: Registering a system, Attaching a component and Message format.
A container is run in one of two ways. A threaded container updates itself:
An application-driven container is updated by the application:
A program that uses the process-wide manager, ECS, shuts it down before main returns:
Start() starts the container's thread with 30 passes a second. Start(interval) sets the time between passes; see Intervals for the accepted range.Start() takes effect once. A repeated call returns normally and does nothing: no second thread, no second start of any system, and the interval stays as it was.Start() from the thread that owns the container, before any other thread uses it.Initialize() is called exactly once per system, on the container thread, before the system's first Update(). Container::Log() works from it.
SystemsInitialize() is optional. Calling it, once or several times, never starts a system twice.Initialize() throws, the error is logged at level error with the system's handle and rethrown. The system counts as started: it is not started again, it is updated, and it receives Shutdown() when it leaves. The systems after it are started by the next start-up step.Container::Stop() is safe from any thread and can be called more than once. Destroying a container stops it the same way and then releases it.
What Stop() does depends on the caller:
| Caller | Threaded container | Application-driven container |
|---|---|---|
| An application thread | Asks the container to stop after the pass in progress. Returns after every Shutdown() has run and the thread has ended. | Shuts the systems down on the calling thread, then returns. |
| The container's own code, during a pass | Records the request and returns at once. The stop completes when the pass ends. | Shuts the systems down at once, on the calling thread, including the system that made the call. The rest of the pass skips them. |
| Code of a different container | Records the request and returns at once. | Records the request and returns at once. The owner's later Stop(), or destroying the container, shuts the systems down. |
Update() does nothing. On a threaded container this holds from the moment the stop is requested.Defer() is dropped, and a system registered later is never started.Shutdown() is called exactly once on every system that was started, before the system is destroyed. A system that was never started never receives it. Container::Log() works from Shutdown() and from a system's destructor.
Shutdown() the system is not updated and receives no message.Shutdown() is logged at level error with the system's handle and swallowed. The other systems are still shut down and released, and nothing escapes a destructor.Shutdown() that blocks is waited for. The library does not abandon it.While a container is being stopped, a Shutdown() may do the following:
Shutdown() once and is released after the remaining systems have been shut down.Shutdown().Container::Stop() or Manager::Shutdown(). Neither waits and neither repeats the shutdown.| How the system leaves | Thread that runs Shutdown() | When |
|---|---|---|
SystemDestroy() outside a pass | The container thread (the caller) | During the call, before the system is destroyed |
SystemDestroy() during a pass or a start-up step | The container thread | When the outermost pass or step ends |
| Replacement | The container thread | When the outermost pass or step ends, or during the call outside one |
Container::Stop() or Manager::Shutdown() on a threaded container | The container's own thread | After the pass in progress, before the thread ends |
| Destruction of a threaded container | The container's own thread | The destructor stops the thread first |
Container::Stop() on an application-driven container | The thread that calls Stop() | Before Stop() returns |
| Destruction of an application-driven container | The thread that destroys it | During the destructor |
~Manager() | Each threaded container's own thread, then the destroying thread for application-driven containers | The destructor shuts down first, then destroys the containers |
Manager::Shutdown() from an application thread sets IsRunning() to false, asks every threaded container to stop, and returns after each of them has shut its systems down and its thread has ended. After it returns, no thread of the manager runs and no system is updated.Manager::Shutdown() from container code only records the request and returns at once. A system can therefore call it from Update() without waiting for itself, and two containers can call it at the same time. An application thread's later call does the waiting.Manager::Shutdown() is idempotent, and concurrent callers each return after the work is complete.IsRunning() never goes back to true. Other threads see a request the next time they call it.Manager::Shutdown() does not stop application-driven containers, because the library does not run application code on threads it does not own. Stop them with Stop() or destroy them. It does not destroy any container.Manager::Shutdown(), shuts its own systems down and ends. Every threaded container of the manager therefore stops. If the other containers must outlive a failure, catch the error in the system.~Manager() runs Shutdown() first, while every container still exists, so a Shutdown() that sends a message to another container finds it. It then closes the manager: later sends and creation of containers fail with std::runtime_error. Then the containers are destroyed.ECS is never destroyed, so the library cannot stop its containers at process exit. A program that uses it calls ECS->Shutdown() from its main thread before main returns. The library installs no automatic hook.
A system derives from ecs::System and overrides Update() and Export(). It may also override Initialize(), Shutdown() and Configure().
A system's handle is System::Handle, a const std::string that the base constructor sets. ecs::System("name") uses the name given, and ecs::System() generates a UUID. Nothing can change the handle afterwards, so a subclass names itself in its initialiser list, as Loader does above.
Container::System(std::make_unique<Loader>()) registers the system and returns a pointer to it. The system is in Systems when the call returns, and its Container member is set.
These calls are rejected:
| Condition | Key phrase of the error | After the call |
|---|---|---|
| Null pointer | system is missing | Unchanged. |
Handle is "" | system handle is empty | Unchanged. The system that was passed in is destroyed. |
| The object is already registered in a container | system "<handle>" is already registered | Unchanged. The pointer is released and the object is not deleted, because it belongs to its container. |
Registering a system under the handle of a system that is still registered replaces that system:
Container code may add and remove systems while a pass or a start-up step is running.
Removing:
SystemDestroy() accepts any handle, including a member of the system being removed, as in SystemDestroy(system->Handle). The same holds for EntityDestroy() and ComponentDestroy()."", is a silent no-op. Removing a system twice releases it once.Systems, is not exported, cannot receive messages, and is not started or updated again, even later in the same pass. Its timers do not fire. Its handle may be registered again at once.Update() does not run in that pass.System::UpdateSystem() call made outside a pass, the system stays in memory until the end of the next pass or until the container is destroyed.Adding:
Initialize(), Update() or a timer callback throws, the error is logged at level error with the system's handle and rethrown to the caller of Update() or SystemsInitialize().Update() again sees a consistent container.SystemDestroy() twice with the same handle in one pass, either for another system or for itself. The first call removes it, the second is a no-op, and the system is released once when the pass returns.a is removed and a new system is registered as a in the same pass. The new system is not updated in that pass, goes to the end of the order, and is updated from the next pass. Releasing the old one at the end of the pass does not touch it.Container::Export() and the System::Export() overrides it calls are a const query. They must not add or remove systems, entities or components.Update() or SystemsInitialize() from inside a pass of the same container is memory safe, but no outcome is defined.A timer calls a function after a length of time. A system owns its timers and adds them with TimerAdd():
TimerClear(name) cancels it. A one-shot timer is removed after it fires.Update(). A timer therefore cannot fire more often than its system is updated.std::chrono duration from zero to ecs::MAX_INTERVAL. TimerAdd() rejects a length outside that range with std::runtime_error and adds nothing.TimerClear(name) cancels every timer with that name.TimerAdd() on its own system or on any other system of the same container.TimerClear(name) on its own system, including for its own name. A cancelled timer that has not been reached yet in this update does not fire.Update() runs in the same pass.TimerAdd() and TimerClear() called from Update() take effect at once.Two scenarios:
retry fires and calls TimerAdd() with a new timer named retry. The fired timer is removed and the new one is kept, so retry fires again later.TimerAdd() for a timer x, TimerClear() for a timer y, and then throws. The exception reaches the caller of Update(). x is kept, y is gone, the callback's own one-shot timer is removed, and the timers after it did not fire.An interval is the time between two updates of a repeating schedule. Three things have one: a system (System::Timing), a threaded container (Start(interval)) and a repeating timer. Every interval is a std::chrono duration, for example std::chrono::milliseconds(100). A larger interval means fewer updates.
ecs::DEFAULT_INTERVAL, 33 333 microseconds, about 30 updates a second. Change it with this->Timing.SetInterval(std::chrono::milliseconds(100));.ecs::MAX_INTERVAL, 100 years of 365.25 days.std::runtime_error and nothing is changed. Timing::SetInterval(), System::TimerAdd() and Container::Start(interval) all check it.Restart(now). It is not due before that.System::ElapsedGet() returns how much time passed between the previous update of the system and the current one, in microseconds. ElapsedSecondsGet() returns the same value as a double in seconds.
ElapsedGet() returns the same value however many times it is called in that update, from Update(), a timer callback or helper code.ElapsedGet() returns the system's interval. A system whose interval is zero gets zero for its first update; code that divides by the elapsed time must handle that value or set an interval.Systems and containers read time from an ecs::Clock. The default is the real steady clock.
System::ClockSet() replaces the clock of one system. Container::ClockSet() replaces the clock of every system the container holds and of every system registered later.ecs::ManualClock with application-driven containers.Testing time without sleeping, with the Saver system from Timers:
A schedule can be used by itself, with explicit instants:
Timing::SetFrequency(), Timing::GetFrequency(), Timing(us), a Timer length given as a bare number, System::DeltaTimeGet() and the member System::lastTime are deprecated since 1.8.0. They compile with a notice that names the replacement and keep their earlier units. No removal is scheduled; a removal will be announced in the release notes first. The replacements are in the migration table of release 1.8.0.
An entity is a handle. Its data is in its components, and each component is stored under its entity and its type name. A component derives from ecs::Component, sets Type in its constructor and implements Export():
A component has no handle of its own. It is addressed by its entity (EntityHandle) and its Type.
The snippets below use container, an ecs::Container *, and entity, an ecs::Entity * made with container->Entity().
std::unique_ptr<ecs::Component> by value. Raw pointers and std::shared_ptrs do not compile.Entity::Component() sets EntityHandle. Container::Component() reads it.Type on the same entity replaces the first, so an entity has exactly one component of each type name. Code that holds a shared_ptr to the replaced component keeps a valid object.These inputs are rejected:
| Condition | Key phrase of the error | After the call |
|---|---|---|
| Null pointer | component is missing | Unchanged. |
Type is "" | component type is empty | Unchanged. No entry is made for the type name. |
EntityHandle is "" (Container::Component() only) | component entity handle is empty | Unchanged. |
EntityHandle names no entity in the container (never created, destroyed, or misspelled) | entity "<handle>" does not exist | Unchanged. |
A null pointer is reported with the entity's name (ecs::Entity("handle")::Component()). The other rejections are made by the container and carry its name (ecs::Container("main")::Component()). Creating an Entity with a null container throws ecs::Entity: container is missing.
Inside a system, the container is this->Container.
ComponentGet<T>(entity, type) returns a std::shared_ptr<T>. It is empty when the entity has no component with that type name, when the type name has never been used, when the entity is unknown, when either string is empty, and when the stored component is not a T.ComponentHas(entity, type) says whether a component is stored under that type name. Only the name is compared, so the component's class does not matter.std::string arguments, neither call allocates. A string literal of up to 15 characters converts without allocating and a longer one allocates, so code that runs every pass should keep its type names in std::string constants.Components[type][entity] inserts an empty entry for a type name or entity it does not find, as std::unordered_map::operator[] does. To read without inserting, use ComponentGet(), or find() or at() on the table.A resource is a block of bytes stored under a name.
ResourceGet() returns a std::shared_ptr<const ecs::Resource> that shares the stored bytes without a copy.A message is a JSON object that one piece of code sends to a system. Sending:
Receiving, in the destination system:
A message is a JSON object with a destination object. destination.system is non-empty text. For Manager::MessageSubmit(), destination.container is non-empty text as well. Container::MessageSubmit() sends to the container that received the call and does not read destination.container. Every other field is delivered unchanged.
These messages are rejected. The checks run in the order of the table and stop at the first failure. A rejected message takes no lock.
| Condition | Key phrase of the error | Manager | Container |
|---|---|---|---|
| The message is not a JSON object | message must be a JSON object, then got <type> | yes | yes |
No destination | message.destination is missing | yes | yes |
destination is not an object | message.destination must be a JSON object, then got <type> | yes | yes |
No destination.container | message.destination.container is missing | yes | no |
destination.container is not text | message.destination.container must be text, then got <type> | yes | no |
destination.container is "" | message.destination.container is empty | yes | no |
No destination.system | message.destination.system is missing | yes | yes |
destination.system is not text | message.destination.system must be text, then got <type> | yes | yes |
destination.system is "" | message.destination.system is empty | yes | yes |
<type> is the JSON type name: null, boolean, number, string, array, object, binary or discarded.
A well-formed message for an unknown destination throws std::runtime_error as well:
Container <name> not found. names the container given in the message.System <name> not found. names the container that was asked for the system. For Container::MessageSubmit() that is the container that received the call, whatever destination.container says.Callers on other threads should catch these errors.
MessageSubmit() on Manager, Container or System is received by the destination system exactly once while that system exists.messages at the start of the destination's next update, or earlier if the container thread calls MessagesWaiting().Container::System().Two containers messaging each other:
Only the container thread may change a running container. Container::Defer(fn) hands a function to that thread, which runs it at the start of the next pass:
Defer() is safe from any thread, including from container code.fn runs exactly once, on the container thread, at the start of the next Update(), before any system is updated and never in the middle of a pass.fn, every operation that is for the container thread only is available.fn, or during a pass, runs in the next pass.fn throws, the error is logged at level error, the other functions in the batch still run, and the first error is rethrown by Update() before any system is updated. A threaded container handles it as it handles a system's error; see When a system throws.fn and everything it captures must stay valid until it runs or is discarded. A function that needs an entity looks it up by handle inside the function.Container::Log(message, level) and System::Log(message, level) send a line to the container's log destination. The level defaults to info. The usual levels are error, warning, info and debug; any other name is passed on as given. System::Log() puts the system's handle in front of the message.
Each container starts with a destination that writes [level] message. Lines at error and warning go to standard error and every other line goes to standard output.
The tag is coloured only when the stream it is written to is an interactive terminal:
NO_COLOR, when set to a non-empty value, turns colour off on a terminal too.LoggerSet(fn) installs a function that receives exactly (message, level) as plain text, with no colour and no other decoration.
LoggerSet() and Log() are safe from any thread at any time.Log() call goes to exactly one destination, one that was installed at some time during the call. A call that starts after LoggerSet() returned uses the new destination.Log() or LoggerSet() itself.Log() calls succeed and print nothing.A system may call Log() in its constructor, before it belongs to a container. Those lines are held.
Container::System() delivers the held lines to the container's destination, in the order they were logged, each with the system's handle as a prefix, before the system is started.The library writes nothing to the console outside the default destination. A warning it raises itself, for example from componentsClear() on a system that is not registered, goes through the system's Log() like any other line. It reaches a replacement destination, and is held when the system has no container yet.
An ecs::Uuid is a random 128-bit identifier. The library generates one for every handle the program does not choose. Get() returns its text form: 36 lowercase hexadecimal characters with hyphens after the 8th, 12th, 16th and 20th, for example 550e8400-e29b-41d4-a716-446655440000. Generated identifiers are version 4 with the standard variant.
ecs::Uuid() can be called from any number of threads at the same time, including threads the library did not create. Each thread has its own generator, seeded independently on that thread's first identifier, and no lock is taken.ecs::Uuid(text) accepts exactly 36 characters in upper or lower case, with hyphens in the standard places, and any version or variant, including the all-zero identifier. Get() always returns lowercase.urn:uuid: prefix, the form without hyphens, and any character that is not a hexadecimal digit.std::runtime_error. The message is a single line of printable ASCII that names the problem. For a bad character it gives the 1-based position of the first one found. Long input is shortened to its first 64 characters and unprintable bytes are written as \xNN.Every operation belongs to one of three categories:
System::messages.From another thread, changing the entities, components, systems or resources of a running container directly is not supported and the outcome is undefined. Use `Container::Defer()`.
A container that is not running (never started, and nobody is calling Update()) may be changed directly, by one thread at a time. Container code runs on the container thread, so it needs no locks to use Entities, Components, Systems and the other members of its own container.
| Member | Category | Notes |
|---|---|---|
Manager::Container(handle), Manager::Container() | Any thread | One container per handle even when many threads ask at once. The pointer is valid until the manager is destroyed. |
Manager::ContainersGet() | Any thread | A snapshot by value; containers created later are not in it. |
Manager::IsRunning() | Any thread | Atomic. |
Manager::Shutdown() | Any thread | See Manager shutdown and destruction. |
Manager::MessageSubmit(message) | Any thread | See Messages. Also throws while the manager is being destroyed. |
Manager::~Manager() | Exclusive | Shuts down first and waits for sends already in progress. |
Container::Start(), Start(interval) | Once | See Starting a container. |
Container::Stop() | Any thread | See Stopping a container. |
Container::Defer(fn) | Any thread | See Deferred changes. |
Container::MessageSubmit(message) | Any thread | See Messages. |
Container::Log(), Container::LoggerSet() | Any thread | See Logging. |
Container::UuidGet(), Handle, Manager | Any thread | Handle and Manager never change after construction. |
Container::ClockSet(clock) | Container thread | |
Container::Update(), SystemsInitialize() | Container thread | One thread at a time. |
Container::System(), SystemDestroy() | Container thread | |
Container::Entity(), EntityDestroy(), Component(), ComponentDestroy() | Container thread | |
Container::ComponentGet(), ComponentHas() | Container thread | Take no lock and change nothing. |
Container::ResourceAdd(), Resources(), ResourceGet(), Export() | Container thread | |
Container::Entities, Components, Systems | Container thread | Public so systems can iterate them. |
Container::~Container() | Exclusive | Discards pending deferred functions, stops and joins the thread, shuts every started system down. |
System::MessageSubmit(message) | Any thread | The system must be alive. Sending through Container or Manager is safe against removal; a direct pointer is not. |
System::Handle | Any thread | Never changes after construction. |
Every other member of System | Container thread | |
Entity, Component, Timing | Container thread | |
ecs::Uuid, ecs::SteadyClock, ecs::ManualClock | Any thread |
The library has six kinds of mutex:
Each is a leaf: a thread holds at most one of them at a time. None is held while user code runs. User code here means system methods, timer callbacks, log destinations, deferred functions and destructors of user objects.
The library blocks in two places only:
~Manager() waits for sends that are already in progress. Those sends never block.Stop(), Manager::Shutdown() or a destructor.Container code never waits: a request from there to stop a container or the manager only records the request. Sending never waits for a container to update. As a result, containers whose systems send messages to each other in both directions, or to themselves, cannot deadlock.
An application's own locks are its own responsibility. Do not hold one while calling a library function that your systems also need under that lock.