swiftship/internal/skills/data/features/spritekit-game/SKILL.md
Use for 2D games: arcade, puzzle, sports, ping pong, platformer, shooter, 2D racing. SpriteKit + SpriteView architecture, scene hierarchy, physics, game loop, audio, particles, game feel.
npx skillsauth add abdullah4ai/apple-developer-toolkit spritekit-gameInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This skill is for 2D games only. For 3D games (racing, bowling, board games, marble maze, tower defense, 3D sports), load the scenekit-game skill instead.
ARCHITECTURE OVERRIDE: 2D games use SpriteKit (SKScene + SpriteView), NOT plain SwiftUI views with timers or Canvas. The standard @main App → RootView → MainView pattern still applies, but MainView hosts a SpriteView instead of standard SwiftUI content. AppTheme rules apply to SwiftUI parts (menus, overlays) but NOT to SpriteKit scene internals where you use SKColor, SKLabelNode fonts, etc. directly.
Also load the game-assets skill for downloading sprites and textures via nw_download_asset.
import SpriteKit
import SwiftUI
struct GameView: View {
@State var gameState = GameState()
var body: some View {
ZStack {
SpriteView(scene: makeScene(), isPaused: gameState.isPaused)
.ignoresSafeArea()
// SwiftUI overlays for HUD, menus (use AppTheme here)
if gameState.isPaused {
PauseMenuView(gameState: gameState)
}
}
}
private func makeScene() -> GameScene {
let scene = GameScene(size: UIScreen.main.bounds.size)
scene.scaleMode = .resizeFill
scene.gameState = gameState
return scene
}
}
Rules:
.scaleMode = .resizeFill so the scene adapts to actual view size@Observable game state objectsSpriteView(scene:, isPaused:) to control pause from SwiftUILayer-based node hierarchy — create in didMove(to:):
SKScene (GameScene)
├── backgroundLayer (SKNode) — z: -100
├── gameplayLayer (SKNode) — z: 0
└── hudLayer (SKNode) — z: 100
Rules:
zPosition on layers, not individual spritesSKCameraNode to gameplayLayer, set scene.cameraview?.presentScene(newScene, transition: .push(with: .up, duration: 0.3))willMove(from:) — remove actions, nil out referencesstruct PhysicsCategory {
static let none: UInt32 = 0
static let player: UInt32 = 0x1 << 0
static let enemy: UInt32 = 0x1 << 1
static let ball: UInt32 = 0x1 << 2
static let wall: UInt32 = 0x1 << 3
}
Rules:
categoryBitMask, contactTestBitMask, collisionBitMask on every physics bodySKPhysicsContactDelegate — use Double Dispatch (delegate to nodes, not massive if-else)usesPreciseCollisionDetection = true only for fast objects (balls, bullets)physicsWorld.gravity = .zero for top-down games, CGVector(dx: 0, dy: -9.8) for platformersprivate var lastUpdateTime: TimeInterval = 0
override func update(_ currentTime: TimeInterval) {
let dt = lastUpdateTime == 0 ? 0 : currentTime - lastUpdateTime
lastUpdateTime = currentTime
updateGameLogic(deltaTime: dt)
}
Rules:
update() → game logic, didEvaluateActions() → post-action, didSimulatePhysics() → position corrections@MainActor @objc for CADisplayLink targets in Swift 6@Observable
class GameState {
var score: Int = 0
var lives: Int = 3
var isPaused: Bool = false
var phase: GamePhase = .menu
enum GamePhase {
case menu, playing, paused, gameOver
}
}
Share between SwiftUI and SKScene — SKScene updates properties, SwiftUI reacts automatically.
Generate sounds programmatically — no audio files needed:
import AVFoundation
func generateTone(frequency: Float, duration: Float) -> AVAudioPlayer? {
let sampleRate: Float = 44100
let samples = Int(sampleRate * duration)
var data = Data()
// WAV header
let dataSize = samples * 2
let fileSize = 36 + dataSize
data.append(contentsOf: "RIFF".utf8)
data.append(contentsOf: withUnsafeBytes(of: Int32(fileSize).littleEndian) { Array($0) })
data.append(contentsOf: "WAVEfmt ".utf8)
data.append(contentsOf: withUnsafeBytes(of: Int32(16).littleEndian) { Array($0) })
data.append(contentsOf: withUnsafeBytes(of: Int16(1).littleEndian) { Array($0) }) // PCM
data.append(contentsOf: withUnsafeBytes(of: Int16(1).littleEndian) { Array($0) }) // mono
data.append(contentsOf: withUnsafeBytes(of: Int32(44100).littleEndian) { Array($0) })
data.append(contentsOf: withUnsafeBytes(of: Int32(88200).littleEndian) { Array($0) })
data.append(contentsOf: withUnsafeBytes(of: Int16(2).littleEndian) { Array($0) })
data.append(contentsOf: withUnsafeBytes(of: Int16(16).littleEndian) { Array($0) })
data.append(contentsOf: "data".utf8)
data.append(contentsOf: withUnsafeBytes(of: Int32(dataSize).littleEndian) { Array($0) })
for i in 0..<samples {
let t = Float(i) / sampleRate
let envelope = max(0, 1.0 - t / duration)
let sample = Int16(sin(2.0 * .pi * frequency * t) * Float(Int16.max) * envelope * 0.5)
data.append(contentsOf: withUnsafeBytes(of: sample.littleEndian) { Array($0) })
}
return try? AVAudioPlayer(data: data)
}
Common game sounds:
Create particles in code (no .sks files needed):
func createHitParticles(at position: CGPoint) -> SKEmitterNode {
let emitter = SKEmitterNode()
emitter.particleBirthRate = 200
emitter.numParticlesToEmit = 20
emitter.particleLifetime = 0.3
emitter.particleSpeed = 150
emitter.particleSpeedRange = 50
emitter.emissionAngleRange = .pi * 2
emitter.particleScale = 0.1
emitter.particleScaleRange = 0.05
emitter.particleAlphaSpeed = -3.0
emitter.particleColor = .white
emitter.position = position
// Auto-remove after emission completes
emitter.run(.sequence([.wait(forDuration: 0.5), .removeFromParent()]))
return emitter
}
Screen shake on impact:
func screenShake(intensity: CGFloat = 8, duration: TimeInterval = 0.15) {
let shake = SKAction.sequence([
SKAction.moveBy(x: intensity, y: intensity, duration: duration / 4),
SKAction.moveBy(x: -intensity * 2, y: -intensity, duration: duration / 4),
SKAction.moveBy(x: intensity, y: -intensity, duration: duration / 4),
SKAction.moveBy(x: 0, y: intensity, duration: duration / 4),
])
camera?.run(shake)
}
Hit pause (freeze frame) — 50-100ms pause on impact:
func hitPause(duration: TimeInterval = 0.05) {
isPaused = true
DispatchQueue.main.asyncAfter(deadline: .now() + duration) { self.isPaused = false }
}
Haptic feedback:
let impact = UIImpactFeedbackGenerator(style: .medium)
impact.impactOccurred()
let texture = view.texture(from: shapeNode)deinit during developmentSports/Pong: gravity = .zero, rectangle paddles, ball with restitution = 1.0, AI tracks ball with delay
Platformer: gravity = CGVector(dx: 0, dy: -9.8), applyImpulse() for jumps, ground detection via contact
Endless Runner: Parallax scrolling with duplicate background sprites leap-frogging, SKCameraNode follows player
Puzzle/Match-3: Grid data structure, nested loop match detection, SKAction chains for animations
Shooter: Contact (not collision) tests for bullets, spawn timers for enemy waves, projectile pooling
For visual assets (sprites, backgrounds, textures), load the game-assets skill to use the nw_download_asset tool. For sounds, generate programmatically as shown above.
testing
Use for 3D games: racing, 3D sports, board games, marble maze, tower defense, bowling. SceneKit + SceneView architecture, 3D scene hierarchy, physics, game loop, primitives, materials, cameras, particles, audio.
documentation
Game UI patterns: SwiftUI HUD overlays on SpriteKit, menus (main/pause/game-over), virtual joystick/d-pad, score displays, health bars, tutorial onboarding.
tools
Download free game sprites/textures/3D models and generate procedural assets. Covers nw_download_asset tool, texture factories, sprite atlas organization, 3D model loading, and programmatic asset creation.
tools
Share extension with NSExtensionActivationRule filtering, SLComposeServiceViewController handling, App Group data sharing, and shared content processing for URLs, text, and images. Use when adding share sheet integration to an iOS app.