--- 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 metadata: version: "1.0.0" --- # 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, 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, 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.. 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