Need guidance on iOS game development from scratch

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:

  1. GameViewController

    • Owns SKView
    • Loads your first SKScene
  2. Scenes

    • MainMenuScene
    • GameScene
    • PauseScene (optional)
  3. 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

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:

  1. Texture sizes

    • Prefer texture atlases
    • Use power of two textures if possible
    • Avoid huge textures, try to stay under 2048x2048 per texture
  2. 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
  3. Physics

    • Keep physics bodies simple shapes
    • Avoid many precise polygon bodies
    • Reduce collision categories, use bitmasks wisely
  4. 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
  5. Particles

    • Particle emitters are expensive
    • Short lifetimes, low birthrates
    • Turn off emitters when offscreen
  6. 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() and removeAllActions() when leaving scenes where needed
  • Use SKTextureAtlas for grouped graphics
  • Avoid strong reference cycles in closures [weak self] in SKActions with blocks

Rough checklist to keep you on track

  1. Prototype

    • Empty GameScene with a player that moves with touches
    • One enemy type
    • Simple lose condition
  2. Structure

    • Extract Player, EnemyManager, GameState
    • Add a basic main menu scene
  3. Polish

    • Add sounds using SKAction.playSoundFileNamed or AVAudioPlayer for bgm
    • Try on slow device or Simulator with Low Power mode to feel perf
  4. 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

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.swift file
  • One GameConfig.swift for 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 on SKActions, you can read positions here instead of update.

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?.velocity only
  • Node B: position changed using position += velocity * dt only

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
  • Allocations in update:
    Don’t create new arrays or allocate textures every frame. If you see let something = [...] inside update, be suspicious.

  • Text / labels:
    SKLabelNode is 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 of SKActions can actually get messy. For constant movement / fading / scaling that’s always happening, I’d rather do it manually in update with dt. 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:

  • GameSession struct or class:

    • current score
    • high score
    • current level / difficulty
      Lives between scene reloads.
  • Config or Balance:

    • 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

  1. Single scene, player that moves, one enemy type, collisions, “you died” label.
  2. Add a simple state machine in GameScene: .menu, .playing, .gameOver. No extra scenes yet.
  3. Add some kind of score and simple spawn logic.
  4. Only then:
    • Extract Player, EnemyManager.
    • Add a proper main menu scene if you still feel like it.
  5. 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.