Immersive apps for XR headsets tend to have an engagement dilemma: they perform exceptionally well for the initial experience when a user gets to enjoy having a large-scale fantastical world brought into their living room for the first time, but have difficulties bringing those users back on a regular basis for a meaningful period of time.
There are plenty of reasons why, but for apps that make full use of the immersive capabilities of XR, it frequently boils down to the challenge of producing complex 3D content continuously and efficiently in volumes large enough to keep a niche audience of users engaged on a regular basis. It takes multitudes more effort compared to 2D apps and games.
The strategy that sparked this project is an attempt to merge that heightened first-time unboxing experience with mechanics designed to motivate regular usage for a decent period of time. And an app category that frequently emphasizes the latter is casual gaming: easy to learn, but also enough competition and possibilities to motivate return sessions whenever you have some spare time to kill.
Sanctuaries and Voids is an experience that uses the exceptional immersive capabilities of Apple’s Vision Pro to captivate and focus players while they engage in competition to traverse a course of alien planets in as few orbital jumps as possible. The gameplay is meant to be more of a meditative, "anti-game" experience and flow where you move at your own liesurely pace. Production for the game is designed for simplicity, scalability, and extensibility.
It is currently a vertical slice prototype that I built to quickly progress through feature development. You need an Apple Vision Pro feel the immersion and awe, but you can play the video below to get an idea of how things came together.
Games and interactive experiences are basically multi-sensory simulations of fictional worlds that require bringing a broad collection of disciplines together to create an experience. I blended game, interaction, UX, UI, motion/animation, and sound design to weave a fabric that holds the experience together for players.
Something that deserves its own mention is the neuroaesthetic design portion at the end. Intention elevates craft, so I've been exploring neuroaesthetic design as a way to ground more of my choices in empirical science to develop elements that are optimized to produce the physiological responses I want for people.
Before getting into the details, here's a summary of the design brief:







/// Drag and set gesture
/// Ellipses (...) are lines removed to keep the code sample brief
struct SatelliteForceDragGesture: Gesture {
@Environment(AppModel.self) var appModel
@State private var lastForceUpdateTime: TimeInterval = 0
@State private var lastThrustInterval: Int = 0
private let forceUpdateInterval: TimeInterval = DS.Satellite.dragSampleInterval
private let thrustTickStep: Float = 0.005
private let rotationSmoothing: Float = 0.3 // 0 = no rotation, 1 = instant snap (old behavior)
private let minLookDistance: Float = 0.03
var body: some Gesture {
EntityDragGesture { entity, targetPosition in
...
}
let currentPosition = satellite.position(relativeTo: nil)
let now = CACurrentMediaTime()
if now - lastForceUpdateTime >= forceUpdateInterval {
let direction = targetPosition - currentPosition
let strength = simd_length(direction)
let force = DS.Satellite.launchVelocityPerDragMeter * strength * simd_normalize(-direction)
...
}
lastForceUpdateTime = now
}
let lookVector = targetPosition - currentPosition
if simd_length(lookVector) > minLookDistance {
...
}
}
.onEnded { _ in
lastForceUpdateTime = 0
}
}
}

