Contents

Writing a Native Behavior

A native behavior is the native C++ counterpart of a ScriptComponent: attach the component’s type_name to an entity in the Inspector, and — once Play starts — an instance of the matching IEntityBehavior is constructed and receives on_spawn, on_enable, on_disable, on_destroy and on_message as that entity’s lifecycle changes, plus on_update once per fixed simulation step. There is no scripting language, virtual machine or reflection behind this; a behavior is an ordinary C++ class in this project’s own source tree.

Writing one

#include <SushiEngine/simulation/entity_behavior.hpp>

class SpinningLight : public SushiEngine::Simulation::IEntityBehavior
{
    public:
        void on_spawn(SushiEngine::Simulation::IWorldEditor& world,
                      SushiEngine::Simulation::EntityId id) override
        {
            // Read this entity's authored fields, if any, via
            // world.script_component(id, "SpinningLight").fields.
        }

        void on_enable(SushiEngine::Simulation::IWorldEditor& world,
                       SushiEngine::Simulation::EntityId id) override
        {
        }

        void on_message(SushiEngine::Simulation::IWorldEditor& world,
                        SushiEngine::Simulation::EntityId self,
                        SushiEngine::Simulation::EntityId sender,
                        const std::string& name,
                        const SushiEngine::Simulation::ScriptField& payload) override
        {
            if (name == "TurnOff")
                world.set_enabled(self, false);
        }
};
SE_REGISTER_BEHAVIOR(SpinningLight);

SE_REGISTER_BEHAVIOR(ClassName) registers the class under its own name the moment this translation unit’s static initializers run — nothing else needs editing to make a new behavior type available; the Inspector’s ScriptComponent field simply needs "SpinningLight" typed into type_name to attach it. A behavior that is registered by the time the editor opens does not need typing at all: the Inspector’s Add Component ▸ Scripts menu is seeded from BehaviorRegistry::registered_types() each time it opens, so a compiled-in behavior is on the list.

When each hook fires

  • on_spawn — once, the first time this entity’s instance exists: either because it already carried the ScriptComponent when Play started, or because add_script_component attached it to an already-existing entity during Play. An entity is always created bare — nothing can attach a script before it exists, so add_script_component (called after) is the only way a fresh behavior comes alive during Play.
  • on_enable — immediately after on_spawn if the entity is hierarchy-enabled at that moment, and again every time it transitions from hierarchy-disabled to hierarchy-enabled (its own enabled flag flipping, an ancestor’s flag flipping, or being reparented under a now-enabled ancestor).
  • on_disable — every time the entity transitions the other way, for the same three reasons.
  • on_destroy — once, immediately before the instance is torn down: at end_play() (Stop), when the entity is destroyed (request_destroy or the synchronous authoring API), or when its ScriptComponent is removed (remove_script_component) — preceded by on_disable if it was hierarchy-enabled at that moment.
  • on_update — once per fixed simulation step, for every hierarchy-enabled entity carrying the behavior, for as long as the simulation is playing. It is the one hook that is not edge-triggered on a lifecycle change; the section below is about it.

Running every fixed step

on_update is where a game advances its own state on simulated time:

void on_update(SushiEngine::Simulation::IWorldEditor& world,
               SushiEngine::Simulation::EntityId id,
               const SushiEngine::Simulation::TickContext& context) override
{
    if (id != context.driven_entity)
        return;
    if (!context.input.held("DriveThrottle"))
        return;
    SushiEngine::Simulation::EntityTransform transform = world.transform(id);
    transform.position.z += 5.0 * context.delta_seconds;
    world.set_transform(id, transform);
}

TickContext (engine/world/simulation/include/SushiEngine/simulation/simulation.hpp) carries four things and nothing else: input, the Input::TickSample the simulation pulled at the head of this step; delta_seconds, the fixed step, so a hook never reaches for a clock; tick_index, steps completed since begin_play(); and driven_entity, the entity the host named as the one being played, or NULL_ENTITY. Comparing your own id against driven_entity is how one behavior type attached to many entities acts only on the controlled one.

Dispatch runs inside RuntimeSimulation::step_once, after the tick sample is pulled and before the physics reconcile, so a transform, a control or a set_has_physics_body you ask for on this tick reaches the solver on this tick rather than the next. It runs once per fixed step, not once per host frame: a frame that consumes three steps calls on_update three times, and a frame that consumes none calls it not at all. It is serial — never on the ECS schedule’s worker pool — so request_instantiate and request_destroy stay safe to call from it.

The engine ships two worked examples. engine/world/simulation/source/behaviors/driven_mover_behavior.cpp is the smallest one: read the input, write the transform. engine/world/simulation/source/behaviors/character_mover_behavior.cpp is the one that asks the world questions — it reaches the physics through IWorldEditor rather than through anything of its own.

Asking the physics world a question

Two services hang off the IWorldEditor every hook receives, declared in engine/world/simulation/include/SushiEngine/simulation/physics_services.hpp:

#include <SushiEngine/simulation/physics_services.hpp>

SushiEngine::Simulation::ICollisionQueryService* queries = world.collision_queries();
if (queries == nullptr)
    return;
