PulsarPulsar/ Blog
← Back to blog

Pulsar's Subsystem Architecture: How the Engine Core Ends Up Knowing Nothing

A thorough walkthrough of how Pulsar's subsystem and component architecture achieves genuine decoupling — components only import the subsystems they touch, the central engine knows nothing about any of them, and the whole system is open to extension by plugins without modifying engine code.

··15 min read

When the Engine Knows Too Much

Most game engines end up with a central god-object: an Engine or World struct that holds references to the renderer, the physics simulation, the audio system, the network layer, and anything else that has ever been needed by any feature. This works fine until the coupling becomes a real problem — when you want to run a headless simulation without the renderer, strip the audio system for a dedicated server build, or let a third-party plugin add a subsystem that the engine code has never heard of. At that point you discover that your central object has tentacles into everything and untangling it is a multi-week project.

Pulsar was designed from the beginning to avoid this. The central engine object — EngineBackend — holds nothing but a SubsystemRegistry and a PluginComponentRegistry. It does not know what a renderer is. It does not know what the physics engine is. It does not know what components exist. All of those things exist in separate crates that depend on engine_backend, not the reverse, and they wire themselves in at startup time through the same injection paths that plugins use. When the sync_component call comes in for a LightComponent, the engine doesn't route it — the component routes itself, reaching into a type-erased context to find and mutate the exact subsystem it knows it needs.

This post explains how that works end to end: from the Subsystem trait and registry, through the ComponentRuntimeBehavior pattern, to the get_subsystem! macro that lets a LightComponent touch the renderer without engine_backend importing helio at all.


The Subsystem Trait

The foundation is in engine_subsystems, a crate with no engine or UI dependencies — just std and thiserror. This is important: because it has no dependencies pointing back into the engine, it can be depended on by both the engine crate and by plugin DLLs without creating cycles.

The Any bound is load-bearing. Without it, a Box<dyn Subsystem> would be opaque — you could call id(), init(), and shutdown(), but you could never get back to the concrete type to call renderer.scene_mut() or physics.add_collider(). With Any, a consumer that knows what it's looking for can downcast:

SubsystemId is a newtype over &'static str — a constant, human-readable identifier for each subsystem. The well-known IDs are defined as constants so nothing has to match strings at runtime:

Plugin subsystems don't need to use these IDs — they can define their own with SubsystemId::new("com.myplugin.my-subsystem"). The string just has to be unique among registered subsystems.


The SubsystemRegistry and Dependency Resolution

The registry stores all subsystems as Box<dyn Subsystem>, fully type-erased:

When init_all is called, the registry does a topological sort of the registered subsystems using Kahn's algorithm. Each subsystem declares its dependencies via fn dependencies(&self) -> Vec<SubsystemId>. The sort produces an initialization order guaranteed to initialize every dependency before the subsystems that depend on it. If a dependency is declared but not registered, or if the dependency graph contains a cycle, initialization fails with a descriptive error rather than silently producing undefined behavior.

Shutdown runs in the reverse of initialization order, which means every subsystem is guaranteed to outlive the subsystems that depended on it. No dangling references, no shutdown ordering bugs.

The registry also supports merging — registry.merge(other) absorbs all subsystems from other into self, silently skipping any ID that already exists. This is the mechanism used to inject plugin subsystems: built-in subsystems are registered first, then plugin subsystems are merged in. Built-in wins.


EngineBackend: Knowing Nothing

EngineBackend holds a SubsystemRegistry and nothing else of significance:

It has no fields named renderer, physics, audio, or anything else. It imports none of those crates. Concretely: engine_backend's Cargo.toml does not list helio, rapier3d, or any subsystem-specific crate as a dependency. The only thing it knows about subsystems is that they implement Subsystem and can be stored, initialized, and shut down.

All subsystems are injected after construction. The call to init() starts with an empty registry:

The ui_core crate, which sits above engine_backend in the dependency graph and does know about specific subsystems, calls inject_plugin_subsystems with the list of built-in and plugin-provided subsystems after loading is complete:

Each subsystem is initialized individually as it arrives. This means subsystems from a plugin DLL are initialized with the same call sequence as built-in subsystems, and the registry doesn't care which is which.


