I’m trying to build my first iOS game using Swift and SpriteKit, but I’m confused about how to structure the project, handle game loops, and optimize for performance on different iPhone models. I’ve followed a few tutorials, but my prototype lags and the codebase is getting messy. Can someone explain the best practices or share a clear step‑by‑step approach to organizing an iOS game project and improving frame rates for beginners?
You are on the right stack. SpriteKit + Swift is fine for a first iOS game.
Project structure
Keep it simple first. Something like:
-
GameViewController
- Owns SKView
- Loads your first SKScene
-
Scenes
- MainMenuScene
- GameScene
- PauseScene (optional)
-
In GameScene split logic into a few files:
- GameState.swift (enum: playing, paused, gameOver)
- Player.swift (SKSpriteNode subclass or wrapper)
- EnemyManager.swift (spawns enemies)
- PhysicsCategory.swift (bitmasks)
You do not need a fancy architecture for v1. Keep stuff in GameScene until it hurts, then extract.
Game loop in SpriteKit
SpriteKit gives you update(_ currentTime: TimeInterval). That is your loop.
Typical flow in GameScene:
update- compute
deltaTime - update player
- update enemies
- scroll background
- run game logic based on
gameState
- compute
Example:
class GameScene: SKScene {
private var lastUpdateTime: TimeInterval = 0
override func update(_ currentTime: TimeInterval) {
if lastUpdateTime == 0 {
lastUpdateTime = currentTime
}
let dt = currentTime - lastUpdateTime
lastUpdateTime = currentTime
guard gameState == .playing else { return }
player.update(dt: dt)
enemyManager.update(dt: dt)
background.update(dt: dt)
}
}
Never store logic in didMove(to:) other than setup. Put time based stuff in update.
Time based movement
Use dt so it feels the same on 60 vs 120 Hz screens.
Bad:
player.position.x += 5
Better:
player.position.x += playerSpeed * dt
Use physics or manual movement, not both at same time on same nodes or you get weird jitter.
Performance on different iPhones
Key points:
-
Texture sizes
- Prefer texture atlases
- Use power of two textures if possible
- Avoid huge textures, try to stay under 2048x2048 per texture
-
Node count
- Old iPhones start to choke around a few thousand nodes
- Try to stay under ~500 visible nodes for a small 2D game
- Reuse nodes with pools for bullets and enemies
-
Physics
- Keep physics bodies simple shapes
- Avoid many precise polygon bodies
- Reduce collision categories, use bitmasks wisely
-
Updates
- Do not run heavy work per node in
update - Try managers that loop over arrays, not tons of self-updating objects with heavy logic
- Do not run heavy work per node in
-
Particles
- Particle emitters are expensive
- Short lifetimes, low birthrates
- Turn off emitters when offscreen
-
Device scaling
In GameViewController set:if let view = self.view as? SKView {
let scene = GameScene(size: CGSize(width: 750, height: 1334))
scene.scaleMode = .aspectFill
view.presentScene(scene)
}Use a fixed logical size, then design UI around “safe area” using relative positions.
You optimize assets once you have something running. Do not start with micro optimization.
Scene transitions
Keep global stuff in a singleton or manager if needed:
- AudioManager
- GameData / Settings
- LevelManager
Do not put global game state in multiple scenes. Use one source of truth, eg GameSession.
Typical flow:
- MainMenuScene
- tap Play
- create GameScene with desired size
- pass level / difficulty via init or a shared GameSession
Memory tips
- Use
texture.removeAllChildren()andremoveAllActions()when leaving scenes where needed - Use
SKTextureAtlasfor grouped graphics - Avoid strong reference cycles in closures
[weak self]in SKActions with blocks
Rough checklist to keep you on track
-
Prototype
- Empty GameScene with a player that moves with touches
- One enemy type
- Simple lose condition
-
Structure
- Extract Player, EnemyManager, GameState
- Add a basic main menu scene
-
Polish
- Add sounds using SKAction.playSoundFileNamed or AVAudioPlayer for bgm
- Try on slow device or Simulator with Low Power mode to feel perf
-
Optimize only after you feel drops
- Use Xcode’s FPS + node count overlay (set
view.showsFPS = true,view.showsNodeCount = true) - If FPS falls under 55 on target device, profile with Instruments
- Use Xcode’s FPS + node count overlay (set
If you share what kind of game, side scroller, endless runner, puzzle, people here can suggest a more tailored folder and class layout.
You’re already getting solid advice from @vrijheidsvogel, so I’ll just fill in some gaps and disagree in a couple of spots to keep things spicy.
1. Project structure without overthinking it
I’d actually start even smaller than suggested:
- One
GameViewController - One
GameScene - One
Player.swiftfile - One
GameConfig.swiftfor constants like speeds, categories, zPositions, etc.
Stay in one scene until:
- You actually need a menu that does something
- You have at least one full loop: start → play → lose → restart
Multiple scenes are nice in theory, but for a first project, you can easily end up debugging scene transitions instead of your actual game. A basic in‑scene state machine plus a UI overlay (labels, buttons as SKSpriteNodes) can handle “menu-ish” stuff at the start.
2. Game loop: use update, but not for everything
update(_:) is great, but don’t cram literally all logic there.
Use this rough split:
update(_:): pure per-frame logic that must be time-based (movement, timers).didSimulatePhysics(): things that depend on final positions from physics (camera following, clamping positions, etc).didEvaluateActions(): if you rely a lot onSKActions, you can read positions here instead ofupdate.
That keeps your logic from fighting the physics / actions ordering.
Also, I’d keep some “event-based” logic outside of update:
- Touches
- Collision callbacks (
didBegin(_ contact:)) - One-off triggers like spawning a wave
Use update to evolve the world over time, not as a trash can for all code.
3. Time handling: don’t trust currentTime blindly
SpriteKit’s currentTime can jump if the app is backgrounded or the debugger pauses. You don’t want a dt of 5 seconds suddenly yeeting your player off screen.
Pattern:
private var lastUpdateTime: TimeInterval = 0
private let maxDT: TimeInterval = 1.0 / 10.0 // cap, e.g. 0.1s
override func update(_ currentTime: TimeInterval) {
if lastUpdateTime == 0 {
lastUpdateTime = currentTime
}
var dt = currentTime - lastUpdateTime
lastUpdateTime = currentTime
// Clamp dt so resumes / breakpoints don’t explode the world
if dt > maxDT { dt = maxDT }
guard gameState == .playing else { return }
updateGame(dt: dt)
}
private func updateGame(dt: TimeInterval) {
player.update(dt: dt)
enemyManager.update(dt: dt)
// etc
}
That small clamp saves you from some really weird “resume from pause” bugs.
4. Using physics vs manual movement
Here I’ll slightly disagree with how aggressively people insist on choosing one. You can mix them, just be strict about per-node rules:
- Node A: position changed using
physicsBody?.velocityonly - Node B: position changed using
position += velocity * dtonly
If you start setting position manually on something with a dynamic physics body, then yeah, things go wobbly. The trick is to pick for each node which system is “in charge” and stick to it.
Simple pattern:
- Player: manual movement if it’s a platformer or top-down shooter
- Bullets: physics bodies with linear velocity
- Environment: static physics bodies, no manual motion except maybe scrolling background without physics
5. Performance: think “budget” instead of random limits
A few practical rules that help more than arbitrary “500 nodes” numbers:
-
Per-frame work:
Try not to loop over huge arrays each frame unless you have to. Prefer:- Early-outs: if
gameState != .playing, return fast - Chunk processing: update 1/2 or 1/4 of rare systems each frame, instead of all every frame
- Early-outs: if
-
Allocations in
update:
Don’t create new arrays or allocate textures every frame. If you seelet something = [...]insideupdate, be suspicious. -
Text / labels:
SKLabelNodeis more expensive than sprites. Don’t spam hundreds of labels for score popups. Use:- Sprite-based numbers
- Or a small pool of reusable labels
-
Actions vs custom logic:
Tons ofSKActions can actually get messy. For constant movement / fading / scaling that’s always happening, I’d rather do it manually inupdatewithdt. Reserve actions for one-off sequences like “death animation and remove”.
6. Device scaling: one more option
Fixed logical size like @vrijheidsvogel showed is solid. Another approach that might be easier for UI-heavy games:
- Base your scene size on the device:
let scene = GameScene(size: view.bounds.size) scene.scaleMode = .resizeFill - Then inside
GameScene, define safe “virtual” regions:let gameArea: CGRect let uiArea: CGRect
Center your gameplay inside gameArea and treat extra horizontal / vertical space as “bonus” area where UI can live or background can stretch. This keeps your gameplay consistent vertically while not freaking out about exact points.
Not strictly better, just another pattern that might fit some game types better than a hard-coded 750×1334.
7. Minimal “architecture” without feeling enterprise-y
If you start drowning in random global state, add these light-weight helpers:
-
GameSessionstruct or class:- current score
- high score
- current level / difficulty
Lives between scene reloads.
-
ConfigorBalance:- enemy spawn rates
- speed values
- etc
That lets you tweak stuff in one place and pass small clean objects into scenes instead of relying on a mess of singletons. I’d avoid going full singleton‑fest unless you genuinely need cross-scene systems like music that must survive everything.
8. A tiny roadmap so you don’t get lost
- Single scene, player that moves, one enemy type, collisions, “you died” label.
- Add a simple state machine in
GameScene:.menu,.playing,.gameOver. No extra scenes yet. - Add some kind of score and simple spawn logic.
- Only then:
- Extract
Player,EnemyManager. - Add a proper main menu scene if you still feel like it.
- Extract
- Run on an older iPhone or lower power mode. If FPS under ~55:
- Check node count
- Check if something is allocating in
update - Check if you have too many particle systems or labels
If you share what kind of game you’re building (endless runner, top-down shooter, etc.), it’s a lot easier to suggest a concrete folder layout and “who owns what” for that specific genre.