SushiEngine::Simulation::SceneQueryFilter filter;
filter.exclude = id;                 // or the ray's first hit is you
filter.include_triggers = false;
const SushiEngine::Simulation::SceneRayHit hit =
    queries->raycast_closest(origin, direction, 10.0, filter);

ICollisionQueryService answers raycast_closest, raycast_all, overlap_sphere, sweep_sphere, sweep_capsule and closest_point. ICharacterService, reached through world.character_service(), resolves one tick of intended movement for a capsule against the world. Both return nullptr when the world has no physics scene, and both are the narrow service rather than the scene that implements them — a hook cannot reach step() through either.

move_character reports moved == false for an entity whose rigid body is missing or is not kinematic. That is an authoring mistake rather than an edge case, and the answer is to honour the report: writing a pose anyway would leave the solver and your hook fighting over it every tick.

Determinism is your half of the bargain. The engine guarantees the three things it can: the same number of calls per step in both hosts, the same TickSample at each call, and the same dispatch order — behavior_entities_ order, which is scene order, not a hash order. That is what makes the editor and the player produce the same world from the same input, which is the whole point of the fixed step.

What the engine cannot guarantee is the body of your hook. Read and mutate the world through the IWorldEditor you are handed, and keep whatever state you like in your own C++ members — a behavior instance is not an ECS component, so it may hold a string, a vector or a timer. Do not read a wall clock, call rand(), branch on a pointer value or a memory address, or iterate an unordered container’s order: each makes your entity end up somewhere different in the two hosts, and nothing in the engine detects it. Do not reach for the renderer, the window, ImGui or the asset library, and do not try to reach ISimulation itself — a hook never receives one, so tick, begin_play and set_driven_entity are out of reach by signature rather than by convention.

A behavior compiled into a test executable needs nothing but SE_REGISTER_BEHAVIOR: the object file is linked directly and its static initializer runs.

A behavior compiled into the engine’s own sushiengine_simulation static library needs one thing more. A linker takes an archive member only when something already in the link references one of its symbols, and nothing references a behavior — the only reference to it is a string typed into a ScriptComponent. The member is dropped, the registration never runs, and BehaviorRegistry::create returns null in a shipped host while every test that constructs the type directly still passes.

So an engine-supplied behavior is three lines rather than one: the SE_REGISTER_BEHAVIOR in its own .cpp, a link_<name>() function beside it, and a call to that function from link_builtin_behaviors() in engine/world/simulation/source/behaviors/builtin_behaviors.cpp, which RuntimeSimulation’s constructor calls once. It fails the way a build should: a link_ function that is declared and called but never defined is an undefined symbol at link time rather than a feature that quietly does nothing. And the anchor is load-bearing rather than defensive decoration — deleting the one link_builtin_behaviors() call turns all three Integration_BuiltinBehaviorRegistration cases red on this toolchain, which is how archive-dropping here was measured rather than assumed.

Sending and receiving messages

A behavior calls another entity’s behaviors by name, without knowing their concrete C++ type. The SpinningLight above already shows the receiving side — its on_message override reacts to a "TurnOff" message by disabling itself:

// Elsewhere, in some other behavior's own hook:
world.send_message(self, target_id, "TurnOff", {});

send_message broadcasts to every behavior the target entity carries — each one either recognizes name and acts, or falls through on_message’s empty default body and does nothing. There is no reflection, so a message name is matched by whatever if/else if chain the receiving behavior’s on_message override writes, the same way a ScriptComponent’s type_name is matched by convention rather than introspection.

send_message only reaches behaviors on a hierarchy-enabled entity — a disabled entity’s behaviors receive nothing, the same “nothing fires” rule the rest of this guide applies to Play state, applied here to enabled/activeInHierarchy instead. That check runs fresh on every behavior a broadcast visits, not once at the start: if an earlier behavior’s on_message disables the target entity (as SpinningLight’s own "TurnOff" handler above does), later behaviors on that same entity are skipped for the rest of that one send_message call — a broadcast can end up partially delivered, not a crash, just fewer receivers than it started with. send_message is synchronous and gated on Play exactly like the four lifecycle hooks above — calling it outside Play is a no-op.

Nothing fires outside Play. Placing a scripted prefab in the Scene view while editing never constructs an instance or calls a hook — only begin_play() (Play) and the calls above, while playing, do. “Play” is not editor-only: the standalone player (applications/player) calls begin_play() once at startup and end_play() once at shutdown, so in a shipped/played game every hook fires for the whole process lifetime, exactly as it does between the editor’s Play and Stop. See docs/design/ENTITY_LIFECYCLE_SYSTEM/README.md §6-§7 for the full design and every firing site’s exact call chain.

Reentrancy. Calling world.destroy(...), world.add_script_component(...), or world.remove_script_component(...) on the same entity currently running one of these hooks — for example, a hook that destroys its own entity, or an on_message handler that removes a script component from the entity currently receiving a broadcast — is safe. Each hook still fires immediately, in the same order it always has; only the underlying container write is deferred until the outermost hook-firing call for that entity finishes, so nothing corrupts a loop still iterating it. world.request_instantiate(...)/world.request_destroy(...) remain fine to call reentrantly too, including from inside on_message — that queue is deferred by Phase 2’s design.