ComponentRuntimeBehavior: Self-Contained Component Logic

The bridge between components and subsystems is the ComponentRuntimeBehavior trait in pulsar_reflection:

And the context it receives:

Subsystems here is the per-sync-pass type-erased map of registered subsystems — the same concept as SubsystemRegistry but scoped to the component runtime:

This map is populated before the sync pass by the concrete context implementation (which lives in engine_backend and does know about specific subsystems). The component's sync_component function sees only &mut dyn ComponentRuntimeContext — it has no idea which concrete context it's running inside, which means the same component code runs identically in the editor's live preview, in a standalone game build, and in a headless simulation.


get_subsystem!: The Ergonomic Glue

Reaching into context.subsystems_mut().get_mut::<SomeType>() everywhere is verbose and produces a messy error message when the subsystem isn't found. The get_subsystem! macro wraps this into a one-liner that panics with a descriptive message naming the missing type:

In practice this reads like accessing a field. The LightComponent's sync_component implementation is the most direct illustration of the whole pattern:

The crate pulsar_rendering — which contains LightComponent — imports helio directly. It uses the renderer's full API. But it doesn't import engine_backend. It doesn't know that there is a PhysicsEngine or an AudioSystem. It knows exactly one subsystem — Renderer — and it imports exactly one subsystem crate. The #[register_runtime_behavior] attribute emits an inventory::submit! block that registers this sync_component function with the global inventory, making it available to the engine at runtime without any registration table to maintain.

The physics component does the same, but for the physics engine:

pulsar_physics knows nothing about helio. pulsar_rendering knows nothing about rapier3d. Both know exactly the subsystems they need and nothing more.


How StaticMeshComponent Touches Multiple Subsystems

The most realistic component is StaticMeshComponent. A mesh component needs to load mesh geometry (potentially hitting disk), upload it to the GPU (through the renderer), cache the result (to avoid re-loading on every frame), and track which scene objects are alive (for stale-entry cleanup). That requires four separate subsystems: Renderer, MeshCache, SceneObjectCache, and LiveKeySet.

The key discipline here is that each get_subsystem! call must be in its own scope. get_subsystem! calls subsystems_mut(), which takes a mutable borrow on the context. Two simultaneous borrows would violate the borrow checker. The solution is to extract what you need from each subsystem into a local variable before accessing the next one. The borrow ends when the block ends. This is not a workaround — it's the borrow checker correctly enforcing that you can't simultaneously hold a mutable reference to the mesh cache and the renderer, which would be genuinely problematic if both were behind the same lock.

MeshCache, SceneObjectCache, and LiveKeySet are themselves simple types — HashMap wrappers, essentially — that happen to be registered as subsystems so they can be shared between the context and the component without passing them as explicit parameters. The context registers them before the sync pass starts; the component retrieves them by type during the sync pass.


Plugin Subsystems

Plugins participate in this system on equal footing with built-in subsystems. A plugin DLL that wants to add a custom audio subsystem implements the Subsystem trait from engine_subsystems:

The plugin's EditorPlugin::subsystems() method returns this as a Vec<Box<dyn Subsystem>>. At engine startup, PluginManager collects the subsystems from all loaded plugins and passes them to EngineBackend::inject_plugin_subsystems. They are registered, dependency-sorted, and initialized through the same path as built-in subsystems. The on_frame method gets called every game tick in init order.

A plugin component can then reach this subsystem through get_subsystem! just like a built-in component reaches the renderer:

The engine doesn't need to know about MyAudioSubsystem. The component crate and the subsystem crate can be in the same plugin DLL or in different ones. The context carries them by TypeId, and get_subsystem! retrieves by TypeId. As long as the plugin is loaded before the sync pass starts, it works.


Plugin Components

Plugin components go through a parallel registration path via PluginComponentRegistry. A plugin component is anything that implements EngineClass (giving it the full reflection system: properties panel, serialization, blueprint methods) and also implements ComponentRuntimeBehavior (giving it runtime sync behavior). The plugin's EditorPlugin::components() method returns a list of (class_name, factory) pairs:

