Dark Spaces – Devlog 008
Replacing the debug navigation with a router, starting work on the plane and cube scenes.
Work on structure and navigation
In Devlog 006 I added a way to quickly load/reload Reality Composer Pro scenes. It was a great way to let me view lots of scenes and ideas without a whole lot of code. But it is time to replace that with something more flexible. Many of these scenes will require a lot of custom code, systems, components. That code may not be used elsewhere. I decided to break each scene into a SwiftUI file, then load them as a router.
I based this on the work I did when setting up the Step Into Vision labs project.
Here is a short breakdown of how it works.
The app loads a main window with a Directory view as the root. This view gets a model with data for each scene. I can include those objects when opening an Immersive Space, and pass them on to the router view.
struct DarkSpacesApp: App {
@State private var appModel = AppModel()
@State private var modelData = ModelData()
var body: some Scene {
// Main window
WindowGroup {
Directory()
.environment(appModel)
.environment(modelData)
}
.defaultSize(CGSize(width: 800, height: 532))
// We use a single shared space with a router view at the root
ImmersiveSpace(id: "RouterSpaceFull", for: DSScene.self, content: { $route in
DSRouter(route: $route)
.environment(appModel)
})
.immersionStyle(selection: .full, in: .full)
}
}The router view will check the object, and will open the corresponding SwiftUI file. This uses ViewBuilder to render one of these many views based on the switch statement. The downside is that I have to write out the name of each SwiftUI file here. There is no way to do this programmatically from within the app.
struct DSRouter: View {
@Binding var route: DSScene?
@ViewBuilder
var body: some View {
if let dsScene = route {
switch dsScene.file {
case "Dark 001": Dark001(dsScene: dsScene)
case "Dark 002": Dark002(dsScene: dsScene)
case "Dark 003": Dark003(dsScene: dsScene)
case "Dark 004": Dark004(dsScene: dsScene)
case "Dark 005": Dark005(dsScene: dsScene)
case "Dark 006": Dark006(dsScene: dsScene)
default :
DSRouterWarning(message: "No View could be found for " + (dsScene.file))
}
} else {
DSRouterWarning(message: "No route passed")
}
}
}I also created a new Xcode template for these SwiftUI views. This will set up the view to get me started quickly.
// Dark Spaces
//
// File: Dark006
//
// Name:
//
// Description:
//
// Type:
//
// Created by Joseph Simpson on 3/29/25.
import SwiftUI
import RealityKit
import RealityKitContent
struct Dark006: View {
@State var dsScene: DSScene
var body: some View {
RealityView { content, attachments in
guard let scene = try? await Entity(named: dsScene.sceneName, in: realityKitContentBundle) else { return }
content.add(scene)
} update: { content, attachments in
} attachments: {
Attachment(id: "AttachmentContent") {
Text("")
}
}
}
}
The data model is basically just a hardcoded struct with the Observable macro. I can use this to build the directory view.
@Observable
class ModelData {
var labData: [DSScene] = [
DSScene(title: "Dark 001",
sceneName: "Scenes/Ch1_Planes",
type: .SPACE_FULL,
chapter: .chapter_one,
date: Date("03/29/2025"),
isFeatured: false,
subtitle: "Welcome",
description: "")
,DSScene(title: "Dark 002",
sceneName: "Scenes/Ch1_Cubes",
type: .SPACE_FULL,
chapter: .chapter_one,
date: Date("03/29/2025"),
isFeatured: false,
subtitle: "Cubes",
description: "")
,DSScene(title: "Dark 003",
sceneName: "Scenes/Ch1_Spheres",
type: .SPACE_FULL,
chapter: .chapter_one,
date: Date("03/29/2025"),
isFeatured: false,
subtitle: "Spheres",
description: "")
]
}I kept it pretty simple with the directory for now. I’m only starting with six scenes, so I just show them all in a grid. Eventually, I’ll want a more complex interface here.

