Interaction Arts & Engineering
Designing, building, and releasing an immersive game
Sanctuaries and Voids is a native visionOS experience I developed for the underserved “calm gaming” audience on Apple Vision Pro. With the help of AI tools, I delivered an ambitious product scope and covered a wide spectrum of design and engineering activities (game design, interaction design, UX/UI, motion, sound, 3D tech art, and Swift engineering) that normally would require a small team. The game is currently on TestFlight for beta testing.
Strategy & Concept
Multidisciplinary Design
Technical Art
Engineering
Outcomes

The Strategy And Concept

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.

‍

AI And A Modern Design And Development Process
‍
Since AI is the topic de jour, here is the mandatory summary of its role in this project. Rather than ramble about the details of how I used it, this a discussion of the tangible value it produced for me.

From fighting syntax to architecting systems
As a designer who engineers, my engineering time shifted from wrangling syntax and endlessly searching forums to architecting systems, thoroughly weighing implementation approaches, and testing built features. That redirection of my time and effort is easily the most valuable benefit I gained from AI assistance. And as a sweetener, I had bandwidth to build the small utilities and pipeline helpers that wouldn't get much attention in previous projects where all of my focus needed to be directed towards making core features functional for demos.
A delightful productivity booster
On the strategy and design side of things, AI was a power-up that I could call in to finish my work on assets, tools, and documents that I started. After I labored over math and formulas for level of difficulty ratings, I had Claude get it all organized in a spreadsheet so that I could immediately start using the framework to design a gameplay level. When iterating the main gameplay UI, I asked Claude to review the layout from the perspective of Swiss Design principles. For many of those "helpful yet tedious when you're trying build something" artifacts—writing product briefs, researching astronomy topics, organizing design system elements after adding new components—AI made it much easier to take advantage of those outputs.

Comes with important caveats
The most important caveat is these gains wouldn't be possible without my previous experience in design and immersive tech. Even for the engineering in this project, there were plenty of times when I had to correct Claude's output (even small errors in syntax), tell it to consider a different approach, and even tell it to go out on the web and read Apple's documentation after it very confidently recommended complex solutions that Apple had converted to simple one-line APIs. I needed years of domain knowledge and real-world practice to know what to ask AI and recognize when it was moving in a wrong direction.

Multidisciplinary Design

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:

  • Awe-inspiring immersive features to captivate at the start, casual competition to motivate repeat sessions.
  • App and gameplay experience that leans more meditative (over action, common fantasy, first-person shooter, role-playing, etc.).
  • A neuroaesthetic design approach to enhance immersion (specifically awe), and maximize response to interaction feedback and player progress rewards.
  • Fully native to take full advantage of the Vision Pro's capabilities in real-time 3D and 2D UI (no Unity or Unreal).
  • Does not require controllers or other peripherals to play.
  • Simplicity in gameplay for this first phase of development. No complicated scoring schemes, random rewards, in-game economies, etc.
Foundational App Structure And Flow
The app launches in shared space, a mixed reality view where any other active apps are visible. For gameplay, I implemented progressive immersion where players can choose between partial immersion (another mixed reality view) or full immersion in the game world. Giving players the option to select if they want to be fully immersed in gameplay or have access to a view of their physical surroundings was the most appropriate choice for this experience.
Gameplay Design

The Core Game Mechanic
A “bow and arrow” interaction where you use a drag gesture to set a flight path before propelling your spacecraft toward an orbital target. The more precise you are, the better your odds of navigating the course in fewer jumps.
‍



‍Competition Design
‍
A very simple and easy to understand approach that is similar to golf. The smallest number of orbital jumps needed to complete the course is the winner or your personal best. For this development phase, I decided against time limits, bonuses, or other extras that would add complexity (likely uneeded) to the player experience.
Level Design

Level design is where the game is made strategic for players. Jumps need to be made to areas where there is enough gravity to lock into orbit and is a good position to launch from for the next jump. You need to watch your velocity to avoid overshooting your orbit target and also stay clear of hazards along the way. You also might discover shortcuts that can boost your score.
‍Systematized Level of Difficulty
One of the most important tasks in level design for this game is estimating the amount of difficulty you program into challenges, obstacles, etc., and for the level overall. It could have been a subjective process requiring a lot of play testing and effort to tune, but fortunately, I was able to come up with this clever little system and spreadsheet that scores the level of difficulty for the jumps in the course.
‍
Rather than blocking out a level design, exporting a build for the device, play testing for 10 minutes, making notes, and then doing that whole process over again five or more times, I could run through a much smaller number of shorter iterations by starting with calculating level of difficulty scores for each jump in this spreadsheet. That made the process for planning the level and dialing in difficulty exponentially faster and easier. And it gave me a mechanism for easily adding a new and useful new piece of gameplay data for players:  jump difficulty estimates.
Spatial And Environment Design: Scale For Realism Or Scale To Accommodate Gameplay and Art?
While I was researching (via watching hours of astronomy videos), I kept getting my mind blown by how massive objects and the space between them are. Jupiter is about 11 times the size of our little Earth, and the sun is 109 times bigger. It took the Voyager spacecraft 12 years to reach Neptune from Earth.


I wanted to simulate that vastness by referencing the scale of our solar system's objects and the voids between them to design levels that deliver novel visual experience and provide the basis for difficulty ratings. I even created a custom astronomical measurement system for the game world (stellar units, centi-stellar units, and quantum-stellar units). The gigantic scale made many aspects of visual and gameplay design much more challenging, but it resulted in a signature element of the game that enhances immersion and makes it a unique experience.
Level Art
My research also brought to light the unimaginable composition of objects in our galaxies. Just in the inner core of our solar system, we have gas giants with ribbons of colossal storms and no solid ground, contrasted with the dry and rocky planets that have atmospheres too thin to generate visible clouds. There's a planet with hundreds of moons in many varieties, and two planets with no natural satellites.

Things get weirder when you travel out to the Kuiper Belt, which is a huge donut-shaped field of icy objects, one with ice volcanoes that blast giant plumes of volatile gases into space. Pluto, the former planet and now Kuiper Belt object, orbits in tandem around a central point it shares with its moon, Charon, which is half of its size. And there's possibly a giant cloud of gas floating about after a recent collision of objects.

And far beyond our solar system, objects become unfathomable when you consider galactic phenomena such as black holes, pulsars, quasars, etc. All of this results in an art direction inspired by these oddities, where planets in a level can look like Earth or an orbital body made of molten metal oceans and volcanic terrain. And hazards that provide obstacles to orbital jumps range from giant fields of alloy debris to gas clouds or clusters of moons orbiting a planet.

Kuiper Belt artist renderings from NASA and PBS


Sanctuaries and Voids level art samples

Interaction Design: UX, UI, Motion, And Sound

If game design and level design are the game, interaction design is the layer that enables people to effectively consume the game. This section is a breakdown of all the different interaction design elements I created to make this concept as smooth a player experience as possible.
‍

Interaction design for the core game mechanic, spacecraft launching

After evaluating a small list of options, I decided a “bow and arrow” styled drag gesture was the best mix of “natural interaction,” ergonomics (it’s comfortable for long periods of play), visual simplicity, and fun.

I could give players a bunch of directional buttons to press to aim the spacecraft—pretty boring and not very visually elegant. I could break the interaction into a wheel for aiming, a separate control to set propulsion, and a button to launch. Three separate controls also isn’t a very elegant approach.


So I chose the drag-and-set approach shown below that combines direction and propulsion into one easy and playful control. You can see in the code that it’s a tunable system where steps in the drag interval can be adjusted, delays can be added for visual smoothing, and a gate is in place to remove some jerkiness that can happen at very small drag distances.

‍

‍Code For The Gesture
/// 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
        }
    }
}
The Gameplay HUD (Heads-Up Display)‍
‍
This is the most important element for giving players control over gameplay, and where I applied all of the common 2D UI approaches seen in our everyday apps: 2D visual hierarchy, typography, iconography, discoverability, progressive disclosure, emphasis, motion, spacing, state, etc.

It is a lightly data-dense UI where I needed to distribute a few different groups of numeric data into a layout that makes it easy for players to find frequently used gameplay info at a glance. Basic Swiss Design principles were a good guide for apportioning this layout (as they are for most modern UIs).

Also, the HUD can be moved anywhere the player wants and has a slim, vertically oriented profile, both of which are important for this game, where I want players to have plenty of opportunity to enjoy panoramic views of the star systems they travel through.
‍
An essential UI element that is separate from the HUD's window is the simple white ring that is activated to help players visualize the orbit area they can target for their jump to a new planet. I went through a few versions for this element: a semitransparent sphere, and then two white rings (one to represent depth). But one simple ring on one plane provides the info needed without cluttering the game scene.
Building Dynamically Scaled Tap Targets
I built a small system that calculates tap target sizes for a specific class of objects because this game has a pretty unique problem: it places targetable planets extremely far off in distance from the player—necessary to make the game challenging but means you will have instances where even a large planet is tiny in the field of view and very difficult to trigger the on-hover function to select it.
‍
‍So I came up with an angular sizing system that crunches together the size of the planet and how far it is from the player to determine if the tap target area needs to be scaled up to make it much easier to trigger on-hover. Not being able to select visible planets was a huge frustration while testing, so that purely interaction/UX-focused piece of programming is critical for the app and ensures that tap targets