These factories are registered in PluginComponentRegistry. When the editor opens a scene and encounters a component whose class name matches a plugin component, it creates an instance via create_instance(name) and that instance provides full get_properties() metadata for the panel, full serialization for the scene file, and calls sync_component during the runtime pass to push state to the plugin subsystem.

What this means architecturally is that you can build an entirely self-contained plugin that:

  1. Defines a subsystem (an audio engine, a procedural terrain system, a network voice layer)
  2. Defines components that use that subsystem via get_subsystem!
  3. Defines custom property editor types for those components' fields via pulsar_type!
  4. Ships as a single DLL

The engine loads the DLL. The subsystem is injected. The component class appears in the Add Component menu. The properties panel shows the component's reflected properties with custom editors. The scene file serializes and deserializes the component data. The runtime sync pass calls the component's behavior and it reaches the subsystem. The editor knows nothing about any of this beyond the EditorPlugin interface.


The ScenePropsProjector Side Channel

There is one more integration point between components and the engine worth mentioning: ScenePropsProjector. Some scene-level data — like the mesh path for a StaticMeshComponent — needs to be visible to systems that process the scene as a whole rather than to the renderer specifically. For instance, the scene snapshot that gets passed to the Helio renderer needs a unified props map per object that aggregates contributions from all attached components.

A component that implements this trait — and marks itself with #[register_scene_props_applier] — contributes to the per-scene-object props map during the snapshot pass. StaticMeshComponent uses this to surface the mesh_asset path:

The scene snapshot system calls apply_scene_props_for_class(class_name, &mut props, component_data), which iterates the inventory of registered ScenePropsApplierRegistration entries to find the right handler. Again, no central list. The component registers itself at startup, and the scene system dispatches to it at runtime by class name.


The Dependency Graph in Practice

To make concrete what "the engine knows nothing" actually means at the crate level, here's a simplified view of which crates import which:

  • engine_subsystems — no engine deps, just std and thiserror. Defines Subsystem, SubsystemRegistry, SubsystemId.
  • pulsar_reflection — no renderer or physics deps. Defines EngineClass, ComponentRuntimeBehavior, Subsystems, get_subsystem!.
  • engine_backend — depends on engine_subsystems and pulsar_reflection. Defines EngineBackend with an empty SubsystemRegistry. Does NOT depend on helio, rapier3d, or any component crate.
  • pulsar_rendering — depends on helio and pulsar_reflection. Defines rendering components. Does NOT depend on engine_backend or rapier3d.
  • pulsar_physics — depends on rapier3d and pulsar_reflection. Defines physics components. Does NOT depend on engine_backend or helio.
  • ui_core — the application root, depends on everything. Responsible for constructing the subsystems, injecting them into EngineBackend, and wiring up the sync passes.

The knowledge boundary is enforced by the Rust compiler. engine_backend cannot accidentally call renderer.scene_mut() because it doesn't have helio in its dependency tree. If someone tries to add that import, the build fails. The architecture is not just a convention — it's a compiler-checked invariant.


The Sync Pass: Putting It All Together

At runtime, a sync pass looks roughly like this. The context is built with the registered subsystems. The scene database is iterated. For each scene object, for each attached component, the engine calls apply_runtime_behavior_for_class(class_name, &owner, index, &data, &mut context). This function iterates the RuntimeBehaviorRegistration entries collected from every loaded crate via inventory::iter and dispatches to the matching sync_component function.

The engine — concretely, the code that calls apply_runtime_behavior_for_class — doesn't know what LightComponent is. It doesn't know what StaticMeshComponent is. It knows there is a function registered under the string "LightComponent" and a function registered under "StaticMeshComponent", and it calls them with the current component data and a context that carries the subsystems. Those functions reach into the context with get_subsystem!, pull out the concrete subsystem they need by type, and do their work. The engine is a dispatch table and a context carrier. What happens inside each sync_component is entirely the business of the component and the subsystem it was written to use.

The result is that every new component type, every new subsystem, and every new plugin is self-contained and additive. Nothing in the engine is modified. Nothing in the editor is modified. The inventory collects the new entries at startup, the registry accepts the new injection, and the sync pass dispatches correctly on the first run. The engine truly ends up knowing nothing — and that's exactly the point.