struct DSCollectionView: View {
@Environment(ModelData.self) var modelData
// Grid layout configuration
private let columns = [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)
]
var chapterOneDarks: [DSScene] {
modelData.labData.filter { $0.chapter == .chapter_one }
}
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(chapterOneDarks) { dsScene in
DSCollectionButton(dsScene: dsScene)
}
}
.padding()
}
}
}Users can tap any of these buttons to enter the space. They can tap again to exit. They can also move from one space to another (close current, open new).
I spent more time than I care to admit on the subviews here. When I have each scene done, I’ll include an image of it here instead of the text label. I needed a way to quickly show the active scene, while also letting users exit or enter a new one. I ended up using play and pause icons for now, along with some custom hover effects and groups.
struct DSCollectionButton: View {
@Environment(AppModel.self) private var appModel
var dsScene: DSScene
@Namespace private var hoverNamespace
// Window environment values
@Environment(\.openWindow) private var openWindow
@Environment(\.dismissWindow) private var dismissWindow
@Environment(\.openImmersiveSpace) var openImmersiveSpace
@Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace
// Local state
@State private var currentRoute: String?
init(dsScene: DSScene) {
self.dsScene = dsScene
_currentRoute = State(initialValue: dsScene.file)
}
let width: CGFloat = 240
let height: CGFloat = 160
let indicatorSize: CGFloat = 18
var body: some View {
ZStack {
// Background
Color.black
.frame(width: width, height: height)
.hoverEffect(.lift, in: HoverEffectGroup(hoverNamespace))
// Content
VStack {
Text("\(dsScene.name)")
.font(.largeTitle)
}
// Open indicator
Image(systemName: appModel.isSceneOpen(dsScene) ? "stop.fill" : "play.fill")
.frame(width: indicatorSize, height: indicatorSize)
.foregroundColor(.white)
.position(x: width - indicatorSize - 8, y: indicatorSize + 8)
.hoverEffect(in: HoverEffectGroup(hoverNamespace)) { effect, isActive, _ in
effect.opacity(isActive ? 1.0 : appModel.isSceneOpen(dsScene) ? 1.0 : 0)
}
}
.frame(width: width, height: height)
.clipShape(.rect(cornerRadius: 24))
.onTapGesture {
Task {
await handleSpace(spaceId: "RouterSpaceFull")
}
}
}
/// Handles opening and closing of immersive spaces
private func handleSpace(spaceId: String) async {
if appModel.isSceneOpen(dsScene) {
await closeSpace()
} else {
await openSpace(spaceId: spaceId)
}
}
/// Closes the current immersive space
private func closeSpace() async {
appModel.immersiveSpaceState = .inTransition
await dismissImmersiveSpace()
appModel.removeOpenScene(dsScene)
appModel.immersiveSpaceState = .closed
}
/// Opens a new immersive space, closing any existing space first
private func openSpace(spaceId: String) async {
// Close existing space if one is open
if appModel.immersiveSpaceState == .open {
appModel.immersiveSpaceState = .inTransition
await dismissImmersiveSpace()
if let currentScene = appModel.currentImmersiveScene {
appModel.removeOpenScene(currentScene)
}
}
// Open the new space
appModel.immersiveSpaceState = .inTransition
switch await openImmersiveSpace(id: spaceId, value: dsScene) {
case .opened:
appModel.addOpenScene(dsScene)
appModel.immersiveSpaceState = .open
case .error, .userCancelled:
fallthrough
@unknown default:
appModel.immersiveSpaceState = .closed
}
}
}Scene: Planes
The idea for this scene is to limit myself to planes as much as possible. I may have a few other entities here, such as the dome, and some light orbs, but I want to build some place that is primarily constructed our of simple planes.
I started with a function to generate a plane with some pseudo-random sizing and positioning code. I need to make some more changes this. I’d like to create other formations. I’d also like to animate the planes over time, add custom hover shaders, and add an organic feel to the space. Here is the scene for now.

