- 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).
- // 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
- .
- projectile
- Bitmask Checklist
- For every physics body, verify:
- categoryBitMask
- set to exactly one category
- collisionBitMask
- set to only categories it should bounce off (NOT
- 0xFFFFFFFF
- )
- contactTestBitMask
- set to categories that should trigger delegate callbacks
- 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/frame
- Forces 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 end
- Actions 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 ) } } 6. Performance Performance Priorities For detailed performance diagnosis, see axiom-spritekit-diag Symptom 3. Key priorities: Node count — Remove offscreen nodes, use object pooling Draw calls — Use texture atlases, replace SKShapeNode with pre-rendered textures Physics cost — Prefer simple body shapes, limit usesPreciseCollisionDetection 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 drawn Delta 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
- .
- enemy
- Anti-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
- .
- enemy
- Anti-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
- (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):
- Enable
- showsPhysics
- — verify bodies exist and overlap
- Print all three bitmasks for both bodies
- Verify
- contactTestBitMask
- on body A includes category of body B (or vice versa)
- Verify
- physicsWorld.contactDelegate
- is set
- 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
- :
- Enable
- showsFPS
- ,
- showsNodeCount
- ,
- showsDrawCount
- Test on oldest supported device
- If >200 nodes or >30 draw calls, investigate
- Check for SKShapeNode in gameplay
- 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
- :
- Check
- showsDrawCount
- — each SKShapeNode adds a draw call
- If >20 shape nodes in gameplay, pre-render to textures
- Use
- view.texture(from:)
- to convert once, reuse as SKSpriteNode
- 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