‍

‍Tap Assist Code Samples
    /// 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)
    }
‍Building An Affordance And System For Managing The Hover Effect
A small but noteworthy UX affordance-related detail that I added to this project is the option to toggle the cursor’s hover effect on/off. A detail that makes visionOS’ usability outstanding is the cursor highlighting used for the eye tracking pointer system. A nice white gradient overlay lets users know when they’ve hovered over an interactive element, and it’s usually necessary for navigation.

But there are situations where the hover effect is a bit distracting, and it’s typically present in apps built around viewing and interacting with well-crafted 3D objects. Looking at the object means you’ll trigger the hover effect highlight, which brightens the lighting that was designed for the object, which means you’re now looking at the object with lighting that wasn’t the intention when designing it. It’s trivial for one or a few instances, but when it occurs frequently over a session, it can add up to a memorable distraction.

I noticed it was a problem for my game where after only a minute of playing, you learn that the spacecraft and planets can be interacted with, so the hover effect becomes less useful and more of a distraction due to the constant on/off highlighting of objects when you move your focus.
‍
Thankfully, Apple gives you an API to handle that issue, and I used it to give players the option to toggle the hover effect on or off, set its intensity, and choose a color. Having it on is useful for new players getting oriented and can also help experienced players when viewing small targets off in the distance.

‍

‍Hover Effect Code Sample
/// 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))
}
UI Motion Design
‍Obviously, I programmed a lot of animation in many varieties for this project. This section includes specific UI motion designs I created and programmed for the gameplay HUD. All animations in the HUD were created to draw attention to important info and signal important events and changes in state.

Section expand-collapse is the most prominent UI animation and necessary for signaling player turn events, guiding focus, and hiding unneeded info.


Light sweeps subtly draw attention to the active section's title;
Pulse animation added to a glyph to gently nudge for the required action.


The orbit visualizer ring for targeting uses the typical entry and exit easings to animate its scale.

HUD Motion Code Walkthrough
‍This is a bit of an experiment for my portfolio project presentations. The artifact below is an asset I had Claude generate for a walkthrough and description of the code that handles the section expand-collapse animations in the gameplay HUD. Without showing the lines of code used, it illustrates the complexity required to make some pretty standard UI animations possible.

Walkthrough: from level pick to settled sections

Section expansion sequence LevelPickerControls calls AppModel.enterLevel, which sets levelLoadState to loading inside withAnimation, showing the loader. AppModel opens the immersive space; ImmersiveGameplayView loads the level, then sets ready inside withAnimation, and MainWindowView's sections expand at 0, 80 and 160 ms. On disappear, ImmersiveGameplayView sets idle inside withAnimation and the sections collapse. LevelPickerControls AppModel ImmersiveGameplayView MainWindowView enterLevel(level) withAnimation { .loading } LevelLoadingView fades in openSpace() loadAndSetupGameLevel withAnimation { .ready } Loader out; sections expand at 0 / 80 / 160 ms onDisappear withAnimation { .idle } Sections collapse
From level pick to settled sections: every levelLoadState write is animated.
  1. The player picks a level, which calls AppModel.enterLevel.
  2. levelLoadState becomes .loading inside withAnimation. MainWindowView inserts LevelLoadingView.
  3. The immersive space opens. ImmersiveGameplayView's RealityView builds the controllers and awaits loadAndSetupGameLevel.
  4. When loading finishes, levelLoadState becomes .ready, again inside withAnimation(DS.Motion.sectionExpand).
  5. SwiftUI re-evaluates MainWindowView's body. The loader's if turns false and the sections' if turns true, all in one animated transaction.
  6. Each section runs its .move(edge: .top).combined(with: .opacity) transition. The animation attached to each transition takes over from the transaction's, adding the stagger: Level Info starts at 0 ms, Set Course at 80 ms, Mission Control at 160 ms.
  7. The spring (0.5 s response, 0.82 damping) settles each section with a slight overshoot.
  8. During play, animated state writes resize sections in place, using the same spring, and cross-fade the active tint with a 0.3 s ease.
  9. When the immersive space closes, onDisappear writes .idle inside withAnimation. The sections run the same transition in reverse and collapse upward.

Design notes

The sample shows a small, token-driven animation system rather than one-off modifiers. The key decisions behind it:

  • One source of truth for visibility. The sections depend only on levelLoadState. They appear when the scene is actually built, not when the space opens, so the UI never shows controls for a level that isn't ready.
  • Animation lives at the state write. withAnimation wraps the mutation, not the view. Where a write runs on a later runloop tick (the gravity system's @MainActor Task), the wrapper moves into that Task, around the assignment itself.
  • Transition-scoped animation for the stagger. .transition(...).animation(...) gives each section its own delayed spring. withAnimation still has to be there, or the insertion isn't animated and the transition never runs.
  • Composable, reusable modifier. .sectionTransition(index:) hides the recipe behind one call. Adding a fourth section means one line.
  • Two-layer design tokens. Raw numbers live in a private Primitive enum, and views only see semantic names like DS.Motion.sectionExpand. Retuning the feel is a one-line change.
  • Right curve for the job. Layout motion uses a spring; the color tint uses a plain ease, because a bounce reads oddly on a color fade.
  • Composite animation key. ContentStateKey bundles two states into one Equatable value. Every content change animates, including the success screen, which changes the turn state while the transit state stays the same.
  • Always-present background. The active tint fades to .clear instead of being inserted and removed, so it cross-fades smoothly instead of popping.
‍Design System
Something that is a bit different these days is designers having more access and interaction with the engineering aspect of design systems. Designers are more likely to write tokens and also convert them to the parameters or variables used in code to define components. For designer-builders like me, we bridge the design and engineering pieces of design systems and can bypass much of the low-value handoff work that has become ritual in the design workflow.

The images below show the structure of my design system in code for UI elements of the game, which is basically a section for defining primitives, then a section where primitives are used to create semantics for re-useable properties, and finally a section for more complex re-useable structures.

‍
‍Design System Primitives Sample
// 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
}


‍
‍Design System Semantics Sample
// 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)
    }


‍
‍Design System ViewModifier (Instructions For Components) Sample
/// 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) }
    }
‍Sound Design
Everything you hear in the experience was created by me in Logic Pro: music, gameplay SFX, and UI SFX. I was facing a bit of a dilemma where easy and low-cost music and SFX licensing for gaming is not as abundant as it is for video projects. So I was a little worried that composing my own work would take too much time and not get the moods I wanted. But it probably ended up getting me exactly the moods I wanted in the same amount or less time than searching through libraries of stock assets.
Gameplay Music Samples
Successful Jump

Level Start

Level Complete

Out of Bounds
UI And Gameplay SFX Samples
POB Select

POB Deselect

Dart Station Select

Explosion


‍

‍Screenshots from the Logic Pro work
Neuroaesthetic Design
For this project, neuroaesthetic design is the practice of making aesthetic decisions using research findings from neuroscience and empirical psychology on how the brain processes beauty, pleasure, and emotion. The aim is to treat specific visual and sonic choices as predictable perceptual responses rather than mostly matters of taste. The majority of aesthetic choices in all domains of art and design will always be subjective, frequently fickle, and driven by perspective, but there are plenty of opportunities to enhance the impact of designs with identifiable visual, aural, and tactile characteristics that produce predictable sensory responses across the general population.
I implemented a set of five neuroaesthetic design elements for this experience that are derived from empirical findings on how specific visual and audio characteristics shape perception and emotion, in several cases down to measurable physiological response. The elements play various roles that include enhancement of signals/feedback that communicate gameplay events and state; inducing the emotional state of awe; rewarding player achievements, and adding ambiguous narrative.

Technical Art

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.