struct Dark001: View {
@State var dsScene: DSScene
private func generateRandomTemplatePlane(material: ShaderGraphMaterial) -> ModelEntity {
let width = Float.random(in: 1...8.0)
let height = Float.random(in: width...(width * 8))
let cornerRadius: Float = 0.0
let templatePlane = ModelEntity(
mesh: .generatePlane(width: width, height: height, cornerRadius: cornerRadius),
materials: [material])
let collision = CollisionComponent(shapes: [.generateBox(size: .init(x: width, y: height, z: 0.05))])
templatePlane.components.set(collision)
// Generate random X and Z positions that are outside the 5-meter radius
var randomX: Float
var randomZ: Float
var distance: Float
let size: Float = 50.0
repeat {
randomX = Float.random(in: -size...size)
randomZ = Float.random(in: -size...size)
distance = sqrt(randomX * randomX + randomZ * randomZ)
} while distance < 5.0
let yPosition = height / 2
templatePlane.setPosition([randomX, yPosition, randomZ], relativeTo: nil)
let rotationY = atan2(randomX, randomZ) + .pi // Add 180 degrees to face the correct direction
templatePlane.setOrientation(simd_quatf(angle: rotationY, axis: [0, 1, 0]), relativeTo: nil)
return templatePlane
}
var body: some View {
RealityView { content in
// Load the scene
guard let scene = try? await Entity(named: dsScene.sceneName, in: realityKitContentBundle) else { return }
content.add(scene)
// Get the entity with the shader graph material
guard let matEntity = scene.findEntity(named: "MatSphere") else { return }
matEntity.isEnabled = false
// Get the matrial
guard let matFresnel = matEntity.components[ModelComponent.self]?.materials.first as? ShaderGraphMaterial else { return }
for _ in 0..<144 {
let templatePlane = generateRandomTemplatePlane(material: matFresnel)
content.add(templatePlane)
}
} update: { content in
// We'll be using this later
}
}
}Scene: Cubes
For this one, I started with a custom component and system to create an array of cubes. I added the same fresnel shader to them, then placed them in a 10×10 wall. The system will run a sequence that will slowly select cubes and modify their z position values. At the end of the sequence they all snap back to the initial value. I have a lot of ideas for how to improve this one too. I might construct a large structure around the user, then slowly change it over time.
public struct CubeWallComponent: Component, Codable {
var targetZPosition: Float?
var isAnimating: Bool
var animationDuration: Float
var currentAnimationTime: Float
var startZPosition: Float?
public init() {
self.isAnimating = false
self.animationDuration = 0
self.currentAnimationTime = 0
self.targetZPosition = nil
self.startZPosition = nil
}
}
public enum WallPhase {
case waiting
case breakingApart
case reassembling
case complete
}
public class CubeWallSystem: System {
static let query = EntityQuery(where: .has(CubeWallComponent.self))
private var elapsedTime: Float = 0
private var waitDuration: Float = 0
private var phase: WallPhase = .waiting {
didSet {
print("Phase changed to: \(phase)")
}
}
private var phaseStartTime: Float = 0
// Configuration
private let initialDelay: Float = 4.9
private let breakingPhaseLength: Float = 56.0 // 1 minute of breaking apart
private let reassemblyDuration: Float = 0.1 // Time to reassemble the wall
required public init(scene: Scene) {
self.waitDuration = initialDelay
print("CubeWallSystem initialized with \(initialDelay) second delay")
}
public func update(context: SceneUpdateContext) {
let deltaTime = Float(context.deltaTime)
elapsedTime += deltaTime
phaseStartTime += deltaTime
let entities = context.entities(matching: Self.query, updatingSystemWhen: .rendering)
if phase == .waiting {
print("Waiting phase: \(elapsedTime)/\(waitDuration) seconds, found \(entities) entities")
}
switch phase {
case .waiting:
if elapsedTime >= waitDuration {
phase = .breakingApart
phaseStartTime = 0
elapsedTime = 0
print("Starting breaking apart phase")
}
case .breakingApart:
if phaseStartTime >= breakingPhaseLength {
phase = .reassembling
phaseStartTime = 0
startReassembly(context: context)
print("Starting reassembly phase")
} else {
updateBreakingPhase(context: context, deltaTime: deltaTime)
}
case .reassembling:
if phaseStartTime >= reassemblyDuration {
phase = .waiting
phaseStartTime = 0
elapsedTime = 0
waitDuration = initialDelay
print("Cycle complete, waiting \(initialDelay) seconds before restarting")
} else {
updateReassemblyPhase(context: context, deltaTime: deltaTime)
}
case .complete:
break
}
}
private func updateBreakingPhase(context: SceneUpdateContext, deltaTime: Float) {
if elapsedTime >= waitDuration {
elapsedTime = 0
waitDuration = Float.random(in: 0.2...0.8)
let entities = context.entities(matching: Self.query, updatingSystemWhen: .rendering)
let nonAnimatingEntities = entities.filter { entity in
guard let component = entity.components[CubeWallComponent.self] else { return false }
return !component.isAnimating
}
if let selectedEntity = nonAnimatingEntities.randomElement() {
var component = selectedEntity.components[CubeWallComponent.self]!
component.targetZPosition = Float.random(in: -0.25...0.25)
component.startZPosition = selectedEntity.position.z
component.animationDuration = Float.random(in: 0.05...0.5)
component.currentAnimationTime = 0
component.isAnimating = true
selectedEntity.components[CubeWallComponent.self] = component
}
}
updateAnimatingEntities(context: context, deltaTime: deltaTime)
}
private func startReassembly(context: SceneUpdateContext) {
let entities = context.entities(matching: Self.query, updatingSystemWhen: .rendering)
for entity in entities {
if abs(entity.position.z) > 0.001 { // Check if cube is not at z=0
var component = entity.components[CubeWallComponent.self]!
component.targetZPosition = 0
component.startZPosition = entity.position.z
component.animationDuration = reassemblyDuration
component.currentAnimationTime = 0
component.isAnimating = true
entity.components[CubeWallComponent.self] = component
}
}
}
private func updateReassemblyPhase(context: SceneUpdateContext, deltaTime: Float) {
updateAnimatingEntities(context: context, deltaTime: deltaTime)
}
private func updateAnimatingEntities(context: SceneUpdateContext, deltaTime: Float) {
for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) {
guard var component = entity.components[CubeWallComponent.self],
component.isAnimating,
let startZ = component.startZPosition,
let targetZ = component.targetZPosition else {
continue
}
component.currentAnimationTime += deltaTime
let progress = min(component.currentAnimationTime / component.animationDuration, 1.0)
let newZ = startZ + (targetZ - startZ) * progress
var position = entity.position
position.z = newZ
entity.position = position
if progress >= 1.0 {
component.isAnimating = false
component.startZPosition = nil
component.targetZPosition = nil
}
entity.components[CubeWallComponent.self] = component
}
}
}
There is so much more to do and so many ideas to explore for these spaces. I’ll keep working!

Follow Step Into Vision