Structuring a visionOS app or game with the ImmersiveTesting architecture
Most RealityKit code ends up tangled inside one RealityView closure. ImmersiveTesting is a layered scaffold that keeps scene construction, game logic, and platform services in separate, swappable places.
Most immersive visionOS code starts in the same place: one RealityView closure that loads entities, wires ARKit, runs game logic, and mutates state. It works for a demo. It stops working the moment the app grows, because everything is coupled to everything else and there is no seam to change one part without touching the rest.
ImmersiveTesting is a Swift package that gives an immersive app a spine. It is a layered architecture plus a small set of services that keep spatial 3D code clean instead of tangled into a closure. This post is about that architecture and why it holds up as an app or game grows.
Three layers, one responsibility each
The framework splits an immersive app into three layers, and the rule is that each concern lives in exactly one of them.
- SwiftUI shell. The
ImmersiveViewstays thin. It wires the environment and calls the scene builder. No game logic, no entity construction. - Scene layer. The
SceneBuilderconstructs the entity graph, and ECS systems drive behavior through staticstepmethods. - Services layer. Provider protocols hide ARKit,
.sharedsingletons, and other platform calls behind interfaces you inject.
The payoff is that when something breaks or changes, you know which layer to open. Locomotion is a system. A new entity is the builder. A hand-tracking call is a provider. Nothing forces you back into a 400-line closure to find it.
The scene builder is a pure function
The SceneBuilder takes a configuration and a SceneEnvironment and returns a constructed entity. It does not reach for globals and it does not read hidden state. Same inputs, same scene.
struct GameImmersiveView: View {
@StateObject private var viewModel = GameViewModel()
var body: some View {
RealityView { content in
let env = CompositeSceneEnvironment(
worldTracking: LiveWorldTracking(),
sceneEffects: LiveSceneEffects()
)
let scene = GameSceneBuilder().makeScene(viewModel.config, env: env)
content.add(scene.root)
viewModel.sceneRoot = scene.root
}
}
}Because construction is a function of config, you can build variants of a scene (difficulty, level layout, spawn counts) by passing different configuration, not by branching inside the view.
Game logic lives in ECS systems
Per-frame behavior goes into systems that expose static step methods. A system reads the environment and the entities it cares about, then mutates them. It does not own the world and it does not know about SwiftUI.
static func step(entities: [Entity], dt: Float, env: any SceneEnvironment) {
let target = env.worldTracking.devicePosition()
for npc in entities {
guard var ai = npc.components[NPCAIComponent.self] else { continue }
npc.position += normalize(target - npc.position) * ai.speed * dt
}
}This keeps behavior composable. Each system is one file doing one thing, and you add a behavior by adding a system rather than by threading another branch through existing code. It also maps cleanly onto how RealityKit already wants per-frame work structured.
Tip: static
stepmethods make a system trivial to reason about. There is no instance state hiding between frames, so what you read is what runs.
Platform calls hide behind provider protocols
Everything platform-specific goes behind a protocol. The device pose, scene effects, hand tracking, and even randomness each have a -Providing interface, and the SceneEnvironment carries the concrete implementations.
WorldTrackingProvidingfor device poseSceneEffectsProvidingfor scene effectsHandTrackingProvidingfor hand inputRandomProvidingfor randomization
In the app you inject the live adapters (LiveWorldTracking, LiveSceneEffects). The value is that your systems and builder depend on interfaces, not on ARKit or singletons directly. When Apple changes an API or you want to swap an implementation, the change is contained to one adapter instead of scattered across the scene.
Determinism is built in
RandomProviding is backed by SeededRandom, so procedural content is reproducible. Give the environment a seed and the scene lays out the same way every time.
let env = CompositeSceneEnvironment(random: SeededRandom(seed: 42))
let scene = GameSceneBuilder().makeScene(config, env: env)For a game this matters more than it looks. Reproducible spawns mean a bug someone reports is a bug you can recreate exactly. A daily-challenge or shared-seed mode becomes a seed value rather than a new subsystem. Procedural layout stops being something you can only observe and becomes something you can pin down.
Real physics, not hand-rolled math
The architecture leans on RealityKit's actual physics engine (gravity, contacts, collisions) rather than approximating motion by hand. Collisions are expressed through real CollisionComponent group and mask contracts, so what governs the scene is the same simulation shipping in the app. Fewer bespoke movement equations means fewer places for spatial math to drift out of sync with what the user sees.
Why this holds up
The through-line is separation with clean seams. Scene construction, behavior, and platform services each occupy one swappable location, and each depends on interfaces rather than on the layer beneath it. That is what lets an immersive app grow past the demo stage: you can add a system, swap an adapter, or reconfigure a scene without pulling the whole thing apart.
Requirements
Swift 6, Xcode 16 or newer, targeting visionOS 2 (with macOS 15 and iOS 18 support). The runtime library is linkable from app targets directly.
Next steps
The framework is on GitHub at ImmersiveTesting. Start by moving one RealityView closure into a SceneBuilder and a single system, then push platform calls behind a provider. The layering pays off incrementally.