![]() |
vkmEngine 1.0.0
A C++ game engine · vkmengine.com
|
Native C++ gameplay logic. A Behavior is the engine's MonoBehaviour / ActorComponent analogue: subclass it, override lifecycle hooks, and attach instances to an entity through a ScriptComponent. BehaviorSystem drives the hooks during play; behaviors live in a separate gameplay module that the editor can hot-reload without restarting.
BehaviorSystem runs in SystemStage::Simulation, before AnimationSystem and PhysicsSystem, so a behavior can set state the same frame those integrate it (events -> gameplay -> animation -> physics). It opts into fixedUpdate.
A behavior is a C++ class in your project's src/, built into the project's module by vkm build. One include brings in what a behavior commonly reaches - the base class, the reflect block, the body components, input, the physics and audio events, the asset graph and the log: system/script/behavior_api.h.
The class and its fields. Derive from ReflectedBehavior<YourClass>, put the values a designer tunes in a public block, and list them in a reflect block below the class - one VKM_F per field, no commas. The class name is the behavior's name: it is what a scene file stores and what the editor's Add Behavior menu lists.
A field the block does not list is runtime state: not saved, not shown, not copied. A behavior with nothing to tune still writes the block, empty. The inspector labels a field in words - pushStrength shows as "Push Strength" - and edits a glm::vec3 or glm::vec4 whose name ends in color or colour with a colour picker.
Registering it. The module names its behaviors once, in src/module.cpp:
A behavior left out of that list cannot be loaded from a scene: it is held as text, and the log says so.
Attaching it. In the editor, select an entity and pick the behavior from Add Behavior; its fields appear on the card. In code - a vkmBuildScene, or a hook spawning something - addBehavior gives the entity its ScriptComponent if it has none and returns the new behavior:
Added during play it starts on the next simulation tick, as a loaded one does.
Reading input. Name an action once, in onStart, and ask for it by name (input.md has the rest - axes, and why a fixed update reads command() instead):
Collisions. A body is a Collider and a Rigidbody together. A Collider without a Rigidbody is in no broadphase at all: it touches nothing and no hook hears it. Static ground takes a Rigidbody whose motion is RigidbodyMotion::Static; something a behavior moves by hand takes Kinematic. onCollisionEnter, onCollisionStay and onCollisionExit are handed a Collision: the entity on the other side, a contact point, and a normal that points from the other entity into this one - so a crate landing on the floor hears a normal pointing up. On exit the two touch nowhere, and the point and normal are zero. A trigger collider calls onTriggerEnter / Stay / Exit with the entity that entered instead.
A sound, in one line. A one-shot that needs no entity of its own - an impact, a pickup - is an event:
resources().find(thud) resolved the authored reference to a handle in onStart. A sound that must follow something, loop or stop is an AudioSource instead (audio.md).
Another behavior. findBehavior<T>(entity) answers the T on an entity, or null; findBehavior<T>() asks this entity. For the behaviors a game has one of - a director, a menu - findEntityWithBehavior<T>(scene()) finds the entity carrying it:
Ask each time rather than keeping the pointer: the other entity can be destroyed between two hooks.
Listening for an event. subscribe reads the event type off the lambda, and drops the listener when the behavior goes:
What remains is when each hook runs, which Time and pause states in full - read it before writing a second behavior.
The five at the top are the commonest lines in any behavior, which is why they exist: a behavior asking about its own entity should not have to name it. Reach for tryGet<T>() by default -
one lookup, one mention of the entity, and the null check is the "does it have one" question already answered. Writing it as has<T>() and then get<T>() looks the component up twice and names the entity twice, so a later edit can change one and not the other. get<T>() is for the components the entity cannot meaningfully run without - the ones its own onStart added.
They are shorthand for scene() calls with the entity filled in, so the same four exist there for reaching another entity, and Scene::tryGet is total: scene().tryGet<Transform>(findActiveCamera(scene())) is a line you can write, because every find in the engine answers with a null id when there is nothing to find.
Those eight accessors are the whole engine surface a behavior reaches, and they are named reads of one BehaviorContext the BehaviorSystem owns and binds before onStart(). Gameplay never holds that struct. Growing the surface is a field on BehaviorContext and an accessor beside these.
The four below them are questions rather than capabilities - they read the session through net() and answer for this entity. All four answer the single-player way offline (isSimulated() and isMine() yes, isReplaying() no, command() the local player's), so a project that never opens a session is written the same as one that does. Time and pause below says when to ask each, and networking.md says why.
window() is the one that hands back a pointer, and that is the nullability made visible - a dedicated server runs the same behaviors with no window at all, so anything reading the cursor or the framebuffer size checks first.
Behavior is non-copyable and non-movable - instances are owned by unique_ptr inside the ScriptComponent. BehaviorSystem binds its session-stable BehaviorContext (bindContext) before onStart(), and because that context outlives every frame (unlike FrameContext), the accessors are safe from a subscribe() callback as well as from inside a hook - and none of them is worth caching in a member.
Which hook runs when, and what its dt means, is Time and pause below - read it before writing the first one.
Most behaviors derive from ReflectedBehavior<Derived> (CRTP) instead of Behavior directly. Declare the tunable fields once with the VKM_REFLECT markup and typeName(), visitFields(), and clone() are all generated from them; you only override the lifecycle hooks. typeName() is the class name the block was opened with, without its namespace (Reflect::Traits<T>::NAME), so the block is required even for a behavior with no fields - empty, it compiles, and missing, the compile error says what to write. The reflected fields are the single source of authoring state - they drive the inspector, serialization, and duplication uniformly.
In your own namespace, never in Vkm::Engine. That namespace is the engine's, and a project putting types in it can collide with an engine type added later, or with a second project loaded into the same editor - silently, because BehaviorRegistry keys on the class name rather than on the C++ type.
The one line that makes it cost nothing is the using namespace Vkm::Engine; above: written once inside your namespace, the engine's vocabulary is reachable unqualified from every declaration in the file, and the leak stops at your own namespace. The three example projects are Potion, Arena and Lab, and each does exactly this.
Module entry points are the exception, and they have no choice: VKM_MODULE_ENTRY makes them extern "C" at global scope, so they either qualify or open with a function-scope using namespace Vkm::Engine;. examples/physics_lab/src/module.cpp does the latter.
BehaviorFieldVisitor is the type-erased bridge that lets code holding only a Behavior* (the inspector, the serializer) read/write a concrete behavior's fields without knowing its type. The leaf types it supports are float, int, bool, std::string, glm::vec2, glm::vec3, glm::vec4 and glm::quat, plus any VKM_ENUM_NAMES enum, any AssetRef<Asset>, and any VKM_REFLECT-ed struct (descended into). Reflecting anything else is a compile error that lists these; a value of another type is runtime state, left out of the block.
The inspector shows a field's name in words (fieldLabel: degreesPerSecond is "Degrees Per Second"); the name itself stays the key the field is saved under. A glm::vec3 or glm::vec4 whose name ends in color or colour, in any case, edits as a colour (namesAColor) - the name is all a field carries, so it is the rule. A glm::quat edits as Euler angles in degrees and saves as the quaternion.
A std::string field is free text - a label, a tag, a bone name. It serializes as a JSON string and edits as a text box. Like every leaf it keeps its current value when the key is missing, and keeps it with a warning when the key holds the wrong type, so one bad value does not cost the whole load.
A field that points at an asset declares an AssetRef<Asset> (resource/asset_ref.h) - never a bare string:
The type argument is what makes it work, and std::string step would not. A scene's assets block is built by walking what the scene references, and a name that block never lists is a name the loader never recreates - so findByName would answer null and the sound would never play. AssetRef carries the asset kind through BehaviorFieldVisitor::assetField, which is what lets that walk list the name in the right section. Prefabs get the same treatment: a prefab writes its own assets block from the same walk, so an instance brings the clip with it.
It holds a name because a name is the engine's serializable identity for an asset, while a Handle<T> names a slot in one session's ResourceManager. Resolving is the behavior's own job, once, as above - ResourceManager::find takes the reference and answers its handle; nothing resolves it for you. An empty name means "none".
In the inspector the field is a combo over what the project's asset library holds of that kind, plus (none). That is deliberately the library and not what is currently loaded: picking a name is what pulls the asset into the scene's assets block on the next save. A stored name the library does not have - an asset renamed or deleted under the field, or a scene from another project - is flagged under the combo rather than left to fail at load. Nothing rewrites the name for you: it is authored text, not a handle the editor can follow.
Only asset kinds with an ASSET_TYPE (resource/asset_type.h) can be referenced; AssetRef<FontAsset> is a compile error, because the library holds no fonts and the assets block has no section to put one in.
The one ECS component that is not a plain aggregate: it owns unique_ptrs, so it is move-only (the documented exception in the code-style guide). SparseSet stores it through its std::move path; per-behavior deep copy for entity duplication goes through Behavior::clone(). See ecs.md.
A behavior's authored values - the asset references included - are the prefab's, identically, on every instance of it: edit them in the prefab, not in an instance. The inspector says so on the card while it is open, and PrefabOverrides::record refuses the component outright - a static_assert, so the refusal is a build error at whatever call site tried rather than an empty override list at run time.
The mechanical reason is that the component serializes as a single field holding the whole behavior list, so a per-field delta on it would be the whole list. Making one behavior field overridable is therefore not a change to the reference encoding; it is an override address that reaches inside a list - (uid, component, behavior index or type, field) - which the prefab format does not have. The case that would want one (this barrel, that door) also wants an authored entity reference, which the format does not have either.
The loader enforces it, so the rule is the format's and not just the editor's: a hand-written "Script" override is reported as drift and not applied, the way one on the root's Transform is. Both are addresses a file could carry and must not take effect - the Transform because it could never work, this one because it would, wholesale and invisibly, leaving content authored against an address the format has not decided yet.
Three tick hooks, two timelines. Which timeline a hook is on is legible in the hook you are writing, not in a flag set somewhere else - so this section is the whole contract, and a behavior never has to read BehaviorSystem to learn what dt means.
| Hook | Timeline | Runs | dt is |
|---|---|---|---|
| onUpdate(dt) | simulation | only on frames where simulation time advanced | getSimDelta(), always > 0 |
| onFixedUpdate(dt) | simulation | once per fixed step, from an accumulator fed by the sim delta | getFixedStep() |
| onRealtimeUpdate(dt) | real | every frame, paused or not | getDeltaTime(), always > 0 |
onFixedUpdate is simulation time too, and needs no gate of its own - the accumulator behind it is filled from the sim delta, so pause and time-scale already reach it. It is also on a different clock from input: actions are sampled once per render frame, and a fixed step runs zero or many times per frame. So a fixed update reads command() - the per-tick InputCommand, built from the axes as they stand plus the edges latched since the previous tick - and never held() / pressed() / axis(), which answer for the frame. command() is the input driving this entity, which offline is the local player's and on a server is the one that entity's player sent; a single-player project can read input().command() for the same thing. Asking the frame queries from a fixed update drops a tap taken between two ticks and repeats a press across every tick of a slow frame.
Two more questions come with it, and a behavior that moves anything asks the first: isSimulated() - does this end decide what happens to this entity - and isMine() - is this the player sitting here. Both answer yes offline, so a single-player behavior is unchanged by their existence. See networking.md.
The engine-wide rule this follows: a system reads the timeline its responsibility lives on, not the timeline of the stage it sits in. Simulation state - animation, particles, physics, gameplay's onUpdate - reads getSimDelta(). Presentation and services - input, camera, the editor, async loading, audio, gameplay's onRealtimeUpdate - run every frame regardless of the sim delta, on the real delta where they need one at all. Which is why pausing does not cut the music: AudioSystem keeps mixing, 3D positions simply stop changing because nothing moved.
Not supported: per-entity or per-layer time scales, a nested pause stack, pausing individual systems, and running behaviors in Edit mode without pressing Play. EventBus::flush is unconditional - a paused game can still receive a UI click, and rule 3 gives it a hook to answer one in.
A process-wide name -> factory registry. Game code registers each behavior type at startup; serialization recreates instances by name.
registerBehavior<T>() keys off Reflect::Traits<T>::NAME, the same string typeName() returns - one source of truth shared by registration, serialization, and the editor's add-behavior menu (names()). registerBehaviors<A, B, C>() is that call for each type in turn. clear() drops every factory before the game module is unloaded on hot-reload, since the factories close over module code.
The engine ships no gameplay of its own: the project brings its code. Each project builds its sources into game.dll / libgame.so in its own bin/, and every host loads it the same way through ScriptModule - vkm_runtime to play it, vkm_server to serve it, vkm_editor to edit it. There is no static-linked variant and no editor-only path; the shipped game and the edited game run the same binary.
The host dlopens the module and calls the entry points it finds. All four are declared in system/script/module_entry.h, which a module includes and marks each definition with VKM_MODULE_ENTRY:
| Entry | Signature | Required? | Purpose |
|---|---|---|---|
| vkmModuleEngineVersion | const char* () | Yes | Reports the engine the module was built against; the host refuses a mismatch |
| vkmRegisterBehaviors | void () | Yes | Registers the project's behavior types into the engine's BehaviorRegistry |
| vkmBuildScene | void (Scene&, ResourceManager&) | Optional | Builds the project's world in code. Projects whose scene is generated rather than authored use this instead of entryScene. It gets the asset graph as well as the scene, because a world made in code needs meshes and materials the same way an authored one does |
| vkmSetupNetwork | void (NetSession&) | Optional | Says what a joining player is given; a project without it runs offline |
The signatures matter and the loader cannot check them. These have C linkage, so there is no mangling for the linker to disagree about: the host looks the symbol up by name, reinterpret_casts it to the type above and calls it. A module that declares an extra parameter would compile, link and load, and read whatever the calling convention left in that register. Including module_entry.h is what makes that a compile error instead - the declarations there are the same ones the table names, so a definition that disagrees does not build.
VKM_MODULE_ENTRY also carries the Windows half. An entry has to leave the DLL under its own name, and MSVC exports nothing by default; written out by hand at every entry, that __declspec is the half a project forgets, and one that wrote only the extern "C" would build a library whose symbols the host cannot find, on one platform, at run time.
The version guard is an ABI guard. The engine ships prebuilt libraries and is not ABI-stable between versions: struct layouts, inline functions and templates are all free to change, which is what lets them keep improving. A module built against a different version therefore disagrees with the host about memory that both of them read and write, and the symptom is a crash somewhere unrelated rather than a load failure - so ScriptModule refuses the load and says which version to rebuild against. A module reporting no version at all is refused on the same terms rather than assumed compatible.
The module links vkm_core - vkm_add_gameplay_module() does it for every project - and that is not a second copy of the engine. vkm_core is a shared library, so the module and the host reach the same one, with its single typeId registry and its single set of singletons. A static engine could only manage that by making the module resolve its symbols from whichever host loaded it, which is why the engine is shared instead (see Building). What a module must not link is the static archives absorbed into vkm_core; the helper links none of them.
So everything vkm_core carries is available to gameplay code, logger.h included: a behavior logs with the engine's LOG_* macros and its lines land in the project's log file beside the engine's own, rather than only on a console nobody keeps. Name the file's lines with a category, exactly as engine code does:
All three example projects do this.
The module is looked for in the open project's bin/ and nowhere else: a game brings its code with it, and that is the one place a project builds it.
ScriptModule loads a copy of it (libgame.loaded.<n>.so, game.loaded.<n>.dll), so a rebuild is free to overwrite the original while the editor still holds it - which is what vkm build does mid-session, and what Windows would otherwise refuse outright. A directory that refuses writes loads the original in place: an installed game's bin/ is read-only, and nothing rebuilds into one of those, so the copy has nothing left to buy there. Any other failure to copy refuses the load, because loading in place would lock the very file the next build writes.
ScriptModule::reload(scene, behaviors, events) swaps in a freshly built module without restarting. It first opens the new build beside the running one and checks it - that it is a library, reports this engine's version and has vkmRegisterBehaviors - and a build that fails any of that is refused, with the running module and its behaviors left exactly as they were. Only then does it serialize each entity's behaviors (type + reflected fields), destroy them, unload the old module, register the new one, and recreate the behaviors from the saved type + fields. It rebuilds only the behavior C++ objects, and they start fresh (onStart runs again) - so the scene it is handed must hold nothing else of the old module's; see below.
The editor notices the rebuild itself. It stats the built module once a second - the same interval as the poll that reloads changed shaders - and when vkm build rewrites it, reloads on its own and says so - once the new write time has held for a whole poll, because a linker writes the file in several passes and a reload between two of them would open half a library. That includes a module that failed to load, which keeps its path for exactly this, and a project opened before its first build: the editor tells the ScriptModule where the module will be (expect) rather than leaving it with no path, so the build that creates it is seen as a change and a reload loads it. With nothing watching the file, a rebuild would leave the editor running the code it started with until somebody remembered File > Reload Scripts, and the symptom of forgetting is a change that simply does not happen, which reads as the change being wrong.
It is not symmetrical with the shader poll, deliberately. A shader reload is free; this one restarts the play session. So the poll reloads on its own only while nothing is playing - the alt-tab-and-build case - and during a session it only says that a rebuild is waiting. A reload mid-session is sanctioned below because somebody asked for it; a background build finishing is not somebody asking, and it would discard their session's edits without their having pressed anything.
Reloading during a play session is allowed, and is the point - it is how a change is seen without replaying up to it. It restarts the session: the editor stops it (putting the authored scene back, exactly as the Stop button does), swaps the module, and plays again on the new code. Edits made during the session are discarded, and the toast says so, on the same terms as Stop.
It restarts rather than swapping code under a running game because onStart runs again on every behavior, and a game builds its world there. Left running, the second onStart would build a second world beside the first, with two of every canvas drawn over each other.
Building assets in onStart is fine and needs no guard: resources().add is a declaration of identity, so adding potion:rig again replaces what stands under that name rather than making a potion:rig (2) beside it. See Resources.
Nothing may hold module code across the swap. The behavior factories, the wire schema's thunks, the session's spawn callbacks and the buses of the module's own event types all live inside the library being unmapped, and ScriptModule::releaseRegistrations drops all four before the dlclose. A behavior's subscribe() is dropped for it by BehaviorSystem::endSession (see Events), and it is the only subscribe a behavior can reach: events() is an EventSender, which emits and enqueues and cannot subscribe.
A component set is module code too. Scene holds one SparseSet<T> per component type, made by whichever binary first adds a T, and its vtable lives in that binary - so a set the module made outlives the module unless the scene lets go of it, and the next destroyEntity calls through it. On Windows that is every set a vkmBuildScene world or an onStart fills first, since nothing interposes the engine's copy; on every platform it is a component type only the module declares. Three things keep it from happening:
A sound's samples are the same hazard one level down. AudioClipAsset::samples is shared between the clip and its voices, and a shared buffer's control block carries the code that frees it, so one a module built would be freed by the module - by whichever voice or scene load drops the last reference, which can be after a reload has unmapped it. So the field is a ClipSamples (resource/asset/audio_clip_asset.h), whose only filling constructor is defined in vkm_core: there is no way for a module to build the buffer itself. potion_runner's generated sounds are the example. The editor also stops every voice before it unmaps a module, on a reload and on a project switch.
A reload loads a fresh copy of the module beside the old one, and on both platforms the fresh copy's statics start over. On Linux that rests on one flag: GCC gives every static inside an inline function or a template, and every inline or template static data member, an STB_GNU_UNIQUE symbol, which the dynamic linker binds across every copy of a library whatever the dlopen flags, and glibc never unmaps a library that defines one. So a module is compiled with -fno-gnu-unique - vkm_gameplay_module_options in cmake/gameplay_module.cmake applies it to every module, the engine's test module included - and dlclose unmaps the old copy. The exception is a build with the profiler: Tracy replaces dlclose with a no-op so the zone names a module recorded stay readable, and there each reload leaves the previous copy mapped until exit. Its statics are still its own.
So a module's statics do not survive a reload. State a behavior needs across one belongs in its reflected fields, which the reload carries; anything else belongs in a component or an asset, never in a static.
A reload that fails goes through reportError, not LOG_ERROR. A build that will not open is refused before anything is torn down, so the module already running stays, its behaviors running, and the Errors panel names the build that was refused - the log line beside it says why. Past that check the swap cannot fail. A project whose module never loaded has its behaviors held as text: nothing is lost, including on save, and the reload that loads a working build turns them back into behaviors. Either case is a named entry in the Errors panel, the way an unresolved asset reference is.
Everything above is what a project writes. What follows is how the engine answers it, for whoever maintains that half.
Drives the lifecycle of every entity's ScriptComponent behaviors. One update does, in this order:
fixedUpdate ticks onFixedUpdate(getFixedStep()), starts instances too, and drains the deferred destroy() requests after each tick (loadScene() waits for update); its accumulator is fed from the sim delta, so it needs no pause gate. Time and pause is the contract those two paragraphs implement.
Every hook runs under a catch net: a behavior that throws is reported via reportError() (logged, and captured by the editor-owned EngineErrorLog) and disabled, never fatal. A listener registered through subscribe() runs under the same net and is reported, not disabled: it reaches the behavior through the bus, and one bad event is not a broken behavior. onDestroy fires on entity deletion (wired through Scene::addObserver / ISceneObserver::onEntityDestroyed in init, dropped again in shutdown) and on every path that ends a session through endSession: play stop, a scene the editor opens or replaces, a hot reload, a scene load a behavior asked for, and engine shutdown. It is a member, because the queues are the system's: they are emptied whether or not a behavior is left to reach them through. A host gets the system from setupEngineApp's AppSystems, and hands it to the editor and to ScriptModule::reload.
ScriptComponent is in the scene save/load set (key "Script"). Each behavior is stored as its registered type name plus a properties object holding every reflected field, walked through visitFields - the same walk the inspector reads through, and the serializer a hot reload saves and restores with, so the three cannot drift. On load BehaviorRegistry recreates the instance by name and the reader fills the fields back in, keeping a field's constructed default wherever the file has no value for it. A type the registry does not know is not dropped: it is kept as an UnknownBehavior and written back unread, so a scene saved with the module missing still holds it. See io.md.