Gameplay/Systems/EnergySystem.swift
final class EnergySystem {
let maximumEnergy: Int
let startingEnergy: Int
private(set) var currentEnergy: Int
var onEnergyChanged: ((Int) -> Void)?
var isEmpty: Bool { currentEnergy == 0 }
func addEnergy(_ amount: Int) {
guard amount > 0 else { return }
updateEnergy(to: currentEnergy + amount)
}
func reduceEnergy(_ amount: Int) {
guard amount > 0 else { return }
updateEnergy(to: currentEnergy - amount)
}
private func updateEnergy(to newValue: Int) {
let safeValue = min(max(0, newValue), maximumEnergy)
guard safeValue != currentEnergy else { return }
currentEnergy = safeValue
onEnergyChanged?(currentEnergy)
}
}
Energy System: The turtle's stamina. Jellyfish restore energy, trash and bombs drain it; when it hits zero, the run ends. The rest of the game only needs to observe one value. A small, single-purpose class with a clamped setter and a change callback. GameScene subscribes with onEnergyChanged and forwards the value to the HUD, so gameplay logic never touches UI nodes directly.
Scenes/GameScene.swift
enum GamePhase {
case preGame // title screen, two turtles swimming
case cutscene // Tuki gets caught by the net
case tutorial // guided practice before real gameplay
case playing
case paused
case gameOver
}
final class GameScene: SKScene {
private(set) var phase: GamePhase = .preGame
// Each update() and touch handler branches on `phase`,
// so tutorial objects, spawners, and HUD can never
// run in the wrong state.
}
Game Phase Management: Pre-game screen, cutscene, guided tutorial, gameplay, pause, and game over all live inside one SpriteKit scene. A phase enum keeps them from stepping on each other. Instead of presenting six separate SKScenes (and losing shared state on every transition), one GamePhase enum drives what update(), touch handling, and the spawner are allowed to do in each phase.
UI/HUD.swift
private func visibleFrame(in scene: SKScene) -> CGRect {
guard let view = scene.view,
scene.size.width > 0,
scene.size.height > 0 else {
return scene.frame
}
let scale = max(
view.bounds.width / scene.size.width,
view.bounds.height / scene.size.height
)
let visibleWidth = view.bounds.width / scale
let visibleHeight = view.bounds.height / scale
return CGRect(
x: scene.frame.midX - visibleWidth / 2,
y: scene.frame.midY - visibleHeight / 2,
width: visibleWidth,
height: visibleHeight
)
}
Responsive Layout (visibleFrame): The scene is 750×1334 presented with .aspectFill, so roughly 70pt on each side is cropped on iPhone. Anything anchored to frame.minX/maxX simply disappears off-screen. Compute the truly visible rectangle from the view bounds and the aspect-fill scale factor, then anchor every HUD element to that rect with proportional margins instead of the raw scene frame.
Physics/ContactHandler.swift
final class ContactHandler: NSObject, SKPhysicsContactDelegate {
var onTurtleHitBomb: ((Bomb) -> Void)?
var onTurtleHitTrash: ((SKSpriteNode) -> Void)?
var onTurtleCollectJellyfish: ((Jellyfish) -> Void)?
var onTurtleHitSixthRing: ((SixthRing) -> Void)?
func didBegin(_ contact: SKPhysicsContact) {
if containsPair(contact, PhysicsCategory.turtle,
PhysicsCategory.bomb) {
handleBombContact(contact)
return
}
if containsPair(contact, PhysicsCategory.turtle,
PhysicsCategory.trash) {
handleTrashContact(contact)
return
}
// ...jellyfish and six-pack ring follow
}
}
Collision Routing: Every physics contact — turtle vs. bomb, trash, jellyfish, or six-pack ring — needs to trigger different gameplay reactions without coupling physics code to game rules. A dedicated SKPhysicsContactDelegate that only classifies contacts and fires typed closures. GameScene wires the closures to score, energy, and feedback systems at setup time.