/// Tap Assist parameters
private let tapAssistEnabled: Bool = true
private let tapAssistMinDistance: Float = 20.0
private let tapAssistMaxDistance: Float = 150.0
private let tapAssistTargetAngularSize: Float = 0.05
private let tapAssistAngularSizeCeiling: Float = 0.05
private let tapAssistMaxScale: Float = 2.0
private let tapAssistBaseRadiusFactor: Float = 1.0
var tapAssistDebugVisible: Bool = false {
didSet {
guard tapAssistDebugVisible != oldValue else { return }
for (key, proxy) in tapProxiesByPOB {
let radius = tapProxyBaseRadiusByPOB[key] ?? 0.001
applyTapAssistDebugMesh(to: proxy, baseRadius: radius)
}
}
}
/// One of the Tap Assist methods -- calculates scale adjustment amount
private func assistScale(
baseRadius: Float,
pobWorldPos: SIMD3<Float>,
satelliteWorldPos: SIMD3<Float>
) -> Float {
let distance = simd_length(pobWorldPos - satelliteWorldPos)
guard distance >= tapAssistMinDistance,
distance <= tapAssistMaxDistance else { return 1.0 }
guard baseRadius > 0 else { return 1.0 }
let naturalAngularSize = baseRadius / distance
guard naturalAngularSize < tapAssistAngularSizeCeiling else { return 1.0 }
let rawScale = tapAssistTargetAngularSize / naturalAngularSize
return min(max(rawScale, 1.0), tapAssistMaxScale)
}/// Hover Effect controls in App Model
var showHoverEffectInGameplay: Bool = false
var hoverEffectStrength: CGFloat = 0.5
var hoverEffectColor: UIColor = .white
func makeGameplayHoverComponent() -> HoverEffectComponent {
let style = HoverEffectComponent.SpotlightHoverEffectStyle(
color: hoverEffectColor,
strength: Float(hoverEffectStrength)
)
return HoverEffectComponent(.spotlight(style))
}
// MARK: - Primitive Tokens
// Raw values only. Never referenced directly in views
// Ellipses (...) are lines removed to keep the code sample brief
private enum Primitive {
enum Spacing {
...
}
enum Radius {
static let medium: CGFloat = 12
static let large: CGFloat = 16
}
enum Color {
static let green20 = SwiftUI.Color.green.opacity(0.2)
static let red20 = SwiftUI.Color.red.opacity(0.2)
static let white20 = SwiftUI.Color.white.opacity(0.2)
...
}
enum Symbol {
static let circleHexagongridCircle = "circle.hexagongrid.circle"
static let circleFill = "circle.fill"
static let squareFill = "square.fill"
static let diamondFill = "diamond.fill"
static let arrowUp = "arrow.up"
static let arrowLeftAndRight = "arrow.left.and.right"
static let arrowUpAndDown = "arrow.up.and.down"
}
enum Motion {
static let springResponse: Double = 0.5
static let springDamping: Double = 0.82
static let stagger: Double = 0.08
static let tintDuration: Double = 0.3
}// MARK: - Design System (Semantic Tokens)
// Named by purpose, not by value. These are the only tokens views should reference.
enum DS {
enum Spacing {
static let unit: CGFloat = Primitive.Spacing.space1
static let unitHalf: CGFloat = Primitive.Spacing.space0_5
static let windowPadding: CGFloat = Primitive.Spacing.space3
static let sectionVertical: CGFloat = Primitive.Spacing.space2
static let sectionLabel: CGFloat = Primitive.Spacing.space0_5
static let appControlsBottom: CGFloat = Primitive.Spacing.space4
}
enum Layout {
static let windowWidth: CGFloat = 364
static let buttonHeight: CGFloat = Primitive.Spacing.space5
static let controlButtonHighlightRadius: CGFloat = Primitive.Radius.medium
static let controlButtonDisabledOpacity: Double = 0.35
static let controlButtonHighlightPadding: CGFloat = Primitive.Spacing.space1
static let glyphHoverPadding: CGFloat = Primitive.Spacing.space0_5
static let labelSweepBandOpacity: Double = 1.0
static let labelSweepBandWidth: CGFloat = 0.3
static let labelSweepTextOpacity: Double = 0.8
static let difficultyIconSlot: CGFloat = Primitive.Spacing.space1
}
enum Motion {
static let sectionExpand: Animation = .spring(
response: Primitive.Motion.springResponse,
dampingFraction: Primitive.Motion.springDamping
)
static let sectionStagger: Double = Primitive.Motion.stagger
static let sectionTint: Animation = .easeInOut(duration: Primitive.Motion.tintDuration)
static let labelSweepDuration: Double = Primitive.Motion.labelSweepDuration
static let labelSweepPause: Double = Primitive.Motion.labelSweepPause
static let resultFade: Animation = .easeOut(duration: Primitive.Motion.resultFadeDuration)
static let contentCrossFade: Animation = .easeInOut(duration: Primitive.Motion.contentCrossFadeDuration)
static let numericRoll: Animation = .easeInOut(duration: Primitive.Motion.numericRollDuration)
}
/// Styles a Text as a section label: caption font, full width, left-aligned.
/// When `isActive`, a brighter band sweeps across the (always full-brightness)
/// label, eases to the right edge, rests off-screen, then repeats — additive
/// highlight, never dims the text.
struct SectionLabelModifier: ViewModifier {
var isActive: Bool = false
/// Measured label width, needed to size and position the moving band.
@State private var width: CGFloat = 0
/// Measured label height, used to extend the band's horizontal travel so a
/// diagonal band fully clears the glyphs at both ends.
@State private var height: CGFloat = 0
func body(content: Content) -> some View {
// Full-opacity styled label. Used as the band's mask so the sweep reaches
// full strength regardless of how dim the displayed text is.
let maskShape = content
.font(DS.Typography.bodyBold)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, DS.Spacing.sectionLabel)
return maskShape
// Both active and inactive labels rest at this dimmed opacity so they
// read as a matched pair; "active" adds the sweep on top, it doesn't
// change the base brightness.
.opacity(DS.Layout.labelSweepTextOpacity)
.background {
GeometryReader { proxy in
Color.clear
.onAppear {
width = proxy.size.width
height = proxy.size.height
}
.onChange(of: proxy.size.width) { _, w in width = w }
.onChange(of: proxy.size.height) { _, h in height = h }
}
}
.overlay { sweepOverlay(masking: maskShape) }
}


The technical art needs for this project were mostly about creating procedural systems and custom scripts to generate a few important classes of visual assets (planet surfaces and obstacle geometries), manipulating some visionOS system behaviors (environmental lighting, and custom colliders for physics), and writing reusable shaders.
Tech art is also where I spent time wrangling with graphics performance optimizations. The Apple Vision Pro is quite a bit more powerful than most XR headsets, but you still need to consider geometry complexity, transparency passes, and lighting.



/// The IBL (Image Based Lighting) suppression method that does most of the adjustment work.
private func suppressIBL() async throws {
guard let root = rootGameplayScene else { return }
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1))
let blackImage = renderer.image { ctx in
UIColor.black.setFill()
ctx.fill(CGRect(x: 0, y: 0, width: 1, height: 1))
}
let environment = try await EnvironmentResource(
equirectangular: blackImage.cgImage!
)
root.components.set(ImageBasedLightComponent(source: .single(environment)))
iblSource = root
applyIBLReceiver(to: root)
print("✅ IBL suppressed with black environment")
}




Fully End-To-End And Solo Project That Will Be Shipped To An App Store
A year or two ago, an experience of this scope and quality was simply not possible for me to solo develop within the timeline I executed for this project. Back then, even something as small as an Xcode bug not registering custom Reality Composer Pro components as expected could take hours to investigate. Now, all of those headaches plus time spent trying to demystify the quirks of a coding language are insignificant and leave much more of my time and capability to direct towards creating something that is much closer to what I want for my users. And it even comes with much higher-quality code running the engine.
Key Assumptions Tested At Checkpoints Throughout The Process
Testing important assumptions about (1) the main gameplay mechanic (2) level design and (3) effects of neuroaesthetic design elements were much easier inside of the modern design and dev process I deployed for the project. For each of those three checkpoints, I was able to generate builds, have people try the experience in the headset, and provide the feedback I needed to make decisions about how to process at each of those three gates. So now the project is going into beta testing with some validation for foundational assumptions, and data collection can be focused on items that have narrow impact rather than throwing a huge backlog of questions, feedback, and adjustments into one unwieldy development stage.
A Solid Test Run And Application Of Modern Design Methods
I did explorations and applications of AI assistance that would not be possible inside a corporate environment these days. Most applications of AI-assisted design and development by a single designer on a team are limited to one or two product features, only relevant to the product they support, probably constrained to a smaller selection of tools, and inhibited by established design culture. There wouldn't be enough room or approval for conceiving a real-world project to tinker with from all aspects of design—interaction, UX, UI, sound, motion, neuroaesthetic, 3D, and ergonomic—and exploring different ways to realize ideas without the burden of corporatized design traditions.
After this project, a universal takeaway I can offer for AI-assisted design and dev is that there is no universal recommendation for how to add AI assistance into your workflow or process. Recent research indicates designers use a wide variety of tools and approaches for AI-assisted workflows. For just one of the AI services I used for this project, Claude, the ways I generated output from it ranged from:
