CtrlK
BlogDocsLog inGet started
Tessl Logo

dpearson2699/swift-ios-skills

Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.

80

Quality

100%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Advisory

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

spritekit-patterns.mdskills/spritekit/references/

SpriteKit Patterns

Advanced SpriteKit patterns for tile maps, texture atlases, shader effects, scene transitions, game loop architecture, audio, and SceneKit embedding.

Contents

  • Tile Maps
  • Texture Atlases
  • Scene Transitions
  • Game Loop Patterns
  • Audio
  • Lighting
  • Custom Shaders
  • Constraints
  • Physics Joints
  • Physics Fields
  • Crop and Effect Nodes
  • SceneKit in SpriteKit
  • Performance Optimization

Tile Maps

SKTileMapNode renders a grid of tile images. Define tile sets in Xcode's SpriteKit scene editor or build them programmatically. Tile maps support square, hexagonal, and isometric grids.

Programmatic Tile Map

func createTileMap() -> SKTileMapNode {
    // Create tile definitions
    let grassTexture = SKTexture(imageNamed: "grass")
    let grassDef = SKTileDefinition(texture: grassTexture, size: CGSize(width: 32, height: 32))

    let dirtTexture = SKTexture(imageNamed: "dirt")
    let dirtDef = SKTileDefinition(texture: dirtTexture, size: CGSize(width: 32, height: 32))

    // Create tile groups
    let grassGroup = SKTileGroup(tileDefinition: grassDef)
    let dirtGroup = SKTileGroup(tileDefinition: dirtDef)

    // Create tile set
    let tileSet = SKTileSet(tileGroups: [grassGroup, dirtGroup])

    // Create tile map
    let tileMap = SKTileMapNode(
        tileSet: tileSet,
        columns: 20,
        rows: 15,
        tileSize: CGSize(width: 32, height: 32)
    )
    tileMap.fill(with: grassGroup)
    return tileMap
}

Setting Individual Tiles

// Set a specific tile
tileMap.setTileGroup(dirtGroup, forColumn: 5, row: 3)

// Set tile with a specific definition (for adjacency rules)
tileMap.setTileGroup(dirtGroup, andTileDefinition: dirtDef, forColumn: 5, row: 3)

Coordinate Conversion

Convert between tile grid coordinates and scene positions:

// Scene position to tile coordinate
let column = tileMap.tileColumnIndex(fromPosition: touchLocation)
let row = tileMap.tileRowIndex(fromPosition: touchLocation)

// Tile coordinate to scene position
let center = tileMap.centerOfTile(atColumn: column, row: row)

Adding Physics to Tiles

Tile maps do not expose individual tiles as nodes. Overlay invisible SKNode objects for physics:

func addPhysicsToTileMap(_ tileMap: SKTileMapNode) {
    let tileSize = tileMap.tileSize

    for column in 0..<tileMap.numberOfColumns {
        for row in 0..<tileMap.numberOfRows {
            guard let def = tileMap.tileDefinition(atColumn: column, row: row),
                  def.userData?["isWall"] as? Bool == true else { continue }

            let center = tileMap.centerOfTile(atColumn: column, row: row)
            let wall = SKNode()
            wall.position = center
            wall.physicsBody = SKPhysicsBody(rectangleOf: tileSize)
            wall.physicsBody?.isDynamic = false
            wall.physicsBody?.categoryBitMask = PhysicsCategory.wall
            tileMap.addChild(wall)
        }
    }
}

Adjacency Rules

Use SKTileGroupRule with adjacency masks for auto-tiling (e.g., terrain edges that blend into neighboring terrain):

let centerRule = SKTileGroupRule(
    adjacency: .adjacencyAll,
    tileDefinitions: [centerDef]
)
let topEdgeRule = SKTileGroupRule(
    adjacency: .adjacencyUpEdge,
    tileDefinitions: [topEdgeDef]
)
let tileGroup = SKTileGroup(rules: [centerRule, topEdgeRule])

Enable auto-fill by using tileMap.enableAutomapping = true in the scene editor. The tile map picks the correct definition based on neighboring tiles.

