Creating a Physics Backend Plugin
A physics backend replaces the engine's numerical simulation for entities that opt in, while everything else — components, events, queries, the frame loop — stays engine-owned. This guide covers everything needed to implement one in an external Swift package, without reading engine source. The engine keeps zero native dependencies: heavyweight physics libraries (Jolt, PhysX, …) live in your plugin package, never in core.
For the general plugin philosophy (namespacing, licensing, review expectations) read Plugin Authoring Guidelines first — notably: optional native libraries and binary frameworks belong in the plugin repository, and dependency licenses should be MIT/BSD/Zlib/Apache-2.0.
The moving parts
| Engine type | Role |
|---|---|
PhysicsBackend |
The protocol your simulation implements |
PhysicsBackendPlugin + PhysicsBackendPluginManifest |
Identity, versioning, validation |
PhysicsBackendRegistry |
Single-slot install/uninstall with rollback |
ColliderComponent, RigidBodyComponent |
Engine-owned ECS vocabulary — never define your own |
PhysicsCoordinator |
Engine-side driver; you never call it directly |
PhysicsEventSink |
Where your buffered events go each substep |
PhysicsQuery.raycast |
Routed to your backend when you report .raycast |
Minimal skeleton
import UntoldEngine
final class MyBackend: PhysicsBackend {
let id = "com.example.myphysics.backend"
let capabilities: PhysicsCapabilities = [.collisions, .raycast]
func configure(_ config: PhysicsWorldConfiguration) { /* gravity, layers */ }
func didAddBody(entity: EntityID, descriptor: PhysicsBodyDescriptor) { /* create body */ }
func didRemoveBody(entity: EntityID) { /* destroy body */ }
func step(deltaTime: Float) { /* advance simulation one fixed substep */ }
func drainEvents(into sink: any PhysicsEventSink) { /* hand over buffered events */ }
func writeKinematicTargets(_ batch: PhysicsBodyWriteBatch) { /* engine → you */ }
func readActiveTransforms(into batch: PhysicsTransformReadBatch) -> Int { /* you → engine */ }
func raycast(_ ray: PhysicsRay, filter: PhysicsQueryFilter) -> PhysicsRayHit? { /* query */ }
}
struct MyBackendPlugin: PhysicsBackendPlugin {
let manifest = PhysicsBackendPluginManifest(
id: "com.example.myphysics",
displayName: "My Physics",
version: PhysicsBackendVersion(major: 1, minor: 0, patch: 0),
requiredAPIVersion: .current
)
func makeBackend() -> any PhysicsBackend { MyBackend() }
}
Every method except configure and step has a default no-op implementation,
so a minimal backend implements only what it supports. Calls gated on a
capability you don't declare are defined no-ops.
Install before creating the renderer:
switch PhysicsBackendRegistry.shared.install(MyBackendPlugin()) {
case .installed, .replaced: break
case let .rejected(failure): print(failure) // validation errors, conflict, or lock
}
Validation requires a reverse-DNS namespaced plugin ID, a backend ID inside the
plugin's namespace, and an exact requiredAPIVersion match. One external
backend is active at a time: installing under the same ID replaces it, a
different ID while one is installed is rejected. The registry locks on the
first simulated substep — install/uninstall after that are rejected for the
rest of the run.
What the engine does with your backend
Installing schedules the engine's PhysicsCoordinator (an EngineExtension)
into the fixed-timestep loop; uninstalling removes it. Once per fixed substep,
after the built-in integrator, the coordinator:
- Diffs the body set. Entities carrying
RigidBodyComponent+ColliderComponent+LocalTransformComponentare yours. New ones arrive viadidAddBodywith aPhysicsBodyDescriptorsnapshot (shape, mass, layer/mask, gravity scale, initial pose and velocities); entities that lost those components or were destroyed arrive viadidRemoveBody. - Writes kinematic targets — one
PhysicsBodyWriteBatchwith parallel entity/transform buffers for every kinematic body. - Calls
step(deltaTime:). - Reads transforms back — you fill the
PhysicsTransformReadBatchwith your active bodies (sleeping bodies can be skipped) and return the count. The engine applies them to dynamic bodies'LocalTransformComponentand marks the scene graph dirty. - Drains events via
drainEvents(into:).
The built-in integrator keeps running for legacy PhysicsComponents/
KineticComponent entities regardless — both can coexist in one scene, and
your backend never sees them.
Contracts to honor
- Threading. Every protocol method is called on the engine's frame thread.
Parallelize internally all you like, but callbacks from your worker threads
must never reach the engine: buffer events into fixed-capacity storage during
stepand hand them over only indrainEvents. Report overflow throughreportDroppedEvents(count:)— never allocate or throw mid-step. - Batch-only transforms. Transform exchange happens through the two batch calls, one contiguous buffer per direction per substep. Never per-body calls. The buffers are valid only for the duration of the call.
- Units. Metres/kilograms/seconds, Y-up, quaternion orientations — matching
PhysicsWorldConfiguration, whosecollisionLayerMatrix[layer]is the bit mask of layers thatlayercollides with (empty = everything collides).
Events
Deliver PhysicsContactEvent (began/persisted/ended),
PhysicsTriggerEvent (entered/exited) and PhysicsBodyActivationEvent to the
sink during drainEvents. The engine fans them out to PhysicsEvents
subscribers and fires USC script events (OnCollision, OnTriggerEnter,
OnTriggerExit) — you only produce the events; delivery order is your
delivery order.
Queries
Declare .raycast and PhysicsQuery.raycast(_:filter:) routes to your
raycast implementation — your answer (including a miss) is authoritative.
Like every other backend method, raycast is called on the frame thread, and
PhysicsQuery.raycast itself must only be called from there (game update
code, engine extensions, USC actions — not from your worker threads or
arbitrary dispatch queues).
Without the capability, the engine answers from its octree of entity bounds
instead. Honor PhysicsQueryFilter: skip excludedEntities, and test
layerMask against each body's layer. Shapecast and overlap capability bits
exist but are not yet exposed through PhysicsQuery; declaring them today is
harmless and future-proof.
Testing without an engine run
The engine's own suites (PhysicsBackendRegistryTests,
PhysicsCoordinatorTests, PhysicsEventsTests, PhysicsQueryTests) exercise
every seam above with mock backends — they double as reference implementations
for the protocol's expected behavior. A useful pattern for your package's
tests: install your plugin, drive PhysicsCoordinator.shared.fixedUpdate
manually, and assert on your backend's recorded calls, exactly as those suites
do.