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 theScriptComponentwhen Play started, or becauseadd_script_componentattached it to an already-existing entity during Play. An entity is always created bare — nothing can attach a script before it exists, soadd_script_component(called after) is the only way a fresh behavior comes alive during Play.on_enable— immediately afteron_spawnif the entity is hierarchy-enabled at that moment, and again every time it transitions from hierarchy-disabled to hierarchy-enabled (its ownenabledflag 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: atend_play()(Stop), when the entity is destroyed (request_destroyor the synchronous authoring API), or when itsScriptComponentis removed (remove_script_component) — preceded byon_disableif 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.
Where a behavior lives, and why it needs a link_ function
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.