Texture Atlases

Texture atlases pack multiple images into a single texture, reducing draw calls and improving GPU performance.

Creating an Atlas in Xcode

  1. Add a folder with the .atlas extension to the asset catalog.
  2. Place individual images inside the folder.
  3. SpriteKit compiles them into a single texture sheet at build time.

Loading Textures from an Atlas

let atlas = SKTextureAtlas(named: "Characters")
let textures = atlas.textureNames.sorted().map { atlas.textureNamed($0) }

let animation = SKAction.animate(with: textures, timePerFrame: 0.1)
sprite.run(SKAction.repeatForever(animation))

Preloading Atlases

Preload atlases before presenting a scene to avoid frame drops during first use:

SKTextureAtlas.preloadTextureAtlasesNamed(["Characters", "Environment"]) {
    error, atlases in
    // Atlases are now in GPU memory; present the scene.
    presentGameScene()
}

Alternatively, use SpriteKit's async preload APIs:

func preloadAssets() async throws {
    _ = try await SKTextureAtlas.preloadTextureAtlasesNamed([
        "Characters",
        "Environment"
    ])

    await SKTextureAtlas.preloadTextureAtlases([
        SKTextureAtlas(named: "Effects")
    ])
}

Texture Filtering

let texture = SKTexture(imageNamed: "pixel_art")
texture.filteringMode = .nearest  // Sharp pixels for pixel art
// .linear (default) for smooth scaling

Scene Transitions

SKTransition provides animated transitions between scenes. Present the new scene with a transition through the view:

func goToGameOver() {
    let gameOverScene = GameOverScene(size: size)
    gameOverScene.scaleMode = scaleMode
    let transition = SKTransition.fade(withDuration: 1.0)
    view?.presentScene(gameOverScene, transition: transition)
}

Transition Types

// Fade
SKTransition.fade(withDuration: 1.0)
SKTransition.fade(with: .black, duration: 1.0)

// Slide
SKTransition.push(with: .left, duration: 0.5)
SKTransition.moveIn(with: .right, duration: 0.5)
SKTransition.reveal(with: .down, duration: 0.5)

// Dissolve effects
SKTransition.crossFade(withDuration: 1.0)
SKTransition.flipHorizontal(withDuration: 0.5)
SKTransition.flipVertical(withDuration: 0.5)

// Doors
SKTransition.doorway(withDuration: 1.0)
SKTransition.doorsOpenHorizontal(withDuration: 0.5)
SKTransition.doorsOpenVertical(withDuration: 0.5)
SKTransition.doorsCloseHorizontal(withDuration: 0.5)
SKTransition.doorsCloseVertical(withDuration: 0.5)

Pausing During Transition

By default, both the outgoing and incoming scenes run during a transition. Pause the outgoing scene if needed:

let transition = SKTransition.fade(withDuration: 1.0)
transition.pausesOutgoingScene = true
transition.pausesIncomingScene = false

Game Loop Patterns

Delta Time Tracking

Frame-rate-independent movement requires delta time calculation:

final class GameScene: SKScene {

    private var lastUpdateTime: TimeInterval = 0

    override func update(_ currentTime: TimeInterval) {
        let deltaTime: TimeInterval
        if lastUpdateTime == 0 {
            deltaTime = 0
        } else {
            deltaTime = currentTime - lastUpdateTime
        }
        lastUpdateTime = currentTime

        updateEntities(deltaTime: deltaTime)
    }
}

Entity-Component Pattern

Organize game objects using a simple entity-component structure. For complex games, consider GameplayKit's GKEntity and GKComponent.

protocol GameComponent {
    func update(deltaTime: TimeInterval)
}

final class HealthComponent: GameComponent {
    var hitPoints: Int
    var maxHitPoints: Int

    init(hitPoints: Int) {
        self.hitPoints = hitPoints
        self.maxHitPoints = hitPoints
    }

    func update(deltaTime: TimeInterval) { }

