axiom-spritekit

Use when building SpriteKit games, implementing physics, actions, scene management, or debugging game performance. Covers scene graph, physics engine, actions system, game loop, rendering optimization.

charleswiltgen/axiom204 installsMITSynced Aug 27

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
name: axiom-spritekit
description: Use when building SpriteKit games, implementing physics, actions, scene management, or debugging game performance. Covers scene graph, physics engine, actions system, game loop, rendering optimization.
license: MIT
---

# SpriteKit Game Development Guide

**Purpose**: Build reliable SpriteKit games by mastering the scene graph, physics engine, action system, and rendering pipeline
**iOS Version**: iOS 13+ (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-scenekit`)
- GameplayKit entity-component systems
- Metal shader programming (`axiom-metal-migration-ref`)
- General SwiftUI layout (`axiom-swiftui-layout`)

---

## 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).

```swift
// 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`).

```swift
// 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 |

```swift
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.

```swift
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

```swift
// 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

```swift
// 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.

```swift
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:)`**:

```swift
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.

```swift
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.

```swift
// 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.projectile
```

### Bitmask 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

```swift
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(_:)`.

```swift
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 |

```swift
// 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:

```swift
// 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/frame
```

### Forces vs Impulses

```swift
// 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

```swift
// 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

```swift
// 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)

```swift
// 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

```swift
// 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

```swift
action.timingMode = .linear     // Constant speed (default)
action.timingMode = .easeIn     // Accelerate from rest
action.timingMode = .easeOut    // Decelerate to rest
action.timingMode = .easeInEaseOut  // Smooth start and end
```

### Actions vs Physics

**Never use actions to move physics-controlled nodes.** Actions override the physics simulation, causing jittering and missed collisions.

```swift
// 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

```swift
// 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

```swift
// 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

```swift
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)
    }
}
```

---

## 6. Performance

### Performance Priorities

For detailed performance diagnosis, see `axiom-spritekit-diag` 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)

```swift
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.

```swift
// 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.

```swift
// 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):

```swift
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

```swift
// 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 drawn
```

### Delta Time

```swift
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

```swift
// 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

```swift
// 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:

```swift
let trail = SKEmitterNode(fileNamed: "RocketTrail")!
trail.targetNode = scene  // Particles stay where emitted
rocketNode.addChild(trail)
```

### Infinite Emitter Cleanup

```swift
// 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.

```swift
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).

```swift
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:

```swift
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

```swift
// 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.enemy
```

### Anti-Pattern 2: Missing contactTestBitMask

**Time cost**: 30-60 minutes wondering why didBegin never fires

```swift
// 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.enemy
```

### Anti-Pattern 3: Actions on Physics Bodies

**Time cost**: 1-3 hours of jittering and missed collisions

```swift
// 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

```swift
// 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` (not `0xFFFFFFFF`)
- [ ] Bodies needing contact detection have `contactTestBitMask` set
- [ ] `physicsWorld.contactDelegate` is assigned
- [ ] No world modifications inside `didBegin`/`didEnd` callbacks
- [ ] Fast objects use `usesPreciseCollisionDetection`

### Actions
- [ ] No `SKAction.move`/`rotate` on physics-controlled nodes
- [ ] Repeating actions use `withKey:` for cancellation
- [ ] `SKAction.run` closures use `[weak self]`
- [ ] One-shot emitters are removed after emission

### Performance
- [ ] Debug overlays enabled during development
- [ ] `ignoresSiblingOrder = true` on 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**: axiom-spritekit-ref, axiom-spritekit-diag

More Debugging skills

← All Debugging skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY