
Axiom Games
- 585 installs
- 1.1k repo stars
- Updated August 3, 2026
- charleswiltgen/axiom
axiom-games is an agent skill that guides iOS, iPadOS, visionOS, and macOS game development with SpriteKit, SceneKit, and GameplayKit for developers building native Apple platform games.
About
axiom-games is a charleswiltgen/axiom agent skill for building native games on Apple platforms including iOS, iPadOS, visionOS, and macOS. It applies agent-guided patterns across SpriteKit 2D rendering, SceneKit 3D scenes, and GameplayKit systems for entities, AI, and game logic. Developers reach for axiom-games when scaffolding gameplay loops, scene graphs, physics, or cross-device Apple game targets without spelunking scattered framework documentation alone. The skill fits multi-phase game work spanning prototype mechanics through platform-specific polish, and assumes Swift-oriented Apple game frameworks rather than cross-engine exports. Teams building visionOS or multiplatform Mac/iOS titles benefit from consolidated patterns for scene lifecycle, asset pipelines, and gameplay component architecture under the Axiom skill family.
- SpriteKit and SceneKit patterns
- GameplayKit systems guidance
- Platform-specific game input
- Performance-minded Apple game APIs
- Agent-ready game architecture tips
Axiom Games by the numbers
- 585 all-time installs (skills.sh)
- Ranked #41 of 247 Game Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-gamesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 585 |
|---|---|
| repo stars | ★ 1.1k |
| Last updated | August 3, 2026 |
| Repository | charleswiltgen/axiom ↗ |
How do you build SpriteKit games for Apple platforms?
Building iOS, iPadOS, visionOS, or macOS games with SpriteKit, SceneKit, GameplayKit, and Apple game frameworks using agent-guided patterns.
Who is it for?
Swift game developers building 2D or 3D titles on iOS, iPadOS, visionOS, or macOS with Apple game frameworks.
Skip if: Unity or Unreal cross-platform games, Android-only titles, or server-authoritative multiplayer backend work.
When should I use this skill?
A developer builds or debugs SpriteKit, SceneKit, or GameplayKit code for Apple platform games.
What you get
SpriteKit or SceneKit scene setups, GameplayKit entity systems, and platform-targeted game architecture patterns.
- game scene architecture
- GameplayKit entity systems
- platform-specific game scaffolding
Files
Games
You MUST use this skill for ANY game development, SpriteKit, SceneKit, RealityKit, touch controls, game controller, or interactive simulation work.
Quick Reference
| Symptom / Task | Reference |
|---|---|
| Building a SpriteKit game | See skills/spritekit.md |
| Adding touch controls to a game (TouchController) | See skills/game-input.md |
| Game controller input (GCController, polling vs handlers) | See skills/game-input.md |
Controller Home button settings, spatial accessories OS27 | See skills/game-input.md |
| SpriteKit API lookup | See skills/spritekit-ref.md |
| Physics contacts not firing | See skills/spritekit-diag.md |
| Frame rate drops (SpriteKit) | See skills/spritekit-diag.md |
| Touches not registering | See skills/spritekit-diag.md |
| Memory spikes in gameplay | See skills/spritekit-diag.md |
| Coordinate confusion | See skills/spritekit-diag.md |
| Scene transition crashes | See skills/spritekit-diag.md |
| Objects tunneling through walls | See skills/spritekit-diag.md |
| SpriteKit node/action reference | See skills/spritekit-ref.md |
| SceneKit maintenance/migration | See axiom-graphics (skills/scenekit.md) |
| SceneKit API / migration mapping | See axiom-graphics (skills/scenekit-ref.md) |
| RealityKit (3D, ECS, AR) | See axiom-graphics (skills/realitykit.md) |
| RealityKit API reference | See axiom-graphics (skills/realitykit-ref.md) |
| RealityKit diagnostics | See axiom-graphics (skills/realitykit-diag.md) |
| RealityKit 27 game features (navmesh, LOD, splats) | See axiom-graphics (skills/realitykit-ref.md Part 10) |
| Long-session game performance tracing (metalperftrace) | See axiom-graphics (skills/display-performance.md Part 12) |
| Porting a Mac/Windows game to Metal | See axiom-graphics (skills/metal-migration.md) |
| In-game content, asset packs, IAP | See axiom-integration (skills/background-assets.md, skills/in-app-purchases.md) |
External Routes
These topics are part of the broader games/3D domain but live in separate skill suites:
SceneKit (3D — soft-deprecated iOS 26):
- Maintenance and migration planning → See axiom-graphics (skills/scenekit.md)
- API reference and migration mapping → See axiom-graphics (skills/scenekit-ref.md)
RealityKit (3D — modern):
- ECS architecture, AR, SwiftUI integration → See axiom-graphics (skills/realitykit.md)
- API reference → See axiom-graphics (skills/realitykit-ref.md)
- Troubleshooting → See axiom-graphics (skills/realitykit-diag.md)
- 27-cycle game-engine layer: navigation mesh + pathfinding, behavior trees, animation graphs, LOD, Gaussian splats → See axiom-graphics (skills/realitykit-ref.md Part 10)
Game performance and porting:
- Long-session game traces, metalperftrace CLI, Game Performance Overview template → See axiom-graphics (skills/display-performance.md Part 12)
- Metal migration, shader conversion → See axiom-graphics (skills/metal-migration.md)
In-game content and monetization:
- Apple-Hosted Background Assets, asset packs, Steam depot conversion → See axiom-integration (skills/background-assets.md)
- In-app purchase, StoreKit views → See axiom-integration (skills/in-app-purchases.md)
Decision Tree
digraph games {
start [label="Game development" shape=ellipse];
what [label="Which framework?" shape=diamond];
sprite_what [label="SpriteKit need?" shape=diamond];
start -> what;
what -> sprite_what [label="SpriteKit (2D)"];
what -> "skills/game-input.md" [label="touch controls / game controllers"];
what -> "axiom-graphics/scenekit" [label="SceneKit (3D legacy)"];
what -> "axiom-graphics/realitykit" [label="RealityKit (3D modern)"];
what -> "axiom-graphics/metal-migration" [label="porting (Metal)"];
sprite_what -> "skills/spritekit.md" [label="architecture/patterns"];
sprite_what -> "skills/spritekit-ref.md" [label="API lookup"];
sprite_what -> "skills/spritekit-diag.md" [label="broken/slow"];
}1. Building/designing a 2D SpriteKit game? → skills/spritekit.md 2. How to use a specific SpriteKit API? → skills/spritekit-ref.md 3. SpriteKit broken or performing badly? → skills/spritekit-diag.md 4. Adding touch controls, or handling game controller input? → skills/game-input.md 5. Maintaining existing SceneKit code? → See axiom-graphics (skills/scenekit.md) 6. SceneKit API reference or migration mapping? → See axiom-graphics (skills/scenekit-ref.md) 7. Building new 3D game or experience? → See axiom-graphics (skills/realitykit.md) 8. How to use a specific RealityKit API? → See axiom-graphics (skills/realitykit-ref.md) 9. RealityKit entity not visible, gestures broken, performance? → See axiom-graphics (skills/realitykit-diag.md) 10. Migrating SceneKit to RealityKit? → See axiom-graphics (skills/scenekit.md) (migration tree) + See axiom-graphics (skills/scenekit-ref.md) (mapping table) 11. Building AR game? → See axiom-graphics (skills/realitykit.md) 12. Porting a Mac/Windows game to Apple platforms? → See axiom-graphics (skills/metal-migration.md) 13. Unlocking in-game content (asset packs, IAP)? → See axiom-integration (skills/background-assets.md, skills/in-app-purchases.md) 14. Want automated SpriteKit code scan? → spritekit-auditor agent
Automated Scanning
SpriteKit audit → Launch spritekit-auditor agent or /axiom:audit spritekit
Detects anti-patterns AND architectural gaps:
- Physics bitmask issues (default
0xFFFFFFFF, missingcontactTestBitMask, magic numbers) - Draw call waste (
SKShapeNodein gameplay, missing texture atlases) - Node accumulation and runaway spawn-in-
update()without cleanup - Action memory leaks (strong
self,.repeatForeverwithoutwithKey:) - Coordinate confusion (
touch.location(in: self.view)instead ofin: self) - Silent input dead zones (custom
touchesBeganwithoutisUserInteractionEnabled) - Missing object pooling for hot spawns
- Missing/ungated debug overlays
- Leaked scenes (transition without
removeAllActions()and child cleanup) - Missing time-step clamping → spiral-of-death teleports bodies through walls
- HUD attached to scene root instead of camera (drifts with scrolling)
- Fast bodies without
usesPreciseCollisionDetection(tunneling)
Scores: PERFORMANT / DEGRADED / UNPLAYABLE
Critical Patterns
SpriteKit (skills/spritekit.md):
- PhysicsCategory struct with explicit bitmasks (default
0xFFFFFFFFcauses phantom collisions) - Camera node pattern for viewport + HUD separation
- SKShapeNode pre-render-to-texture conversion
[weak self]in allSKAction.runclosures- Delta time with spiral-of-death clamping
SpriteKit diagnostics (skills/spritekit-diag.md):
- 5-step bitmask checklist (2 min vs 30-120 min guessing)
- Debug overlays as mandatory first diagnostic step
- Tunneling prevention flowchart
- Memory growth diagnosis via
showsNodeCounttrending
Game input (skills/game-input.md):
- One GCController pipeline — touch controls surface as a
GCController, existing logic unchanged TCTouchController.isSupportedgate + connect/render/touch-routing setup- Half-screen collider shapes for movement and camera (never small circles)
- Safe-area-adjusted anchor offsets for fullscreen games
- Controller Home button settings + visionOS spatial accessories
OS27
Anti-Rationalization
| Thought | Reality |
|---|---|
| "SpriteKit is simple, I don't need a skill" | Physics bitmasks default to 0xFFFFFFFF and cause phantom collisions. The bitmask checklist catches this in 2 min. |
| "I'll just use SKShapeNode, it's quick" | Each SKShapeNode is a separate draw call. 50 of them = 50 draw calls. spritekit.md has the pre-render-to-texture pattern. |
| "I can figure out the coordinate system" | SpriteKit uses bottom-left origin (opposite of UIKit). Anchor points add another layer. spritekit-diag.md Symptom 6 resolves in 5 min. |
| "Physics is straightforward" | Three different bitmask properties, modification rules inside callbacks, and tunneling edge cases. spritekit.md Section 3 covers all gotchas. |
| "The performance is fine on my device" | Performance varies dramatically across devices. spritekit.md Section 6 has the debug overlay checklist. |
| "SceneKit is fine for our new project" | SceneKit is soft-deprecated iOS 26. No new features, only security patches. axiom-graphics (skills/scenekit.md) has the migration decision tree. |
| "ECS is overkill for a simple 3D app" | You're already using ECS — Entity + ModelComponent. axiom-graphics (skills/realitykit.md) shows how to scale from simple to complex. |
| "I don't need collision shapes for taps" | RealityKit gestures require CollisionComponent. axiom-graphics (skills/realitykit-diag.md) diagnoses this in 2 min vs 30 min guessing. |
| "I'll overlay UIButtons for touch controls" | TouchController renders in your Metal pass and surfaces as a GCController — existing controller logic just works. UIButton overlays mean a second input path and per-frame UIKit cost. game-input.md Section 2. |
| "Touch controls = put every controller button on screen" | A 1:1 mapping clutters the play area and demands 3+ fingers. game-input.md Section 5 has the redesign patterns (context icons, hide unused, collapse combos). |
Example Invocations
User: "I'm building a SpriteKit game" → See skills/spritekit.md
User: "My physics contacts aren't firing" → See skills/spritekit-diag.md
User: "How do I create a physics body from a texture?" → See skills/spritekit-ref.md
User: "Frame rate is dropping in my game" → See skills/spritekit-diag.md
User: "What action types are available?" → See skills/spritekit-ref.md
User: "Objects pass through walls" → See skills/spritekit-diag.md
User: "I'm porting my controller game to iPhone and need touch controls" → See skills/game-input.md
User: "How do I handle game controller input?" → See skills/game-input.md
User: "How do I read spatial accessory input on visionOS?" → See skills/game-input.md
User: "I need to build a 3D game" → Invoke: See axiom-graphics (skills/realitykit.md)
User: "I'm migrating from SceneKit to RealityKit" → Invoke: See axiom-graphics (skills/scenekit.md) + See axiom-graphics (skills/scenekit-ref.md)
User: "Can you scan my SpriteKit code for common issues?" → Launch spritekit-auditor agent
Game Input: Touch Controls and Game Controllers
Guide to player input for games on Apple platforms: on-screen touch controls with the TouchController framework (iOS/iPadOS), unified GCController handling, and the GameController framework's 27-cycle additions.
When to Use This Skill
- Adding touch controls to a controller-based game (including Mac/console ports)
- Designing on-screen control layouts that adapt across iPhone and iPad
- Handling game controller input (polling vs change handlers)
- Letting players customize the controller Home button action
OS27 - Reading spatial accessory input
visionOS27
Do NOT use this skill for
- Touch handling inside SpriteKit scenes (
touchesBegangameplay logic) →skills/spritekit.mdSection 5 - General app gestures, SwiftUI gesture recognizers → axiom-swiftui
- Porting the game itself (rendering, shaders) → axiom-graphics (skills/metal-migration.md)
1. Mental Model: One GCController Pipeline
The TouchController framework (iOS 26+, iPhone/iPad only — not macCatalyst, visionOS, tvOS, or macOS) builds on top of the GameController framework. When you enable a touch controller, it shows up as a `GCController` object: you poll its state or set value-changed handlers exactly like a physical controller. Game logic written against GCController needs no changes — touch controls are an input source, not a second input system.
UIKit touches → TCTouchController → GCController profile → your existing game logic
↓
Metal render pass (controls drawn by render(using:))The touch controller identifies itself with the product category constant TCGameControllerProductCategoryTouchController on its GCController.
| Layer | Role |
|---|---|
GCController | Unified input: physical controllers AND touch controller |
TCTouchController | Owns on-screen controls, converts touches to controller input |
TC*Descriptor | Configures a control before creation (label, anchor, collider, contents) |
| Your Metal renderer | Draws the controls each frame via render(using:) |
2. Setup
Three integration points: create + connect the controller, route UIKit touches to it, and render its controls in your Metal pass.
import TouchController
import GameController
final class GameView: MTKView {
private(set) var touchController: TCTouchController?
func setUpTouchControls() {
guard TCTouchController.isSupported else { return }
let descriptor = TCTouchControllerDescriptor(mtkView: self)
let controller = TCTouchController(descriptor: descriptor)
controller.connect() // surfaces as a GCController; disconnect() removes it
touchController = controller
}
// Route UIKit touches; do the same in touchesMoved/touchesEnded
// with handleTouchMoved(at:index:) / handleTouchEnded(at:index:)
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
for touch in touches {
touchController?.handleTouchBegan(at: touch.location(in: self),
index: touch.hash)
}
}
// In your draw loop, after game rendering
func drawControls(using encoder: MTLRenderCommandEncoder) {
touchController?.render(using: encoder)
}
}Where the GCController Comes From
Touch and physical controllers arrive through the same discovery path. Observe connects, sweep already-connected controllers, and bind handlers once:
private var gameController: GCController?
func observeControllers() {
NotificationCenter.default.addObserver(
self, selector: #selector(controllerDidConnect(_:)),
name: .GCControllerDidConnect, object: nil)
for controller in GCController.controllers() {
configure(controller)
}
}
@objc private func controllerDidConnect(_ note: Notification) {
guard let controller = note.object as? GCController else { return }
configure(controller)
}
private func configure(_ controller: GCController) {
// Distinguish the touch controller from physical hardware if needed
let isTouch = controller.productCategory ==
TCGameControllerProductCategoryTouchController
gameController = controller
guard let gamepad = controller.extendedGamepad else { return }
gamepad.buttonA.valueChangedHandler = { (button: GCControllerButtonInput,
value: Float, pressed: Bool) in
// same handler for touch and physical input
}
}Input then arrives through standard GameController patterns — polling (button.isPressed) or change handlers as above. For a quick start without manual placement, automaticallyLayoutControls(for:) lays out a set of labeled controls with system defaults.
Later snippets assume an unwrapped touchController for brevity — guard the optional as in setUpTouchControls().
3. Control Catalog
Every control follows the same pattern: configure a descriptor → add*(descriptor:) → the returned control is live. Remove with removeControl(_:) or removeAllControls().
| Control | Descriptor | Typical use |
|---|---|---|
TCButton | TCButtonDescriptor | Actions, QTEs |
TCSwitch | TCSwitchDescriptor | Toggles (sticky pressed state) |
TCThumbstick | TCThumbstickDescriptor | Movement, camera |
TCDirectionPad | TCDirectionPadDescriptor | Discrete 4/8-way input |
TCThrottle | TCThrottleDescriptor | Analog 1-axis (vehicles, flight) |
TCTouchpad | TCTouchpadDescriptor | Absolute or relative pointer-style input |
Labels map controls to controller elements. TCControlLabel provides statics for the standard gamepad: .buttonA/.buttonB/.buttonX/.buttonY, .buttonMenu, .buttonOptions, .buttonLeftShoulder/.buttonLeftTrigger (and right), .leftThumbstick/.leftThumbstickButton (and right), .directionPad. A control labeled .buttonB delivers input as physical button B — existing handler code just works. For game-specific controls, create a custom label: TCControlLabel(name: "escape_button", role: .button) (roles: .button, .directionPad).
let buttonBDesc = TCButtonDescriptor()
buttonBDesc.label = .buttonB
buttonBDesc.anchor = .bottomRight
buttonBDesc.offset = CGPoint(x: -35, y: -106)
buttonBDesc.contents = .buttonContents(forSystemImageNamed: "b.circle",
size: buttonBDesc.size, shape: .circle,
controller: touchController)
touchController.addButton(descriptor: buttonBDesc)Descriptors also carry size, zIndex, colliderShape, highlightDuration, and anchorCoordinateSystem.
4. Flexible Layout
TCControlLayoutAnchor provides nine anchor points — .topLeft, .topCenter, .topRight, .centerLeft, .center, .centerRight, .bottomLeft, .bottomCenter, .bottomRight — and each control's offset is relative to its anchor. Group related controls on the same anchor so sections keep consistent size and spacing as the device shape changes. anchorCoordinateSystem chooses .relative (scales with screen size; equals absolute on small devices) or .absolute (fixed distance from edges).
Respect safe areas. Rounded corners, the home indicator, and the Dynamic Island can obscure tap targets. Fold safeAreaInsets from your view into each offset:
func adjustedOffset(_ offset: CGPoint, for anchor: TCControlLayoutAnchor) -> CGPoint {
var (x, y) = (offset.x, offset.y)
switch anchor {
case .bottomRight:
x -= safeAreaInsets.right
y -= safeAreaInsets.bottom
default:
break // adjust the other anchors you use
}
return CGPoint(x: x, y: y)
}Placement guidance (WWDC 2026-358): keep the screen center clear of controls (that's the play area); thumb-reach regions near the bottom corners get frequent actions; the top edge gets infrequent controls like menus; avoid regions where movement or camera gestures happen.
5. Fluid Interaction Patterns
A one-to-one copy of the physical controller clutters the screen. These patterns, from WWDC 2026-358, make touch controls feel native.
Context-Sensitive Icons
On-screen buttons can change appearance to show their current function — swap contents when the action changes:
func setButtonBContents(symbolName: String) {
for button in touchController.buttons where button.label == .buttonB {
button.contents = .buttonContents(forSystemImageNamed: symbolName,
size: buttonSize, shape: .circle,
controller: touchController)
}
}Hide What Players Can't Use
| Situation | Mechanism |
|---|---|
| Thumbstick idle | hidesWhenNotPressed = true on the descriptor |
| Control temporarily irrelevant (fixed position) | isEnabled = false to hide, true to show |
| Control appears at varying positions (pick-up prompt) | addButton(descriptor:) at the projected position, removeControl(_:) to dismiss |
Transient control sets (a power wheel opened by a button press) can auto-dismiss with structured concurrency:
func openPowerWheel() {
showPowerWheelButtons()
dismissTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(3))
guard let self, self.powerWheelActive, !Task.isCancelled else { return }
self.closePowerWheel()
}
}Half-Screen Input Regions
Players can't feel where a virtual stick is, so expand its hit area. colliderShape accepts .circle, .rect, .leftSide, or .rightSide:
let leftStickDesc = TCThumbstickDescriptor()
leftStickDesc.label = .leftThumbstick
leftStickDesc.colliderShape = .leftSide // entire left half responds
leftStickDesc.hidesWhenNotPressed = true
touchController.addThumbstick(descriptor: leftStickDesc)Sprint from Tilt Magnitude
Hold-stick-button-while-moving needs two fingers on glass. Fold the modifier into the stick itself — read tilt magnitude from the unified GCController:
func pollInput() {
guard let gamePad = gameController?.extendedGamepad else { return }
let stick = gamePad.leftThumbstick
let move = simd_make_float2(stick.xAxis.value, -stick.yAxis.value)
runModifier = simd_length(move) > 0.8 ? 1.3 : 1.0
characterDirection = move
}Touchpad Camera
Mapping the right thumbstick directly to camera rotation over-rotates and feels sluggish on touch. A touchpad with relative values moves the camera exactly as far as the finger moves:
let touchpadDesc = TCTouchpadDescriptor()
touchpadDesc.label = .rightThumbstick // reuses existing camera logic
touchpadDesc.colliderShape = .rightSide
touchpadDesc.reportsRelativeValues = true // position-independent
touchController.addTouchpad(descriptor: touchpadDesc)Collapse Multi-Finger Combos
- Quick-time events: replace "hold L1+R1" with a single custom-label button shown only during the event (
isEnabledtoggle). - Aim and release: one button does both — fire on release in its
valueChangedHandler, and accumulate aim from raw touch deltas intouchesMovedwhile held (touch deltas are tracked independently of the button's pressed state).
6. Feedback
Every touch control needs a visible pressed state. The framework provides this by default — thumbsticks animate, buttons highlight (highlightDuration tunes it). For stronger feedback, build TCControlContents manually — it is an array of TCControlImage layers:
let haloLayer = TCControlImage(texture: haloTexture, size: haloSize,
highlight: nil, offset: .zero,
tintColor: tint)
let normalContents = TCControlContents.thumbstickStickBackgroundContents(
size: bgSize, controller: touchController)
let haloContents = TCControlContents(images: [haloLayer] + normalContents.images)
thumbstick.backgroundContents = isSprinting ? haloContents : normalContentsTCControlImage also has conveniences init(cgImage:size:device:) and init(uiImage:size:device:) (failable) when you don't already have an MTLTexture.
7. GameController Additions OS27
Controller Home Button Settings (not tvOS)
The system lets players assign an action to a long press of the controller Home button (the logo button on PlayStation/Xbox controllers). GCControllerHomeButtonSettingsManager lets your game partially inspect the configured action (the .other case masks anything beyond "opens this app") and open the Settings screen where players change it — useful for an in-game "customize controller shortcut" entry point.
@available(iOS 27, macOS 27, visionOS 27, *)
func reviewHomeButtonAction() throws {
guard let manager = GCControllerHomeButtonSettingsManager() else { return }
manager.settingsDidChangeHandler = { /* re-read on change */ }
let action = try manager.readControllerHomeButtonAction()
if action != .openCurrentApplication {
// Opens the Settings screen where the player edits the long-press
// action (.customizeAction) or disables system actions while your
// app has focus (.customizeOverrides)
try manager.openControllerHomeButtonSettings(for: .customizeAction)
}
}Action cases: .unavailable, .openCurrentApplication, .other, and .disabled (macOS only). Operations are only permitted while a game controller is connected.
Also in the 27 SDKs: GCControllerElement.SystemGestureState.alwaysReceive is deprecated — prefer .disabled (via preferredSystemGestureState) when your game needs full control of an element bound to a system gesture, such as the Options-button screenshot long press.
Spatial Accessories visionOS27
visionOS 27 generalizes tracked game accessories behind GCSpatialAccessory (a GCDevice). Enumerate with GCSpatialAccessory.spatialAccessories and observe connects/disconnects with typed NotificationCenter messages — keep the observation token alive in a property:
@available(visionOS 27, *)
@MainActor
final class AccessoryCoordinator {
private var connectToken: NotificationCenter.ObservationToken?
func start() {
for accessory in GCSpatialAccessory.spatialAccessories {
configure(accessory)
}
connectToken = NotificationCenter.default.addObserver(
of: GCSpatialAccessory.self, for: .didConnect) { [weak self] message in
self?.configure(message.spatialAccessory)
}
}
private func configure(_ accessory: GCSpatialAccessory) {
_ = accessory.input // GCDevicePhysicalInput profile
_ = accessory.haptics // CHHapticEngine creation, if supported
}
}When you hold a generic GCDevice rather than the enumeration above, test capabilities with conforms(to:) against a GCDeviceType such as .spatialAccessory.
To align buffered input with the accessory's tracked pose, pass an ARKit accessory anchor timestamp (ar_accessory_anchor_get_timestamp) to inputState(forSpatialAccessoryAnchorTimestamp:) — it returns the buffered input state closest to that anchor sample. This requires a running ARKit session with accessory tracking; the timestamp comes from the accessory anchors it delivers.
Gotchas
| Gotcha | Fix |
|---|---|
| Touch controls never appear | You must call render(using:) in your Metal pass each frame — the framework draws nothing on its own |
| Controls appear but never respond | UIKit touches aren't routed: override touchesBegan/Moved/Ended and call the matching handleTouch*(at:index:) |
| Stale touch controller after teardown | Pair every connect() with disconnect() — the touch controller stays registered as a GCController until disconnected |
| Controls clipped by Dynamic Island / home indicator | Fold safeAreaInsets into anchor offsets (Section 4) |
| Virtual stick feels cramped | Set colliderShape = .leftSide/.rightSide — never leave a small .circle collider on a movement stick |
| Camera over-rotates on touch | Use TCTouchpad with reportsRelativeValues = true instead of mapping the right stick directly |
GCControllerHomeButtonSettingsManager calls fail | Only permitted while a game controller is connected; init is failable — guard it |
Resources
WWDC: 2026-358
Docs: /touchcontroller, /gamecontroller, /gamecontroller/gccontroller, /gamecontroller/gcspatialaccessory
Skills: skills/spritekit.md, axiom-graphics (skills/metal-migration.md), axiom-graphics (skills/display-performance.md)
SpriteKit Diagnostics
Systematic diagnosis for common SpriteKit issues with time-cost annotations.
When to Use This Diagnostic Skill
Use this skill when:
- Physics contacts never fire (didBegin not called)
- Objects pass through walls (tunneling)
- Frame rate drops below 60fps
- Touches don't register on nodes
- Memory grows continuously during gameplay
- Positions and coordinates seem wrong
- App crashes during scene transitions
Mandatory First Step: Enable Debug Overlays
Time cost: 10 seconds setup vs hours of blind debugging
if let view = self.view as? SKView {
view.showsFPS = true
view.showsNodeCount = true
view.showsDrawCount = true
view.showsPhysics = true
}If showsPhysics doesn't show expected physics body outlines, your physics bodies aren't configured correctly. Stop and fix bodies before debugging contacts.
Read the Overlay as Diagnostic Gates
The overlay numbers are gates, not vibes. Read them before guessing:
| Overlay | Healthy | Gate (act now) | Points to |
|---|---|---|---|
showsDrawCount | < 10 | > ~50 | Batching problem (Symptom 3) |
showsNodeCount | stable | climbing, or > ~1000 | Missing culling/pooling (Symptoms 3, 5) |
showsFPS | 60 / 120 | sustained dips | Confirms a real frame budget issue |
A climbing showsNodeCount that never plateaus is the single clearest signal of "never remove nodes" damage — node count is unbounded, so the slowdown is too.
For SpriteKit architecture patterns and best practices, see skills/spritekit.md. For API reference, see skills/spritekit-ref.md.
---
Symptom 1: Physics Contacts Not Firing
Time saved: 30-120 min → 2-5 min
didBegin(_:) never called
│
├─ Is physicsWorld.contactDelegate set?
│ └─ NO → Set in didMove(to:):
│ physicsWorld.contactDelegate = self
│ ✓ This alone fixes ~30% of contact issues
│
├─ Does the class conform to SKPhysicsContactDelegate?
│ └─ NO → Add conformance:
│ class GameScene: SKScene, SKPhysicsContactDelegate
│
├─ Does body A have contactTestBitMask that includes body B's category?
│ ├─ Print: "A contact: \(bodyA.contactTestBitMask), B cat: \(bodyB.categoryBitMask)"
│ ├─ Result should be: (A.contactTestBitMask & B.categoryBitMask) != 0
│ └─ FIX: Set contactTestBitMask to include the other body's category
│ player.physicsBody?.contactTestBitMask = PhysicsCategory.enemy
│
├─ Is categoryBitMask set (not default 0xFFFFFFFF)?
│ ├─ Default category means everything matches — but in unexpected ways
│ └─ FIX: Always set explicit categoryBitMask for each body type
│
├─ Do the bodies actually overlap? (Check showsPhysics)
│ ├─ Bodies too small or offset from sprite → Fix physics body size
│ └─ Bodies never reach each other → Check collisionBitMask isn't blocking
│
└─ Are you modifying the world inside didBegin?
├─ NEVER mutate the node tree mid-contact-resolution. Removing a
│ body's node inside didBegin can drop sibling callbacks for the
│ same frame or crash (EXC_BAD_ACCESS) — the physics engine is
│ still iterating the contact set you just edited.
└─ FIX: Flag nodes in didBegin, reap them in update(_:):
func didBegin(_ contact: SKPhysicsContact) {
contact.bodyA.node?.userData = ["dead": true] // flag only
}
override func update(_ currentTime: TimeInterval) {
children.filter { ($0.userData?["dead"] as? Bool) == true }
.forEach { $0.removeFromParent() } // reap here
}Quick Diagnostic Print
func didBegin(_ contact: SKPhysicsContact) {
print("CONTACT: \(contact.bodyA.node?.name ?? "nil") (\(contact.bodyA.categoryBitMask)) <-> \(contact.bodyB.node?.name ?? "nil") (\(contact.bodyB.categoryBitMask))")
}If this never prints, the issue is delegate/bitmask setup. If it prints but with wrong bodies, the issue is bitmask values.
---
Symptom 2: Objects Tunneling Through Walls
Time saved: 20-60 min → 5 min
Fast objects pass through thin walls
│
├─ Is the object moving faster than wall thickness per frame?
│ ├─ At 60fps: max safe speed = wall_thickness × 60 pt/s
│ ├─ A 10pt wall is safe up to ~600 pt/s
│ └─ FIX: usesPreciseCollisionDetection = true on the fast object
│
├─ Is usesPreciseCollisionDetection enabled?
│ ├─ Only needed on the MOVING object (not the wall)
│ └─ FIX: fastObject.physicsBody?.usesPreciseCollisionDetection = true
│
├─ Is the wall an edge body?
│ ├─ Edge bodies have zero area — tunneling is easier
│ └─ FIX: Use volume body for walls (rectangleOf:) with isDynamic = false
│
├─ Is the wall thick enough?
│ └─ FIX: Make walls at least 10pt thick for objects up to 600pt/s
│
└─ Are collision bitmasks correct?
├─ Wall's categoryBitMask must be in object's collisionBitMask
└─ FIX: Verify with print: object.collisionBitMask & wall.categoryBitMask != 0---
Symptom 3: Poor Frame Rate
Time saved: 2-4 hours → 15-30 min
FPS below 60 (or 120 on ProMotion)
│
├─ Check showsNodeCount
│ ├─ >1000 nodes → Offscreen nodes not removed
│ │ ├─ Are you removing nodes that leave the screen?
│ │ ├─ FIX: In update(), remove nodes outside visible area
│ │ └─ FIX: Use object pooling for frequently spawned objects
│ │
│ ├─ 200-1000 nodes → Likely manageable, check draw count
│ └─ <200 nodes → Nodes aren't the problem, check below
│
├─ Check showsDrawCount
│ ├─ >50 draw calls → Batching problem
│ │ ├─ ignoresSiblingOrder = false? → Set view.ignoresSiblingOrder = true FIRST
│ │ │ └─ This is the batching lever: it lets SpriteKit reorder
│ │ │ same-texture/same-zPosition sprites into ONE draw call.
│ │ │ An atlas with this still false batches nothing.
│ │ ├─ Using SKShapeNode for gameplay? → Replace with pre-rendered textures
│ │ ├─ Sprites from different images? → Pack into a texture atlas
│ │ │ (same atlas + same zPosition + ignoresSiblingOrder = 1 draw call)
│ │ └─ Sprites at different zPositions? → Consolidate layers
│ │
│ ├─ 10-50 draw calls → Acceptable for most games
│ └─ <10 draw calls → Drawing isn't the problem
│
├─ Physics expensive?
│ ├─ Many texture-based physics bodies → Use circles/rectangles
│ ├─ usesPreciseCollisionDetection on too many bodies → Use only on fast objects
│ ├─ Many contact callbacks firing → Reduce contactTestBitMask scope
│ └─ Complex polygon bodies → Simplify to fewer vertices
│
├─ Particle overload?
│ ├─ Multiple emitters active → Reduce particleBirthRate
│ ├─ High particleLifetime → Reduce (fewer active particles)
│ ├─ numParticlesToEmit = 0 (infinite) without cleanup → Add limits
│ └─ FIX: Profile with Instruments → Time Profiler
│
├─ SKEffectNode without shouldRasterize?
│ ├─ CIFilter re-renders every frame
│ └─ FIX: effectNode.shouldRasterize = true (if content is static)
│
└─ Complex update() logic?
├─ O(n²) collision checking? → Use physics engine instead
├─ String-based enumerateChildNodes every frame? → Cache references
└─ Heavy computation in update? → Spread across frames or backgroundQuick Performance Audit
#if DEBUG
private var frameCount = 0
#endif
override func update(_ currentTime: TimeInterval) {
#if DEBUG
frameCount += 1
if frameCount % 60 == 0 {
print("Nodes: \(children.count)")
}
#endif
}---
Symptom 4: Touches Not Registering
Time saved: 15-45 min → 2 min
touchesBegan not called on a node
│
├─ Is isUserInteractionEnabled = true on the node?
│ ├─ SKScene: true by default
│ ├─ All other SKNode subclasses: FALSE by default
│ └─ FIX: node.isUserInteractionEnabled = true
│
├─ Is the node hidden or alpha = 0?
│ ├─ Hidden nodes don't receive touches
│ └─ FIX: Check node.isHidden and node.alpha
│
├─ Is another node on top intercepting touches?
│ ├─ Higher zPosition nodes with isUserInteractionEnabled get first chance
│ └─ DEBUG: Print nodes(at: touchLocation) to see what's there
│
├─ Is the touch in the correct coordinate space?
│ ├─ Using touch.location(in: self.view)? → WRONG for SpriteKit
│ └─ FIX: Use touch.location(in: self) for scene coordinates
│ Or touch.location(in: targetNode) for node-local coordinates
│
├─ Is the physics body blocking touch pass-through?
│ └─ Physics bodies don't affect touch handling — not the issue
│
└─ Is the node's frame correct?
├─ SKNode (container) has zero frame — can't be hit-tested by area
├─ SKSpriteNode frame matches texture size × scale
└─ FIX: Use contains(point) or nodes(at:) for manual hit testing---
Symptom 5: Memory Spikes and Crashes
Time saved: 1-3 hours → 15 min
Memory grows during gameplay
│
├─ Nodes accumulating? (Check showsNodeCount over time)
│ ├─ Count increasing? → Nodes created but not removed
│ │ ├─ Missing removeFromParent() for expired objects
│ │ ├─ FIX: Add cleanup in update() or use SKAction.removeFromParent()
│ │ └─ FIX: Implement object pooling for frequently spawned items
│ │
│ └─ Count stable? → Memory issue elsewhere
│
├─ Infinite particle emitters?
│ ├─ numParticlesToEmit = 0 creates particles forever
│ ├─ Each emitter accumulates particles up to birthRate × lifetime
│ └─ FIX: Set finite numParticlesToEmit or manually stop and remove
│
├─ Texture caching?
│ ├─ SKTexture(imageNamed:) caches — repeated calls don't leak
│ ├─ SKTexture(cgImage:) from camera/dynamic sources → Not cached
│ └─ FIX: Reuse texture references for dynamic textures
│
├─ Strong reference cycles in actions?
│ ├─ SKAction.run { self.doSomething() } captures self strongly
│ ├─ In repeatForever, this prevents scene deallocation
│ ├─ FIX: SKAction.run { [weak self] in self?.doSomething() }
│ └─ ALSO run repeatForever with a key so it stays removable:
│ node.run(action, withKey: "spawn")
│ node.removeAction(forKey: "spawn") // keyless = unstoppable
│
├─ Scene not deallocating?
│ ├─ Add deinit { print("Scene deallocated") }
│ ├─ If never prints → retain cycle
│ ├─ Common: strong delegate, closure capture, NotificationCenter observer
│ └─ FIX: Clean up in willMove(from:):
│ removeAllActions()
│ removeAllChildren()
│ physicsWorld.contactDelegate = nil
│
└─ Instruments → Allocations
├─ Filter by "SK" to see SpriteKit objects
├─ Mark generation before/after scene transition
└─ Persistent growth = leak---
Symptom 6: Coordinate Confusion
Time saved: 20-60 min → 5 min
Positions seem wrong or flipped
│
├─ Y-axis confusion?
│ ├─ SpriteKit: origin at BOTTOM-LEFT, Y goes UP
│ ├─ UIKit: origin at TOP-LEFT, Y goes DOWN
│ └─ FIX: Use scene coordinate methods, not view coordinates
│ touch.location(in: self) ← CORRECT (scene space)
│ touch.location(in: view) ← WRONG (UIKit space, Y flipped)
│
├─ Anchor point confusion?
│ ├─ Scene anchor (0,0) = bottom-left of view is scene origin
│ ├─ Scene anchor (0.5,0.5) = center of view is scene origin
│ ├─ Sprite anchor (0.5,0.5) = center of sprite is at position (default)
│ ├─ Sprite anchor (0,0) = bottom-left of sprite is at position
│ └─ FIX: Print anchorPoint values and draw expected position
│
├─ Parent coordinate space?
│ ├─ node.position is relative to PARENT, not scene
│ ├─ Child at (0,0) of parent at (100,100) is at scene (100,100)
│ └─ FIX: Use convert(_:to:) and convert(_:from:) for cross-node coordinates
│ let scenePos = node.convert(localPoint, to: scene)
│ let localPos = node.convert(scenePoint, from: scene)
│
├─ Camera offset?
│ ├─ Camera position offsets the visible area
│ ├─ HUD attached to camera stays in place
│ └─ FIX: For world coordinates, account for camera position
│ scene.convertPoint(fromView: viewPoint)
│
└─ Scale mode cropping?
├─ aspectFill crops edges — content at edges may be offscreen
└─ FIX: Keep important content in the "safe area" center---
Symptom 7: Scene Transition Crashes
Time saved: 30-90 min → 5 min
Crash during or after scene transition
│
├─ EXC_BAD_ACCESS after transition?
│ ├─ Old scene deallocated while something still references it
│ ├─ Common: Timer, NotificationCenter, delegate still referencing old scene
│ └─ FIX: Clean up in willMove(from:):
│ removeAllActions()
│ removeAllChildren()
│ physicsWorld.contactDelegate = nil
│ // Remove any NotificationCenter observers
│
├─ Crash in didMove(to:) of new scene?
│ ├─ Accessing view before it's available
│ ├─ Force-unwrapping optional that's nil during init
│ └─ FIX: Use guard let view = self.view in didMove(to:)
│
├─ Memory spike during transition?
│ ├─ Both scenes exist simultaneously during transition animation
│ ├─ For large scenes, this doubles memory usage
│ └─ FIX: Preload textures, reduce scene size, or use .fade transition
│ (fade briefly shows neither scene, reducing peak memory)
│
├─ Nodes from old scene appearing in new scene?
│ ├─ node.move(toParent:) during transition
│ └─ FIX: Don't move nodes between scenes — recreate in new scene
│
└─ didMove(to:) called twice?
├─ Presenting scene multiple times (button double-tap)
└─ FIX: Disable transition trigger after first tap
guard view?.scene !== nextScene else { return }---
Common Mistakes
These mistakes cause the majority of SpriteKit issues. Check these first before diving into symptom trees.
1. Leaving default bitmasks — collisionBitMask defaults to 0xFFFFFFFF (collides with everything). Always set all three masks explicitly. 2. Forgetting `contactTestBitMask` — Defaults to 0x00000000. Contacts never fire without setting this. 3. Forgetting `physicsWorld.contactDelegate = self` — Fixes ~30% of contact issues on its own. 4. Using SKShapeNode for gameplay — Each instance = 1 draw call. Pre-render to texture with view.texture(from:). 5. Leaving `ignoresSiblingOrder = false` — This is the batching lever. Without view.ignoresSiblingOrder = true, SpriteKit can't reorder same-texture sprites, so an atlas batches nothing and showsDrawCount stays high. 6. Mutating the node tree inside `didBegin(contact:)` — Removing a body mid-resolution drops sibling callbacks or crashes. Flag in didBegin, reap in update(_:). 7. SKAction.move on physics bodies — Actions override physics, causing jitter and missed collisions. Use forces/impulses. 8. Strong self in action closures — SKAction.run { self.foo() } in repeatForever creates retain cycles. Use [weak self], and run with withKey: so the loop stays removable. 9. Not removing offscreen nodes — Node count climbs silently, degrading performance. 10. Missing `isUserInteractionEnabled = true` — Default is false on all non-scene nodes.
---
Diagnostic Quick Reference Card
| Symptom | First Check | Most Likely Cause |
|---|---|---|
| Contacts don't fire | contactDelegate set? | Missing contactTestBitMask |
| Crash on collision | Mutating nodes in didBegin? | Reap in update(_:), not mid-contact |
| Tunneling | Object speed vs wall thickness | Missing usesPreciseCollisionDetection |
showsDrawCount > ~50 | ignoresSiblingOrder = true? | Batching lever off, or SKShapeNode/no atlas |
showsNodeCount climbing | Removing offscreen nodes? | Nodes created but never culled/pooled |
| Touches broken | isUserInteractionEnabled? | Default is false on non-scene nodes |
| Wrong positions | Y-axis direction | Using view coordinates instead of scene |
| Transition crash | willMove(from:) cleanup? | Strong references to old scene |
Resources
WWDC: 2014-608, 2016-610, 2017-609
Docs: /spritekit/skphysicsbody, /spritekit/maximizing-node-drawing-performance
Skills: skills/spritekit.md, skills/spritekit-ref.md
SpriteKit API Reference
Complete API reference for SpriteKit organized by category.
When to Use This Reference
Use this reference when:
- Looking up specific SpriteKit API signatures or properties
- Checking which node types are available and their performance characteristics
- Finding the right physics body creation method
- Browsing the complete action catalog
- Configuring SKView, scale modes, or transitions
- Setting up particle emitter properties
- Working with SKRenderer or SKShader
Part 1: Node Hierarchy
All Node Types
| Node | Purpose | Batches? | Performance Notes |
|---|---|---|---|
SKNode | Container, grouping | N/A | Zero rendering cost |
SKSpriteNode | Textured sprites | Yes (same atlas) | Primary gameplay node |
SKShapeNode | Vector paths | No | 1 draw call each — avoid in gameplay |
SKLabelNode | Text rendering | No | 1 draw call each |
SKEmitterNode | Particle systems | N/A | GPU-bound, limit birth rate |
SKCameraNode | Viewport control | N/A | Attach HUD as children |
SKEffectNode | Core Image filters | No | Expensive — cache with shouldRasterize |
SKCropNode | Masking | No | Mask + content = 2+ draw calls |
SKTileMapNode | Tile-based maps | Yes (same tileset) | Efficient for large maps |
SKVideoNode | Video playback | No | Uses AVPlayer |
SK3DNode | SceneKit content | No | Renders SceneKit scene |
SKReferenceNode | Reusable .sks files | N/A | Loads archive at runtime |
SKLightNode | Per-pixel lighting | N/A | Limits: 8 lights per scene |
SKFieldNode | Physics fields | N/A | Gravity, electric, magnetic, etc. |
SKAudioNode | Positional audio | N/A | Uses AVAudioEngine |
SKTransformNode | 3D rotation wrapper | N/A | xRotation, yRotation for perspective |
SKSpriteNode Properties
// Creation
SKSpriteNode(imageNamed: "player") // From asset catalog
SKSpriteNode(texture: texture) // From SKTexture
SKSpriteNode(texture: texture, size: size) // Custom size
SKSpriteNode(color: .red, size: CGSize(width: 50, height: 50)) // Solid color
// Key properties
sprite.anchorPoint = CGPoint(x: 0.5, y: 0) // Bottom-center
sprite.colorBlendFactor = 0.5 // Tint strength (0-1)
sprite.color = .red // Tint color
sprite.normalTexture = normalMap // For lighting
sprite.lightingBitMask = 0x1 // Which lights affect this
sprite.shadowCastBitMask = 0x1 // Which lights cast shadows
sprite.shader = customShader // Per-pixel effectsSKLabelNode Properties
let label = SKLabelNode(text: "Score: 0")
label.fontName = "AvenirNext-Bold"
label.fontSize = 24
label.fontColor = .white
label.horizontalAlignmentMode = .left
label.verticalAlignmentMode = .top
label.numberOfLines = 0 // Multi-line (iOS 11+)
label.preferredMaxLayoutWidth = 200
label.lineBreakMode = .byWordWrapping---
Part 2: Physics API
SKPhysicsBody Creation
// Volume bodies (have mass, respond to forces)
SKPhysicsBody(circleOfRadius: 20) // Cheapest
SKPhysicsBody(rectangleOf: CGSize(width: 40, height: 60))
SKPhysicsBody(polygonFrom: path) // Convex only
SKPhysicsBody(texture: texture, size: size) // Pixel-perfect (expensive)
SKPhysicsBody(texture: texture, alphaThreshold: 0.5, size: size)
SKPhysicsBody(bodies: [body1, body2]) // Compound
// Edge bodies (massless boundaries)
SKPhysicsBody(edgeLoopFrom: rect) // Rectangle boundary
SKPhysicsBody(edgeLoopFrom: path) // Path boundary
SKPhysicsBody(edgeFrom: pointA, to: pointB) // Single edge
SKPhysicsBody(edgeChainFrom: path) // Open pathPhysics Body Properties
// Identity
body.categoryBitMask = 0x1 // What this body IS
body.collisionBitMask = 0x2 // What it bounces off
body.contactTestBitMask = 0x4 // What triggers didBegin/didEnd
// Physical characteristics
body.mass = 1.0 // kg
body.density = 1.0 // kg/m^2 (auto-calculates mass)
body.friction = 0.2 // 0.0 (ice) to 1.0 (rubber)
body.restitution = 0.3 // 0.0 (no bounce) to 1.0 (perfect bounce)
body.linearDamping = 0.1 // Air resistance (0 = none)
body.angularDamping = 0.1 // Rotational damping
// Behavior
body.isDynamic = true // Responds to forces
body.affectedByGravity = true // Subject to world gravity
body.allowsRotation = true // Can rotate from physics
body.pinned = false // Pinned to parent position
body.usesPreciseCollisionDetection = false // For fast objects
// Motion (read/write)
body.velocity = CGVector(dx: 100, dy: 0)
body.angularVelocity = 0.0
// Force application
body.applyForce(CGVector(dx: 0, dy: 100)) // Continuous
body.applyImpulse(CGVector(dx: 0, dy: 50)) // Instant
body.applyTorque(0.5) // Continuous rotation
body.applyAngularImpulse(1.0) // Instant rotation
body.applyForce(CGVector(dx: 10, dy: 0), at: point) // Force at pointSKPhysicsWorld
scene.physicsWorld.gravity = CGVector(dx: 0, dy: -9.8)
scene.physicsWorld.speed = 1.0 // 0 = paused, 2 = double speed
scene.physicsWorld.contactDelegate = self
// Ray casting
let body = scene.physicsWorld.body(at: point)
let bodyInRect = scene.physicsWorld.body(in: rect)
scene.physicsWorld.enumerateBodies(alongRayStart: start, end: end) { body, point, normal, stop in
// Process each body the ray intersects
}Physics Joints
// Pin joint (pivot)
let pin = SKPhysicsJointPin.joint(
withBodyA: bodyA, bodyB: bodyB,
anchor: anchorPoint
)
// Fixed joint (rigid connection)
let fixed = SKPhysicsJointFixed.joint(
withBodyA: bodyA, bodyB: bodyB,
anchor: anchorPoint
)
// Spring joint
let spring = SKPhysicsJointSpring.joint(
withBodyA: bodyA, bodyB: bodyB,
anchorA: pointA, anchorB: pointB
)
spring.frequency = 1.0 // Oscillations per second
spring.damping = 0.5 // 0 = no damping
// Sliding joint (linear constraint)
let slide = SKPhysicsJointSliding.joint(
withBodyA: bodyA, bodyB: bodyB,
anchor: point, axis: CGVector(dx: 1, dy: 0)
)
// Limit joint (distance constraint)
let limit = SKPhysicsJointLimit.joint(
withBodyA: bodyA, bodyB: bodyB,
anchorA: pointA, anchorB: pointB
)
// Add joint to world
scene.physicsWorld.add(joint)
// Remove: scene.physicsWorld.remove(joint)Physics Fields
// Gravity (directional)
let gravity = SKFieldNode.linearGravityField(withVector: vector_float3(0, -9.8, 0))
// Radial gravity (toward/away from point)
let radial = SKFieldNode.radialGravityField()
radial.strength = 5.0
// Electric field (charge-dependent)
let electric = SKFieldNode.electricField()
// Noise field (turbulence)
let noise = SKFieldNode.noiseField(withSmoothness: 0.5, animationSpeed: 1.0)
// Vortex
let vortex = SKFieldNode.vortexField()
// Drag
let drag = SKFieldNode.dragField()
// All fields share:
field.region = SKRegion(radius: 100) // Area of effect
field.strength = 1.0 // Intensity
field.falloff = 0.0 // Distance falloff
field.minimumRadius = 10 // Inner dead zone
field.isEnabled = true
field.categoryBitMask = 0xFFFFFFFF // Which bodies affected---
Part 3: Action Catalog
Movement
SKAction.move(to: point, duration: 1.0)
SKAction.move(by: CGVector(dx: 100, dy: 0), duration: 0.5)
SKAction.moveTo(x: 200, duration: 1.0)
SKAction.moveTo(y: 300, duration: 1.0)
SKAction.moveBy(x: 50, y: 0, duration: 0.5)
SKAction.follow(path, asOffset: true, orientToPath: true, duration: 2.0)Rotation
SKAction.rotate(byAngle: .pi, duration: 1.0) // Relative
SKAction.rotate(toAngle: .pi / 2, duration: 0.5) // Absolute
SKAction.rotate(toAngle: angle, duration: 0.5, shortestUnitArc: true)Scaling
SKAction.scale(to: 2.0, duration: 0.5)
SKAction.scale(by: 1.5, duration: 0.3)
SKAction.scaleX(to: 2.0, y: 1.0, duration: 0.5)
SKAction.resize(toWidth: 100, height: 50, duration: 0.5)Fading
SKAction.fadeIn(withDuration: 0.5)
SKAction.fadeOut(withDuration: 0.5)
SKAction.fadeAlpha(to: 0.5, duration: 0.3)
SKAction.fadeAlpha(by: -0.2, duration: 0.3)Composition
SKAction.sequence([action1, action2, action3]) // Sequential
SKAction.group([action1, action2]) // Parallel
SKAction.repeat(action, count: 5) // Finite repeat
SKAction.repeatForever(action) // Infinite
action.reversed() // Reverse
SKAction.wait(forDuration: 1.0) // Delay
SKAction.wait(forDuration: 1.0, withRange: 0.5) // Random delayTexture & Color
SKAction.setTexture(texture)
SKAction.setTexture(texture, resize: true)
SKAction.animate(with: [tex1, tex2, tex3], timePerFrame: 0.1)
SKAction.animate(with: textures, timePerFrame: 0.1, resize: false, restore: true)
SKAction.colorize(with: .red, colorBlendFactor: 1.0, duration: 0.5)
SKAction.colorize(withColorBlendFactor: 0, duration: 0.5)Sound
SKAction.playSoundFileNamed("explosion.wav", waitForCompletion: false)Node Tree
SKAction.removeFromParent()
SKAction.run(block)
SKAction.run(block, queue: .main)
SKAction.customAction(withDuration: 1.0) { node, elapsed in
// Custom per-frame logic
}Physics
SKAction.applyForce(CGVector(dx: 0, dy: 100), duration: 0.5)
SKAction.applyImpulse(CGVector(dx: 50, dy: 0), duration: 1.0/60.0) // ~1 frame
SKAction.applyTorque(0.5, duration: 1.0)
SKAction.changeCharge(to: 1.0, duration: 0.5)
SKAction.changeMass(to: 2.0, duration: 0.5)Timing Modes
action.timingMode = .linear // Constant speed
action.timingMode = .easeIn // Slow → fast
action.timingMode = .easeOut // Fast → slow
action.timingMode = .easeInEaseOut // Slow → fast → slow
action.speed = 2.0 // 2x speed---
Part 4: Textures and Atlases
SKTexture
// From image
let tex = SKTexture(imageNamed: "player")
// From atlas
let atlas = SKTextureAtlas(named: "Characters")
let tex = atlas.textureNamed("player_run_1")
// Subrectangle (for manual sprite sheets)
let sub = SKTexture(rect: CGRect(x: 0, y: 0, width: 0.25, height: 0.5), in: sheetTexture)
// From CGImage
let tex = SKTexture(cgImage: cgImage)
// Filtering
tex.filteringMode = .nearest // Pixel art (no smoothing)
tex.filteringMode = .linear // Smooth scaling (default)
// Preload
SKTexture.preload([tex1, tex2]) { /* Ready */ }SKTextureAtlas
// Create in Xcode: Assets.xcassets → New Sprite Atlas
// Or .atlas folder in project bundle
let atlas = SKTextureAtlas(named: "Characters")
let textureNames = atlas.textureNames // All texture names in atlas
// Preload entire atlas
atlas.preload { /* Atlas ready */ }
// Preload multiple atlases
SKTextureAtlas.preloadTextureAtlases([atlas1, atlas2]) { /* All ready */ }
// Animation from atlas
let frames = (1...8).map { atlas.textureNamed("run_\($0)") }
let animate = SKAction.animate(with: frames, timePerFrame: 0.1)---
Part 5: Constraints
// Orient toward another node
let orient = SKConstraint.orient(to: targetNode, offset: SKRange(constantValue: 0))
// Orient toward a point
let orient = SKConstraint.orient(to: point, offset: SKRange(constantValue: 0))
// Position constraint (keep X in range)
let xRange = SKConstraint.positionX(SKRange(lowerLimit: 0, upperLimit: 400))
// Position constraint (keep Y in range)
let yRange = SKConstraint.positionY(SKRange(lowerLimit: 50, upperLimit: 750))
// Distance constraint (stay within range of node)
let dist = SKConstraint.distance(SKRange(lowerLimit: 50, upperLimit: 200), to: targetNode)
// Rotation constraint
let rot = SKConstraint.zRotation(SKRange(lowerLimit: -.pi/4, upperLimit: .pi/4))
// Apply constraints (processed in order)
node.constraints = [orient, xRange, yRange]
// Toggle
node.constraints?.first?.isEnabled = falseSKRange
SKRange(constantValue: 100) // Exactly 100
SKRange(lowerLimit: 50, upperLimit: 200) // 50...200
SKRange(lowerLimit: 0) // >= 0
SKRange(upperLimit: 500) // <= 500
SKRange(value: 100, variance: 20) // 80...120---
Part 6: Scene Setup
SKView Configuration
let skView = SKView(frame: view.bounds)
// Debug overlays
skView.showsFPS = true
skView.showsNodeCount = true
skView.showsDrawCount = true
skView.showsPhysics = true
skView.showsFields = true
skView.showsQuadCount = true
// Performance
skView.ignoresSiblingOrder = true // Enables batching optimizations
skView.shouldCullNonVisibleNodes = true // Auto-hide offscreen (manual is faster)
skView.isAsynchronous = true // Default: renders asynchronously
skView.allowsTransparency = false // Opaque is faster
// Frame rate
skView.preferredFramesPerSecond = 60 // Or 120 for ProMotion
// Present scene
skView.presentScene(scene)
skView.presentScene(scene, transition: .fade(withDuration: 0.5))Scale Mode Matrix
| Mode | Aspect Ratio | Content | Best For |
|---|---|---|---|
.aspectFill | Preserved | Fills view, crops edges | Most games |
.aspectFit | Preserved | Fits in view, letterboxes | Exact layout needed |
.resizeFill | Distorted | Stretches to fill | Almost never |
.fill | Varies | Scene resizes to match view | Adaptive scenes |
SKTransition Types
SKTransition.fade(withDuration: 0.5)
SKTransition.fade(with: .black, duration: 0.5)
SKTransition.crossFade(withDuration: 0.5)
SKTransition.flipHorizontal(withDuration: 0.5)
SKTransition.flipVertical(withDuration: 0.5)
SKTransition.reveal(with: .left, duration: 0.5)
SKTransition.moveIn(with: .right, duration: 0.5)
SKTransition.push(with: .up, duration: 0.5)
SKTransition.doorway(withDuration: 0.5)
SKTransition.doorsOpenHorizontal(withDuration: 0.5)
SKTransition.doorsOpenVertical(withDuration: 0.5)
SKTransition.doorsCloseHorizontal(withDuration: 0.5)
SKTransition.doorsCloseVertical(withDuration: 0.5)
// Custom with CIFilter:
SKTransition(ciFilter: filter, duration: 0.5)---
Part 7: Particles
SKEmitterNode Key Properties
let emitter = SKEmitterNode(fileNamed: "Spark")!
// Emission control
emitter.particleBirthRate = 100 // Particles per second
emitter.numParticlesToEmit = 0 // 0 = infinite
emitter.particleLifetime = 2.0 // Seconds
emitter.particleLifetimeRange = 0.5 // ± random
// Position
emitter.particlePosition = .zero
emitter.particlePositionRange = CGVector(dx: 10, dy: 10)
// Movement
emitter.emissionAngle = .pi / 2 // Direction (radians)
emitter.emissionAngleRange = .pi / 4 // Spread
emitter.particleSpeed = 100 // Points per second
emitter.particleSpeedRange = 50 // ± random
emitter.xAcceleration = 0
emitter.yAcceleration = -100 // Gravity-like
// Appearance
emitter.particleTexture = SKTexture(imageNamed: "spark")
emitter.particleSize = CGSize(width: 8, height: 8)
emitter.particleColor = .white
emitter.particleColorAlphaSpeed = -0.5 // Fade out
emitter.particleBlendMode = .add // Additive for fire/glow
emitter.particleAlpha = 1.0
emitter.particleAlphaSpeed = -0.5
// Scale
emitter.particleScale = 1.0
emitter.particleScaleRange = 0.5
emitter.particleScaleSpeed = -0.3 // Shrink over time
// Rotation
emitter.particleRotation = 0
emitter.particleRotationSpeed = 2.0
// Target node (for trails)
emitter.targetNode = scene // Particles stay in world space
// Render order
emitter.particleRenderOrder = .dontCare // .oldestFirst, .oldestLast, .dontCare
// Physics field interaction
emitter.fieldBitMask = 0x1Common Particle Presets
| Effect | Key Settings |
|---|---|
| Fire | blendMode: .add, fast alphaSpeed, orange→red color, upward speed |
| Smoke | blendMode: .alpha, slow speed, gray color, scale up over time |
| Sparks | blendMode: .add, high speed + range, short lifetime, small size |
| Rain | Downward emissionAngle, narrow range, long lifetime, thin texture |
| Snow | Slow downward speed, wide position range, slight x acceleration |
| Trail | Set targetNode to scene, narrow emission angle, medium lifetime |
| Explosion | High birth rate, short numParticlesToEmit, high speed range |
---
Part 8: SKRenderer and Shaders
SKRenderer (Metal Integration)
import MetalKit
let device = MTLCreateSystemDefaultDevice()!
let renderer = SKRenderer(device: device)
renderer.scene = gameScene
renderer.ignoresSiblingOrder = true
// In Metal render loop:
func draw(in view: MTKView) {
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let rpd = view.currentRenderPassDescriptor else { return }
renderer.update(atTime: CACurrentMediaTime())
renderer.render(
withViewport: CGRect(origin: .zero, size: view.drawableSize),
commandBuffer: commandBuffer,
renderPassDescriptor: rpd
)
commandBuffer.present(view.currentDrawable!)
commandBuffer.commit()
}SKShader (Custom GLSL ES Effects)
// Fragment shader for per-pixel effects
let shader = SKShader(source: """
void main() {
vec4 color = texture2D(u_texture, v_tex_coord);
// Desaturate
float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
gl_FragColor = vec4(vec3(gray), color.a) * v_color_mix.a;
}
""")
sprite.shader = shader
// With uniforms
let shader = SKShader(source: """
void main() {
vec4 color = texture2D(u_texture, v_tex_coord);
color.rgb *= u_intensity;
gl_FragColor = color;
}
""")
shader.uniforms = [
SKUniform(name: "u_intensity", float: 0.8)
]
// Built-in uniforms:
// u_texture — sprite texture
// u_time — elapsed time
// u_path_length — shape node path length
// v_tex_coord — texture coordinate
// v_color_mix — color/alpha mix
// SKAttribute for per-node valuesPart 7: SwiftUI Integration
SpriteView
import SpriteKit
import SwiftUI
// Basic embedding
struct GameView: View {
var body: some View {
SpriteView(scene: makeScene())
.ignoresSafeArea()
}
func makeScene() -> SKScene {
let scene = GameScene(size: CGSize(width: 1024, height: 768))
scene.scaleMode = .aspectFill
return scene
}
}
// With options
SpriteView(
scene: scene,
transition: .fade(withDuration: 0.5), // Scene transition
isPaused: false, // Pause control
preferredFramesPerSecond: 60, // Frame rate
options: [.ignoresSiblingOrder, .shouldCullNonVisibleNodes],
debugOptions: [.showsFPS, .showsNodeCount] // Debug overlays
)SpriteView Options
| Option | Purpose |
|---|---|
.ignoresSiblingOrder | Enable draw order batching optimization |
.shouldCullNonVisibleNodes | Auto-hide offscreen nodes |
.allowsTransparency | Allow transparent background (slower) |
Debug Options
| Option | Shows |
|---|---|
.showsFPS | Frames per second |
.showsNodeCount | Total visible nodes |
.showsDrawCount | Draw calls per frame |
.showsPhysics | Physics body outlines |
.showsFields | Physics field regions |
.showsQuadCount | Quad subdivisions |
Communicating Between SwiftUI and SpriteKit
// Observable model shared between SwiftUI and scene
@Observable
class GameState {
var score = 0
var isPaused = false
var lives = 3
}
// Scene reads/writes the shared model
class GameScene: SKScene {
var gameState: GameState?
override func update(_ currentTime: TimeInterval) {
guard let state = gameState, !state.isPaused else { return }
// Game logic updates state.score, state.lives, etc.
}
}
// SwiftUI view owns the model
struct GameContainerView: View {
@State private var gameState = GameState()
@State private var scene: GameScene = {
let s = GameScene(size: CGSize(width: 1024, height: 768))
s.scaleMode = .aspectFill
return s
}()
var body: some View {
VStack {
Text("Score: \(gameState.score)")
SpriteView(scene: scene, isPaused: gameState.isPaused)
.ignoresSafeArea()
}
.onAppear { scene.gameState = gameState }
}
}Key pattern: Use @Observable model as bridge. Scene mutates it; SwiftUI observes changes. Avoid recreating scenes in view body — use @State to persist the scene instance.
---
Resources
WWDC: 2014-608, 2016-610, 2017-609
Docs: /spritekit/skspritenode, /spritekit/skphysicsbody, /spritekit/skaction, /spritekit/skemitternode, /spritekit/skrenderer
Skills: skills/spritekit.md, skills/spritekit-diag.md
SpriteKit Game Development Guide
Purpose: Build reliable SpriteKit games by mastering the scene graph, physics engine, action system, and rendering pipeline iOS Version: iOS 14+ (SwiftUI integration), iOS 11+ (SKRenderer) Xcode: Xcode 15+
When to Use This Skill
Use this skill when:
- Building a new SpriteKit game or interactive simulation
- Implementing physics (collisions, contacts, forces, joints)
- Setting up game architecture (scenes, layers, cameras)
- Optimizing frame rate or reducing draw calls
- Implementing touch/input handling in a game
- Managing scene transitions and data passing
- Integrating SpriteKit with SwiftUI or Metal
- Debugging physics contacts that don't fire
- Fixing coordinate system confusion
Do NOT use this skill for:
- SceneKit 3D rendering (
axiom-graphics (skills/scenekit.md)) - GameplayKit entity-component systems
- Metal shader programming (
axiom-graphics (skills/metal-migration-ref.md)) - General SwiftUI layout (
axiom-swiftui, layout reference)
---
1. Mental Model
Coordinate System
SpriteKit uses a bottom-left origin with Y pointing up. This differs from UIKit (top-left, Y down).
SpriteKit: UIKit:
┌─────────┐ ┌─────────┐
│ +Y │ │ (0,0) │
│ ↑ │ │ ↓ │
│ │ │ │ +Y │
│(0,0)──→+X│ │ │ │
└─────────┘ └─────────┘Anchor Points define which point on a sprite maps to its position. Default is (0.5, 0.5) (center).
// Common anchor point trap:
// Anchor (0, 0) = bottom-left of sprite is at position
// Anchor (0.5, 0.5) = center of sprite is at position (DEFAULT)
// Anchor (0.5, 0) = bottom-center (useful for characters standing on ground)
sprite.anchorPoint = CGPoint(x: 0.5, y: 0)Scene anchor point maps the view's frame to scene coordinates:
(0, 0)— scene origin at bottom-left of view (default)(0.5, 0.5)— scene origin at center of view
Node Tree
Everything in SpriteKit is an SKNode in a tree hierarchy. Parent transforms propagate to children.
SKScene
├── SKCameraNode (viewport control)
├── SKNode "world" (game content layer)
│ ├── SKSpriteNode "player"
│ ├── SKSpriteNode "enemy"
│ └── SKNode "platforms"
│ ├── SKSpriteNode "platform1"
│ └── SKSpriteNode "platform2"
└── SKNode "hud" (UI layer, attached to camera)
├── SKLabelNode "score"
└── SKSpriteNode "healthBar"Z-Ordering
zPosition controls draw order. Higher values render on top. Nodes at the same zPosition render in child array order (unless ignoresSiblingOrder is true).
// Establish clear z-order layers
enum ZLayer {
static let background: CGFloat = -100
static let platforms: CGFloat = 0
static let items: CGFloat = 10
static let player: CGFloat = 20
static let effects: CGFloat = 30
static let hud: CGFloat = 100
}---
2. Scene Architecture
Scale Mode Decision
| Mode | Behavior | Use When |
|---|---|---|
.aspectFill | Fills view, crops edges | Full-bleed games (most games) |
.aspectFit | Fits in view, letterboxes | Puzzle games needing exact layout |
.resizeFill | Stretches to fill | Almost never — distorts |
.fill | Matches view size exactly | Scene adapts to any ratio |
class GameScene: SKScene {
override func sceneDidLoad() {
scaleMode = .aspectFill
// Design for a reference size, let aspectFill crop edges
}
}Camera Node Pattern
Always use SKCameraNode for viewport control. Attach HUD elements to the camera so they don't scroll.
let camera = SKCameraNode()
camera.name = "mainCamera"
addChild(camera)
self.camera = camera
// HUD follows camera automatically
let scoreLabel = SKLabelNode(text: "Score: 0")
scoreLabel.position = CGPoint(x: 0, y: size.height / 2 - 50)
camera.addChild(scoreLabel)
// Move camera to follow player
let follow = SKConstraint.distance(SKRange(constantValue: 0), to: playerNode)
camera.constraints = [follow]Layer Organization
// Create layer nodes for organization
let worldNode = SKNode()
worldNode.name = "world"
addChild(worldNode)
let hudNode = SKNode()
hudNode.name = "hud"
camera?.addChild(hudNode)
// All gameplay objects go in worldNode
worldNode.addChild(playerSprite)
worldNode.addChild(enemySprite)
// All UI goes in hudNode (moves with camera)
hudNode.addChild(scoreLabel)Scene Transitions
// Preload next scene for smooth transitions
guard let nextScene = LevelScene(fileNamed: "Level2") else { return }
nextScene.scaleMode = .aspectFill
let transition = SKTransition.fade(withDuration: 0.5)
view?.presentScene(nextScene, transition: transition)Data passing between scenes: Use a shared game state object, not node properties.
class GameState {
static let shared = GameState()
var score = 0
var currentLevel = 1
var playerHealth = 100
}
// In scene transition:
let nextScene = LevelScene(size: size)
// GameState.shared is already accessible
view?.presentScene(nextScene, transition: .fade(withDuration: 0.5))Note: A singleton works for simple games. For larger projects with testing needs, consider passing a GameState instance through scene initializers to avoid hidden global state.
Cleanup in `willMove(from:)`:
override func willMove(from view: SKView) {
removeAllActions()
removeAllChildren()
physicsWorld.contactDelegate = nil
}---
3. Physics Engine
Bitmask Discipline
This is the #1 source of SpriteKit bugs. Physics bitmasks use a 32-bit system where each bit represents a category.
struct PhysicsCategory {
static let none: UInt32 = 0
static let player: UInt32 = 0b0001 // 1
static let enemy: UInt32 = 0b0010 // 2
static let ground: UInt32 = 0b0100 // 4
static let projectile: UInt32 = 0b1000 // 8
static let powerUp: UInt32 = 0b10000 // 16
}Three bitmask properties (all default to 0xFFFFFFFF — everything):
| Property | Purpose | Default |
|---|---|---|
categoryBitMask | What this body IS | 0xFFFFFFFF |
collisionBitMask | What it BOUNCES off | 0xFFFFFFFF |
contactTestBitMask | What TRIGGERS delegate | 0x00000000 |
The default `collisionBitMask` of `0xFFFFFFFF` means everything collides with everything. This is the most common source of unexpected physics behavior.
// CORRECT: Explicit bitmask setup
player.physicsBody?.categoryBitMask = PhysicsCategory.player
player.physicsBody?.collisionBitMask = PhysicsCategory.ground | PhysicsCategory.enemy
player.physicsBody?.contactTestBitMask = PhysicsCategory.enemy | PhysicsCategory.powerUp
enemy.physicsBody?.categoryBitMask = PhysicsCategory.enemy
enemy.physicsBody?.collisionBitMask = PhysicsCategory.ground | PhysicsCategory.player
enemy.physicsBody?.contactTestBitMask = PhysicsCategory.player | PhysicsCategory.projectileBitmask Checklist
For every physics body, verify: 1. categoryBitMask set to exactly one category 2. collisionBitMask set to only categories it should bounce off (NOT 0xFFFFFFFF) 3. contactTestBitMask set to categories that should trigger delegate callbacks 4. Delegate is assigned: physicsWorld.contactDelegate = self
Contact Detection
class GameScene: SKScene, SKPhysicsContactDelegate {
override func didMove(to view: SKView) {
physicsWorld.contactDelegate = self
}
func didBegin(_ contact: SKPhysicsContact) {
// Sort bodies so bodyA has the lower category
let (first, second): (SKPhysicsBody, SKPhysicsBody)
if contact.bodyA.categoryBitMask < contact.bodyB.categoryBitMask {
(first, second) = (contact.bodyA, contact.bodyB)
} else {
(first, second) = (contact.bodyB, contact.bodyA)
}
// Now dispatch based on categories
if first.categoryBitMask == PhysicsCategory.player &&
second.categoryBitMask == PhysicsCategory.enemy {
guard let playerNode = first.node, let enemyNode = second.node else { return }
playerHitEnemy(player: playerNode, enemy: enemyNode)
}
}
}Modification rule: You cannot modify the physics world inside didBegin/didEnd. Set flags and apply changes in update(_:).
var enemiesToRemove: [SKNode] = []
func didBegin(_ contact: SKPhysicsContact) {
// Flag for removal — don't remove here
if let enemy = contact.bodyB.node {
enemiesToRemove.append(enemy)
}
}
override func update(_ currentTime: TimeInterval) {
for enemy in enemiesToRemove {
enemy.removeFromParent()
}
enemiesToRemove.removeAll()
}Body Types
| Type | Created With | Responds to Forces | Use For |
|---|---|---|---|
| Dynamic volume | init(circleOfRadius:), init(rectangleOf:), init(texture:size:) | Yes | Players, enemies, projectiles |
| Static volume | Dynamic body + isDynamic = false | No (but collides) | Platforms, walls |
| Edge | init(edgeLoopFrom:), init(edgeFrom:to:) | No (boundary only) | Screen boundaries, terrain |
// Screen boundary using edge loop
physicsBody = SKPhysicsBody(edgeLoopFrom: frame)
// Texture-based body for irregular shapes
guard let texture = enemy.texture else { return }
enemy.physicsBody = SKPhysicsBody(texture: texture, size: enemy.size)
// Circle for performance (cheapest collision detection)
bullet.physicsBody = SKPhysicsBody(circleOfRadius: 5)Tunneling Prevention
Fast-moving objects can pass through thin walls. Fix:
// Enable precise collision detection for fast objects
bullet.physicsBody?.usesPreciseCollisionDetection = true
// Make walls thick enough (at least as wide as fastest object moves per frame)
// At 60fps, an object at velocity 600pt/s moves 10pt/frameForces vs Impulses
// Force: continuous (applied per frame, accumulates)
body.applyForce(CGVector(dx: 0, dy: 100))
// Impulse: instant velocity change (one-time, like a jump)
body.applyImpulse(CGVector(dx: 0, dy: 50))
// Torque: continuous rotation
body.applyTorque(0.5)
// Angular impulse: instant rotation change
body.applyAngularImpulse(1.0)---
4. Actions System
Core Patterns
// Movement
let move = SKAction.move(to: CGPoint(x: 200, y: 300), duration: 1.0)
let moveBy = SKAction.moveBy(x: 100, y: 0, duration: 0.5)
// Rotation
let rotate = SKAction.rotate(byAngle: .pi * 2, duration: 1.0)
// Scale
let scale = SKAction.scale(to: 2.0, duration: 0.3)
// Fade
let fadeOut = SKAction.fadeOut(withDuration: 0.5)
let fadeIn = SKAction.fadeIn(withDuration: 0.5)Sequencing and Grouping
// Sequence: one after another
let moveAndFade = SKAction.sequence([
SKAction.move(to: target, duration: 1.0),
SKAction.fadeOut(withDuration: 0.3),
SKAction.removeFromParent()
])
// Group: all at once
let spinAndGrow = SKAction.group([
SKAction.rotate(byAngle: .pi * 2, duration: 1.0),
SKAction.scale(to: 2.0, duration: 1.0)
])
// Repeat
let pulse = SKAction.repeatForever(SKAction.sequence([
SKAction.scale(to: 1.2, duration: 0.3),
SKAction.scale(to: 1.0, duration: 0.3)
]))Named Actions (Critical for Management)
// Use named actions so you can cancel/replace them
node.run(pulse, withKey: "pulse")
// Later, stop the pulse:
node.removeAction(forKey: "pulse")
// Check if running:
if node.action(forKey: "pulse") != nil {
// Still pulsing
}Custom Actions with Weak Self
// WRONG: Retain cycle risk
node.run(SKAction.run {
self.score += 1 // Strong capture of self
})
// CORRECT: Weak capture
node.run(SKAction.run { [weak self] in
self?.score += 1
})
// For repeating actions, always use weak self
let spawn = SKAction.repeatForever(SKAction.sequence([
SKAction.run { [weak self] in self?.spawnEnemy() },
SKAction.wait(forDuration: 2.0)
]))
scene.run(spawn, withKey: "enemySpawner")Timing Modes
action.timingMode = .linear // Constant speed (default)
action.timingMode = .easeIn // Accelerate from rest
action.timingMode = .easeOut // Decelerate to rest
action.timingMode = .easeInEaseOut // Smooth start and endActions vs Physics
Never use actions to move physics-controlled nodes. Actions override the physics simulation, causing jittering and missed collisions.
// WRONG: Action fights physics
playerNode.run(SKAction.moveTo(x: 200, duration: 0.5))
// CORRECT: Use forces/impulses for physics bodies
playerNode.physicsBody?.applyImpulse(CGVector(dx: 50, dy: 0))
// CORRECT: Use actions for non-physics nodes (UI, effects, decorations)
hudLabel.run(SKAction.scale(to: 1.5, duration: 0.2))---
5. Input Handling
Touch Handling
// CRITICAL: isUserInteractionEnabled must be true on the responding node
// SKScene has it true by default; other nodes default to false
class Player: SKSpriteNode {
init() {
super.init(texture: SKTexture(imageNamed: "player"), color: .clear, size: CGSize(width: 50, height: 50))
isUserInteractionEnabled = true // Required!
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// Handle touch on this specific node
}
}Coordinate Space Conversion
// Touch location in SCENE coordinates (most common)
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let locationInScene = touch.location(in: self)
// Touch location in a SPECIFIC NODE's coordinates
let locationInWorld = touch.location(in: worldNode)
// Hit test: what node was touched?
let touchedNodes = nodes(at: locationInScene)
}Common mistake: Using touch.location(in: self.view) returns UIKit coordinates (Y-flipped). Always use touch.location(in: self) for scene coordinates.
Game Controller Support
import GameController
func setupControllers() {
NotificationCenter.default.addObserver(
self, selector: #selector(controllerConnected),
name: .GCControllerDidConnect, object: nil
)
// Check already-connected controllers
for controller in GCController.controllers() {
configureController(controller)
}
}For on-screen touch controls (TouchController framework), GCController input patterns, and the 27-cycle controller additions, see skills/game-input.md.
---
6. Performance
Performance Priorities
For detailed performance diagnosis, see skills/spritekit-diag.md Symptom 3. Key priorities:
1. Node count — Remove offscreen nodes, use object pooling 2. Draw calls — Use texture atlases, replace SKShapeNode with pre-rendered textures 3. Physics cost — Prefer simple body shapes, limit usesPreciseCollisionDetection 4. Particles — Limit birth rate, set finite emission counts
Debug Overlays (Always Enable During Development)
if let view = self.view as? SKView {
view.showsFPS = true
view.showsNodeCount = true
view.showsDrawCount = true
view.showsPhysics = true // Shows physics body outlines
// Performance: render order optimization
view.ignoresSiblingOrder = true
}Texture Atlas Batching
Sprites using textures from the same atlas render in a single draw call.
// Create atlas in Xcode: Assets → New Sprite Atlas
// Or use .atlas folder in project
let atlas = SKTextureAtlas(named: "Characters")
let texture = atlas.textureNamed("player_idle")
let sprite = SKSpriteNode(texture: texture)
// Preload atlas to avoid frame drops
SKTextureAtlas.preloadTextureAtlases([atlas]) {
// Atlas ready — present scene
}SKShapeNode Trap
SKShapeNode generates one draw call per instance. It cannot be batched. Use it for prototyping and debug visualization only.
// WRONG: 100 SKShapeNodes = 100 draw calls
for _ in 0..<100 {
let dot = SKShapeNode(circleOfRadius: 5)
addChild(dot)
}
// CORRECT: Pre-render to texture, use SKSpriteNode
let shape = SKShapeNode(circleOfRadius: 5)
shape.fillColor = .red
guard let texture = view?.texture(from: shape) else { return }
for _ in 0..<100 {
let dot = SKSpriteNode(texture: texture)
addChild(dot)
}Object Pooling
For frequently spawned/destroyed objects (bullets, particles, enemies):
class BulletPool {
private var available: [SKSpriteNode] = []
private let texture: SKTexture
init(texture: SKTexture, initialSize: Int = 20) {
self.texture = texture
for _ in 0..<initialSize {
available.append(createBullet())
}
}
private func createBullet() -> SKSpriteNode {
let bullet = SKSpriteNode(texture: texture)
bullet.physicsBody = SKPhysicsBody(circleOfRadius: 3)
bullet.physicsBody?.categoryBitMask = PhysicsCategory.projectile
bullet.physicsBody?.collisionBitMask = PhysicsCategory.none
bullet.physicsBody?.contactTestBitMask = PhysicsCategory.enemy
return bullet
}
func spawn() -> SKSpriteNode {
if available.isEmpty {
available.append(createBullet())
}
let bullet = available.removeLast()
bullet.isHidden = false
bullet.physicsBody?.isDynamic = true
return bullet
}
func recycle(_ bullet: SKSpriteNode) {
bullet.removeAllActions()
bullet.removeFromParent()
bullet.physicsBody?.isDynamic = false
bullet.physicsBody?.velocity = .zero
bullet.isHidden = true
available.append(bullet)
}
}Offscreen Node Removal
// Manual removal is faster than shouldCullNonVisibleNodes
override func update(_ currentTime: TimeInterval) {
enumerateChildNodes(withName: "bullet") { node, _ in
if !self.frame.intersects(node.frame) {
self.bulletPool.recycle(node as! SKSpriteNode)
}
}
}---
7. Game Loop
Frame Cycle (8 Phases)
1. update(_:) ← Your game logic here
2. didEvaluateActions() ← Actions completed
3. [Physics simulation] ← SpriteKit runs physics
4. didSimulatePhysics() ← Physics done, adjust results
5. [Constraint evaluation] ← SKConstraints applied
6. didApplyConstraints() ← Constraints done
7. didFinishUpdate() ← Last chance before render
8. [Rendering] ← Frame drawnDelta Time
private var lastUpdateTime: TimeInterval = 0
override func update(_ currentTime: TimeInterval) {
let dt: TimeInterval
if lastUpdateTime == 0 {
dt = 0
} else {
dt = currentTime - lastUpdateTime
}
lastUpdateTime = currentTime
// Clamp delta time to prevent spiral of death
// (when app returns from background, dt can be huge)
let clampedDt = min(dt, 1.0 / 30.0)
updatePlayer(deltaTime: clampedDt)
updateEnemies(deltaTime: clampedDt)
}Pause Handling
// Pause the scene (stops actions, physics, update loop)
scene.isPaused = true
// Pause specific subtree only
worldNode.isPaused = true // Game paused but HUD still animates
// Handle app backgrounding
NotificationCenter.default.addObserver(
self, selector: #selector(pauseGame),
name: UIApplication.willResignActiveNotification, object: nil
)---
8. Particle Effects
Emitter Best Practices
// Load from .sks file (designed in Xcode Particle Editor)
guard let emitter = SKEmitterNode(fileNamed: "Explosion") else { return }
emitter.position = explosionPoint
addChild(emitter)
// CRITICAL: Auto-remove after emission completes
let duration = TimeInterval(emitter.numParticlesToEmit) / TimeInterval(emitter.particleBirthRate)
+ TimeInterval(emitter.particleLifetime + emitter.particleLifetimeRange / 2)
emitter.run(SKAction.sequence([
SKAction.wait(forDuration: duration),
SKAction.removeFromParent()
]))Target Node for Trails
Without targetNode, particles move with the emitter. For trails (like rocket exhaust), set targetNode to the scene:
let trail = SKEmitterNode(fileNamed: "RocketTrail")!
trail.targetNode = scene // Particles stay where emitted
rocketNode.addChild(trail)Infinite Emitter Cleanup
// WRONG: Infinite emitter never cleaned up
let fire = SKEmitterNode(fileNamed: "Fire")!
fire.numParticlesToEmit = 0 // 0 = infinite
addChild(fire)
// Memory leak — particles accumulate forever
// CORRECT: Set emission limit or remove when done
fire.numParticlesToEmit = 200 // Stops after 200 particles
// Or manually stop and remove:
fire.particleBirthRate = 0 // Stop new particles
fire.run(SKAction.sequence([
SKAction.wait(forDuration: TimeInterval(fire.particleLifetime)),
SKAction.removeFromParent()
]))---
9. SwiftUI Integration
SpriteView (Recommended, iOS 14+)
The simplest way to embed SpriteKit in SwiftUI. Use this unless you need custom SKView configuration.
import SpriteKit
import SwiftUI
struct GameView: View {
var body: some View {
SpriteView(scene: {
let scene = GameScene(size: CGSize(width: 390, height: 844))
scene.scaleMode = .aspectFill
return scene
}(), debugOptions: [.showsFPS, .showsNodeCount])
.ignoresSafeArea()
}
}UIViewRepresentable (Advanced)
Use when you need full control over SKView configuration (custom frame rate, transparency, or multiple scenes).
import SwiftUI
import SpriteKit
struct SpriteKitView: UIViewRepresentable {
let scene: SKScene
func makeUIView(context: Context) -> SKView {
let view = SKView()
view.showsFPS = true
view.showsNodeCount = true
view.ignoresSiblingOrder = true
return view
}
func updateUIView(_ view: SKView, context: Context) {
if view.scene == nil {
view.presentScene(scene)
}
}
}SKRenderer for Metal Hybrid
Use SKRenderer when SpriteKit is one layer in a Metal pipeline:
let renderer = SKRenderer(device: metalDevice)
renderer.scene = gameScene
// In your Metal render loop:
renderer.update(atTime: currentTime)
renderer.render(
withViewport: viewport,
commandBuffer: commandBuffer,
renderPassDescriptor: renderPassDescriptor
)---
10. Anti-Patterns
Anti-Pattern 1: Default Bitmasks
Time cost: 30-120 minutes debugging phantom collisions
// WRONG: Default collisionBitMask is 0xFFFFFFFF
let body = SKPhysicsBody(circleOfRadius: 10)
node.physicsBody = body
// Collides with EVERYTHING — even things it shouldn't
// CORRECT: Always set all three masks explicitly
body.categoryBitMask = PhysicsCategory.player
body.collisionBitMask = PhysicsCategory.ground
body.contactTestBitMask = PhysicsCategory.enemyAnti-Pattern 2: Missing contactTestBitMask
Time cost: 30-60 minutes wondering why didBegin never fires
// WRONG: contactTestBitMask defaults to 0 — no contacts ever fire
player.physicsBody?.categoryBitMask = PhysicsCategory.player
// Forgot contactTestBitMask!
// CORRECT: Both bodies need compatible masks
player.physicsBody?.contactTestBitMask = PhysicsCategory.enemy
enemy.physicsBody?.categoryBitMask = PhysicsCategory.enemyAnti-Pattern 3: Actions on Physics Bodies
Time cost: 1-3 hours of jittering and missed collisions
// WRONG: SKAction.move overrides physics position each frame
playerNode.run(SKAction.moveTo(x: 200, duration: 1.0))
// Physics body position is set by action, ignoring forces/collisions
// CORRECT: Use physics for physics-controlled nodes
playerNode.physicsBody?.applyForce(CGVector(dx: 100, dy: 0))Anti-Pattern 4: SKShapeNode for Gameplay
Time cost: Hours diagnosing frame drops
Each SKShapeNode is a separate draw call that cannot be batched. 50 shape nodes = 50 draw calls. See the pre-render-to-texture pattern in Section 6 (SKShapeNode Trap) for the fix.
Anti-Pattern 5: Strong Self in Action Closures
Time cost: Memory leaks, eventual crash
// WRONG: Strong capture in repeating action
node.run(SKAction.repeatForever(SKAction.sequence([
SKAction.run { self.spawnEnemy() },
SKAction.wait(forDuration: 2.0)
])))
// CORRECT: Weak capture
node.run(SKAction.repeatForever(SKAction.sequence([
SKAction.run { [weak self] in self?.spawnEnemy() },
SKAction.wait(forDuration: 2.0)
])))---
11. Code Review Checklist
Physics
- [ ] Every physics body has explicit
categoryBitMask(not default) - [ ] Every physics body has explicit
collisionBitMask(not0xFFFFFFFF) - [ ] Bodies needing contact detection have
contactTestBitMaskset - [ ]
physicsWorld.contactDelegateis assigned - [ ] No world modifications inside
didBegin/didEndcallbacks - [ ] Fast objects use
usesPreciseCollisionDetection
Actions
- [ ] No
SKAction.move/rotateon physics-controlled nodes - [ ] Repeating actions use
withKey:for cancellation - [ ]
SKAction.runclosures use[weak self] - [ ] One-shot emitters are removed after emission
Performance
- [ ] Debug overlays enabled during development
- [ ]
ignoresSiblingOrder = trueon SKView - [ ] No SKShapeNode in gameplay sprites (use pre-rendered textures)
- [ ] Texture atlases used for related sprites
- [ ] Offscreen nodes removed manually
Scene Management
- [ ]
willMove(from:)cleans up actions, children, delegates - [ ] Scene data passed via shared state, not node properties
- [ ] Camera used for viewport control
---
12. Pressure Scenarios
Scenario 1: "Physics Contacts Don't Work — Ship Tonight"
Pressure: Deadline pressure to skip systematic debugging
Wrong approach: Randomly changing bitmask values, adding 0xFFFFFFFF everywhere, or disabling physics
Correct approach (2-5 minutes): 1. Enable showsPhysics — verify bodies exist and overlap 2. Print all three bitmasks for both bodies 3. Verify contactTestBitMask on body A includes category of body B (or vice versa) 4. Verify physicsWorld.contactDelegate is set 5. Verify you're not modifying the world inside the callback
Push-back template: "Let me run the 5-step bitmask checklist. It takes 2 minutes and catches 90% of contact issues. Random changes will make it worse."
Scenario 2: "Frame Rate Is Fine on My Device"
Pressure: Authority says "it runs at 60fps for me, ship it"
Wrong approach: Shipping without profiling on minimum-spec device
Correct approach: 1. Enable showsFPS, showsNodeCount, showsDrawCount 2. Test on oldest supported device 3. If >200 nodes or >30 draw calls, investigate 4. Check for SKShapeNode in gameplay 5. Verify offscreen nodes are being removed
Push-back template: "Performance varies by device. Let me check node count and draw calls — takes 30 seconds with debug overlays. If counts are low, we're safe to ship."
Scenario 3: "Just Use SKShapeNode, It's Faster to Code"
Pressure: Sunk cost — already built with SKShapeNode, don't want to redo
Wrong approach: Shipping with 100+ SKShapeNodes causing frame drops
Correct approach: 1. Check showsDrawCount — each SKShapeNode adds a draw call 2. If >20 shape nodes in gameplay, pre-render to textures 3. Use view.texture(from:) to convert once, reuse as SKSpriteNode 4. Keep SKShapeNode only for debug visualization
Push-back template: "Each SKShapeNode is a separate draw call. Converting to pre-rendered textures is a 15-minute refactor that can double frame rate. SKSpriteNode from atlas = 1 draw call for all of them."
Resources
WWDC: 2014-608, 2016-610, 2017-609, 2013-502
Docs: /spritekit, /spritekit/skscene, /spritekit/skphysicsbody, /spritekit/maximizing-node-drawing-performance
Skills: skills/spritekit-ref.md, skills/spritekit-diag.md
Related skills
How it compares
Choose axiom-games for native Apple framework games; pick cross-engine skills when the same codebase must ship on Android or consoles.
FAQ
Which Apple frameworks does axiom-games cover?
axiom-games covers SpriteKit for 2D games, SceneKit for 3D scenes, and GameplayKit for game logic and entity systems on iOS, iPadOS, visionOS, and macOS.
What platforms does axiom-games target?
axiom-games targets native Apple game platforms: iOS, iPadOS, visionOS, and macOS. The skill description emphasizes agent-guided patterns for those OS targets using Apple game frameworks.