    func takeDamage(_ amount: Int) {
        hitPoints = max(0, hitPoints - amount)
    }
}

final class MovementComponent: GameComponent {
    weak var node: SKNode?
    var velocity: CGVector = .zero

    func update(deltaTime: TimeInterval) {
        guard let node else { return }
        node.position.x += velocity.dx * deltaTime
        node.position.y += velocity.dy * deltaTime
    }
}

Spawn Timer Pattern

Use SKAction for timed spawning rather than manual timer tracking:

func startSpawning() {
    let spawn = SKAction.run { [weak self] in
        self?.spawnEnemy()
    }
    let delay = SKAction.wait(forDuration: 2.0, withRange: 1.0) // 1.5-2.5s
    run(SKAction.repeatForever(SKAction.sequence([spawn, delay])), withKey: "spawning")
}

func stopSpawning() {
    removeAction(forKey: "spawning")
}

Scene Delegate Pattern

Use SKSceneDelegate to share update logic across scenes without subclassing:

final class GameController: SKSceneDelegate {

    func update(_ currentTime: TimeInterval, for scene: SKScene) {
        // Shared game logic applied to any scene
    }

    func didEvaluateActions(for scene: SKScene) { }
    func didSimulatePhysics(for scene: SKScene) { }
}

// Usage
let scene = SKScene(fileNamed: "Level1")!
scene.delegate = gameController

Audio

SKAudioNode

SKAudioNode provides positional audio tied to a node's position in the scene. Set the scene's listener property for spatial audio.

// Background music
let music = SKAudioNode(fileNamed: "background.mp3")
music.autoplayLooped = true
music.isPositional = false
addChild(music)

// Positional sound effect
let engineSound = SKAudioNode(fileNamed: "engine.wav")
engineSound.isPositional = true
engineSound.autoplayLooped = true
spaceship.addChild(engineSound)

// Set the listener for positional audio
listener = cameraNode

Sound Effects with SKAction

For short, non-positional sound effects:

let playSound = SKAction.playSoundFileNamed("explosion.wav", waitForCompletion: false)
run(playSound)

This is simple but offers no volume or positional control. Use SKAudioNode for sounds that need spatial positioning or dynamic volume.

Stopping Audio

// Stop a specific audio node
music.run(SKAction.changeVolume(to: 0, duration: 1.0)) {
    music.removeFromParent()
}

// Or immediately
music.removeFromParent()

Lighting

SKLightNode adds 2D lighting with shadows to a scene. Light affects sprites that have matching lightingBitMask values.

let light = SKLightNode()
light.categoryBitMask = 0b0001
light.falloff = 1.5
light.ambientColor = UIColor(white: 0.3, alpha: 1.0)
light.lightColor = .white
light.shadowColor = UIColor(white: 0.0, alpha: 0.5)
light.position = player.position
addChild(light)

// Enable lighting on a sprite
wall.lightingBitMask = 0b0001
wall.shadowCastBitMask = 0b0001   // This sprite casts shadows
wall.shadowedBitMask = 0b0001     // This sprite receives shadows

Normal Maps for Depth

Apply a normal map texture to a sprite for per-pixel lighting detail:

let sprite = SKSpriteNode(imageNamed: "stone_wall")
sprite.normalTexture = SKTexture(imageNamed: "stone_wall_normal")
sprite.lightingBitMask = 0b0001

Custom Shaders

SKShader applies custom GLSL fragment shaders to sprites, shape nodes, emitters, and tile maps.

let shader = SKShader(source: """
    void main() {
        vec2 uv = v_tex_coord;
        vec4 color = texture2D(u_texture, uv);
        float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
        gl_FragColor = vec4(vec3(gray), color.a) * v_color_mix;
    }
""")
sprite.shader = shader

Uniform Variables

Pass values from Swift to the shader:

let shader = SKShader(fileNamed: "dissolve.fsh")
shader.uniforms = [
    SKUniform(name: "u_threshold", float: 0.5)
]
sprite.shader = shader

// Update at runtime
shader.uniformNamed("u_threshold")?.floatValue = newValue

Built-in Shader Symbols

SymbolTypeDescription
u_texturesampler2DThe node's texture
u_timefloatTime since shader attached
u_path_lengthfloatPath length (shape nodes)
v_tex_coordvec2Texture coordinate
v_color_mixvec4Node's blend color
v_path_distancefloatDistance along a shape-node stroke path
SKDefaultShading()functionDefault SpriteKit shading behavior

Values such as u_sprite_size are app-defined uniforms or attributes, not SpriteKit-provided built-ins.

Attribute Values

Pass per-node values through attributes when multiple nodes share a shader but need different parameters:

let shader = SKShader(fileNamed: "tint.fsh")
shader.attributes = [
    SKAttribute(name: "a_tintColor", type: .vectorFloat4)
]

sprite.setValue(
    SKAttributeValue(vectorFloat4: vector_float4(1, 0, 0, 1)),
    forAttribute: "a_tintColor"
)

Constraints

SKConstraint limits a node's position or rotation automatically each frame.

// Keep node oriented toward a target
let orient = SKConstraint.orient(to: targetNode, offset: SKRange(constantValue: 0))
turret.constraints = [orient]

// Keep node within a rectangular boundary
let xRange = SKRange(lowerLimit: 50, upperLimit: frame.width - 50)
let yRange = SKRange(lowerLimit: 50, upperLimit: frame.height - 50)
let boundary = SKConstraint.positionX(xRange, y: yRange)
player.constraints = [boundary]

// Keep distance from another node
let distance = SKConstraint.distance(SKRange(lowerLimit: 50, upperLimit: 200), to: leader)
follower.constraints = [distance]

Physics Joints

Joints connect two physics bodies. Both bodies must exist in the scene before creating a joint.

// Pin joint: bodies rotate around a shared anchor
let pin = SKPhysicsJointPin.joint(
    withBodyA: wheelBody,
    bodyB: chassisBody,
    anchor: wheelNode.position
)
physicsWorld.add(pin)

// Spring joint: elastic connection
let spring = SKPhysicsJointSpring.joint(
    withBodyA: bodyA.physicsBody!,
    bodyB: bodyB.physicsBody!,
    anchorA: bodyA.position,
    anchorB: bodyB.position
)
spring.frequency = 1.0
spring.damping = 0.5
physicsWorld.add(spring)

// Fixed joint: rigid connection
let fixed = SKPhysicsJointFixed.joint(
    withBodyA: partA.physicsBody!,
    bodyB: partB.physicsBody!,
    anchor: CGPoint(x: 0, y: 0)
)
physicsWorld.add(fixed)

// Sliding joint: constrained to an axis
let slide = SKPhysicsJointSliding.joint(
    withBodyA: slider.physicsBody!,
    bodyB: track.physicsBody!,
    anchor: slider.position,
    axis: CGVector(dx: 1, dy: 0)
)
physicsWorld.add(slide)

// Limit joint: maximum distance between anchors
let limit = SKPhysicsJointLimit.joint(
    withBodyA: chainLink1.physicsBody!,
    bodyB: chainLink2.physicsBody!,
    anchorA: chainLink1.position,
    anchorB: chainLink2.position
)
physicsWorld.add(limit)

Remove joints with physicsWorld.remove(joint).

Physics Fields

SKFieldNode applies forces to physics bodies within a region. Bodies opt in through fieldBitMask.

// Radial gravity (black hole effect)
let gravity = SKFieldNode.radialGravityField()
gravity.strength = 5.0
gravity.falloff = 1.0
gravity.position = CGPoint(x: frame.midX, y: frame.midY)
gravity.region = SKRegion(radius: 200)
addChild(gravity)

// Vortex (swirling)
let vortex = SKFieldNode.vortexField()
vortex.strength = 2.0

// Turbulence (random jitter)
let turbulence = SKFieldNode.turbulenceField(withSmoothness: 0.5, animationSpeed: 1.0)

// Linear gravity (wind)
let wind = SKFieldNode.linearGravityField(withVector: vector_float3(2, 0, 0))
wind.strength = 3.0

// Electric field (attracts/repels based on charge)
let electric = SKFieldNode.electricField()
// Set physicsBody.charge on affected bodies

Bodies interact with fields when their fieldBitMask matches the field node's categoryBitMask.

Crop and Effect Nodes

SKCropNode

Masks child content using another node as a mask shape:

let cropNode = SKCropNode()
let maskShape = SKSpriteNode(imageNamed: "circle_mask")
cropNode.maskNode = maskShape
cropNode.addChild(contentSprite)
addChild(cropNode)

Only the portions of children that overlap the mask's non-transparent pixels are rendered.

SKEffectNode

Applies a Core Image filter to its child subtree:

let effectNode = SKEffectNode()
effectNode.shouldRasterize = true  // Cache the result for performance
effectNode.filter = CIFilter(name: "CIGaussianBlur", parameters: [
    "inputRadius": 10.0
])
effectNode.addChild(backgroundSprite)
addChild(effectNode)

Use shouldRasterize = true when children do not change frequently.

SceneKit in SpriteKit

SK3DNode embeds a SceneKit scene within a SpriteKit scene:

let node3D = SK3DNode(viewportSize: CGSize(width: 200, height: 200))

let scnScene = SCNScene(named: "model.scn")!
node3D.scnScene = scnScene

// Set a camera for the 3D viewport
let scnCamera = SCNCamera()
let cameraNode = SCNNode()
cameraNode.camera = scnCamera
cameraNode.position = SCNVector3(x: 0, y: 2, z: 5)
scnScene.rootNode.addChild(cameraNode)
node3D.pointOfView = cameraNode

node3D.position = CGPoint(x: frame.midX, y: frame.midY)
addChild(node3D)

The 3D node participates in the 2D scene's draw order like any other node.

Performance Optimization

Draw Call Reduction

  • Use texture atlases to batch sprites sharing the same atlas into a single draw call.
  • Set ignoresSiblingOrder = true on SKView to enable automatic batching.
  • Avoid SKShapeNode for repeated elements; convert shapes to textures using SKView.texture(from:).

Node Count Management

  • Remove offscreen nodes. Use SKCameraNode.containedNodeSet() or manual bounds checking to cull nodes that leave the viewport.
  • Pool and reuse nodes instead of creating and destroying them each frame.
  • Use SKAction.removeFromParent() in action sequences for projectiles and effects that leave the screen.
final class NodePool<T: SKNode> {
    private var available: [T] = []

    func acquire() -> T {
        if let node = available.popLast() {
            return node
        }
        return T()
    }

    func release(_ node: T) {
        node.removeAllActions()
        node.removeFromParent()
        available.append(node)
    }
}

Physics Optimization

  • Use simple shapes (circleOfRadius, rectangleOf) over texture:size: bodies when possible.
  • Set usesPreciseCollisionDetection = true only on fast-moving small bodies that tunnel through thin obstacles.
  • Set isDynamic = false on static scenery.
  • Limit the number of active physics bodies; disable physics on offscreen nodes.

Emitter Performance

  • Limit particleBirthRate and particleLifetime to the minimum needed.
  • Set numParticlesToEmit for finite effects and remove the emitter after completion.
  • Use advanceSimulationTime(_:) to pre-warm emitters that should appear mid-effect when added to the scene.
  • Set targetNode to the scene when particles should detach from a moving emitter, but be aware this prevents batching.

Texture Memory

  • Use the smallest texture size that looks acceptable at the rendered size.
  • Provide @2x and @3x variants only when necessary.
  • Call SKTextureAtlas.preloadTextureAtlases to load textures before the scene appears, avoiding mid-game stalls.

Profiling

Enable debug overlays on SKView during development:

skView.showsFPS = true
skView.showsNodeCount = true
skView.showsDrawCount = true
skView.showsPhysics = true

Use Instruments with the SpriteKit template to profile frame time, draw calls, and node count over time. The Core Animation instrument helps identify GPU bottlenecks.

skills

.mcp.json

README.md

tile.json