Procedural Planet Surface Generation In Blender
Planets in each level are obviously a key component to the game, and designing the surface and atmosphere art without any procedural or computational design interface would be an absolute nightmare of a process. So I implemented two pieces of pipeline support: procedural node graphs to generate terrain features, and a Python script to quickly export the diffuse, normal, roughness, opacity, and emission textures. These are essential for establishing a scalable process for building multiple levels for the game that is also capable of producing infinitely unique pieces of art.
Procedural Geometry Generation in Blender
The geometry of the 3D models for many of the hazards/obstacles in this vertical slice is more complicated than usual. The visual style of the game is a mix of realism, speculative sci-fi, and fantasy, so rather than always crashing into one continuous surface (like one big asteroid or moon), I wanted hazards that look like gigantic debris fields composed of alien-looking alloys and rocky material frozen in orbit around the star.
That meant creating single 3D models with geometry meant to look like thousands of pieces of material in a cluster, and procedural generation is much more suitable for that work. In Blender, I made it all happen by (1) creating a custom geometry node graph to scatter geometry across shapes (2) manually drawing low-poly versions for collider shapes and (3) writing a script to make it a bit faster to realize instances and apply modifiers for export into Reality Composer Pro.
Programming VFX Scenes
I have a class of VFX sequences that required some crafty thinking for mixing animations created in Blender with particle systems and objects in Reality Composer Pro. These effects are triggered as the player progresses through the level and have different playback parameters based on what I authored for the progression milestone.
‍
One is a simple particle system burst with parameters for the number of bursts and timing, another is playing back a Blender-authored animation scene, and the last is a mix of Blender-authored animation scene playback plus particle system playback. The programming for this system is mostly Swift’s RealityKit.
Another nifty little system I put together is lightning storm VFX for planets that have atmospheres. It’s basically a RealityKit component that holds the parameters for the storm, a light component authored in Reality Composer Pro, and some Swift to manage runtime. And also a function that makes it really easy to set up markers around the planet for where the lightning strikes occur: necessary for pinning the strikes to areas that have storm clouds.
‍
And a last bit of easy programming is the explosion when the spacecraft collides with anything. I had to keep it simple to fit into my production schedule, but it’s a good effect that is extra nice with the depth and immersion of VR.

Particle system bursts and Blender-authored scene + particle systems at progress milestone; Blender-authored scene + particle system on spacecraft transit


Atmospheric storm VFX.


Spacecraft explosion VFX.

Lighting
‍Lighting was almost a very simple setup. It’s a space game, so the most important piece of lighting is the star at the center of the system blasting light onto the planets and other objects, creating that dramatic half brightly lit, half completely dark pattern on their surfaces.
‍
The small but big issue I ran into is that when the immersive gameplay scene is in a mixed immersion mode for the Vision Pro, environmental lighting from your physical surroundings is injected into the scene, which mostly cancels out the lighting and realism I intended.
So I did some research to figure out how to suppress the mixed reality environment lighting (also found out that it occurs in full immersion in some situations), and added functions to remove the system-supplied lighting and keep only the lighting I created for the scene.
/// 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")
    }
Reality Composer Pro Shader Graphs
‍There is quite a bit of shader graph work in Reality Composer Pro for this project. Work that is a good bit more difficult given that Reality Composer Pro is not nearly as widely used and documented as Unity, Unreal, etc.
‍
The most important shader graphs are for assembling textures for the planets and hazards, plus a few animated VFX for gas clouds and planet rings.
Gas Cloud Shader Graph
Shader Graph For Assembling Planet Textures
Planet Rings Shader Graph

Engineering

For me and my work, code is another design tool that I use to render ideas, test assumptions, and demonstrate features. I typically code prototypes for design purposes, but this is a case where I’m taking the prototype all the way to production release. AI tools, mainly Claude, plus years of building immersive apps are what made it possible to cover a wide range of design work and ship the product.
The current version is a "vertical slice" of the full gameplay loop, and my engineering was focused on feature progress over product completeness so
that I could test ideas thoroughly before spending effort on polish.
Overview Of The Gameplay Loop
The gameplay loop is split into two areas to keep the per-frame simulation performant: ECS systems handle the continuous physics of the scene (planet gravity, spacecraft movement, collisions) while UI and audio respond to discrete events outside that update path, so they never compete with the frame budget.
The Full Architecture
If you're interested in more detail, there are three ECS systems handling gravity, atmospheric storms, and spatial audio; eleven controllers under a root coordinator; and a set of components authored in Reality Composer Pro that the ECS and controllers share.
A Few More Important But Low Effort Items To Tackle
  • The gameplay controller group is currently called from the immersive view that houses game levels, and that data won't persist when the view is closed. So I need to do a bit of refactoring for situations where the immersive view might get closed by system behaviors (e.g. a user opening the device start menu will close the immersive view and lose any progress).
  • Redefine a few systems: an event bus that doesn't get used as much as I expected, and a frame sync component that has much less scope after taking a different approach to solve a clock issue.
  • And find a fix for a UX problem caused by a visionOS behavior: a window opening up over immersive content and obscuring the view of a key item right at the start of gameplay.

Outcomes

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:

  • Making very specific step-by-step requests organized into topical threads in a Claude project. Typically, I'd frame a problem and specify output formats (show options, make a step-by-step implementation guide with code diffs, or output a document). Then implement and come back for adjustments.
  • Using Claude Code via a connection in Xcode for rote tasks, like cleaning-up code, and fixing smaller syntax errors.
  • Sending screenshots to have Claude analyze layouts, read documentation, and debug animations.