13 KiB
TASKS_World2.md — Collapse Logic: World 2 Implementation
Feed this file to Claude Code. Work through tasks in order. Do not skip ahead.
Complete each task fully before starting the next. Run tests after every task that touches the engine.
Task 1 — Extend BlockColor with new colors
File: Models/Block.swift
Add three primary and three secondary colors to the BlockColor enum (yellow already may be stubbed — check first):
enum BlockColor: String, Codable, CaseIterable {
// Primary (can merge)
case red
case blue
case yellow
// Secondary (result of merges)
case purple
case orange
case green
var isPrimary: Bool {
switch self {
case .red, .blue, .yellow: return true
case .purple, .orange, .green: return false
}
}
var displayHex: String {
switch self {
case .red: return "#E63946"
case .blue: return "#457B9D"
case .yellow: return "#F4D35E"
case .purple: return "#7B2D8B"
case .orange: return "#E76F51"
case .green: return "#2A9D8F"
}
}
}
Verify: No existing code breaks. BlockColor is Codable so JSON loading of "yellow", "purple", etc. will now work automatically.
Task 2 — Add mergeColor to GameEngine
File: Engine/GameEngine.swift
Add this static helper. Place it near the top of GameEngine as a private static func:
private static func mergeColor(_ a: BlockColor, _ b: BlockColor) -> BlockColor? {
guard a.isPrimary && b.isPrimary && a != b else { return nil }
let pair = Set([a, b])
if pair == Set([BlockColor.red, .blue]) { return .purple }
if pair == Set([BlockColor.red, .yellow]) { return .orange }
if pair == Set([BlockColor.blue, .yellow]) { return .green }
return nil
}
No other changes in this task.
Task 3 — Update processMove for merge collisions
File: Engine/GameEngine.swift
Find step 5 of the movement algorithm — the collision check after the block slides to position T. Currently it reads:
// Different color → block stops (MVP: no merge)
Replace that branch with:
// Different color — check for merge
if let merged = GameEngine.mergeColor(movingBlock.color, stationaryBlock.color) {
// Remove both blocks
var newBlocks = state.blocks
// Remove in reverse index order to avoid index shifting
let indicesToRemove = [movingIndex, stationaryIndex].sorted(by: >)
for idx in indicesToRemove { newBlocks.remove(at: idx) }
// Place merged block at stationary position
let mergedBlock = Block(position: stationaryBlock.position, color: merged)
newBlocks.append(mergedBlock)
// Emit merged event
events.append(.merged(
blockIndex1: movingIndex,
blockIndex2: stationaryIndex,
resultColor: merged,
at: stationaryBlock.position
))
newState = GameState(
grid: state.grid,
blocks: newBlocks,
walls: state.walls,
moveCount: state.moveCount + 1,
history: state.historySnapshot(),
objective: state.objective,
par: state.par
)
} else {
// No merge possible — block stops at T
// (existing stop logic unchanged)
}
Note:
historySnapshot()should already exist from World 1's undo implementation. If it's named differently in your codebase, use the correct name.
After this task: run ALL existing unit tests. Zero regressions allowed before continuing.
Task 4 — Unit tests for merge logic
File: CollapseLogicTests/GameEngineTests.swift (add to existing test file)
Add a // MARK: - World 2 Merge Tests section with these tests:
// MARK: - World 2 Merge Tests
func testRedBlueProducesPurple() {
// red at (0,0), blue at (2,0), push red right
// Expected: purple block at (2,0), both originals removed
}
func testRedYellowProducesOrange() { }
func testBlueYellowProducesGreen() { }
func testMergedBlockAtStationaryPosition() {
// Verify the new block is at the stationary block's cell, not the moving block's cell
}
func testSecondaryPlusPrimaryStops() {
// purple moving into red → block stops, no merge, no destroy
}
func testDifferentSecondariesStop() {
// purple moving into green → block stops
}
func testSameSecondaryDestroysBoth() {
// purple moving into purple → both destroyed (same rule as primaries)
}
func testMoveCountIncrementsOnMerge() { }
func testUndoAfterMergeRestoresState() {
// After a merge, undo should restore both original blocks and remove the merged block
}
func testWinConditionAfterMerge() {
// Board with one red + one blue. Merge them. If objective is clear_all,
// that's NOT a win (merged block remains). Verify win is NOT triggered.
// Then push merged purple into a pre-placed purple → destroy both → win.
}
Implement each test fully. All must pass before proceeding.
Task 5 — Update BlockNode color rendering
File: Scene/BlockNode.swift
Add color cases for yellow, purple, orange, green in the color-to-UIColor/SKColor switch. Use:
case .yellow: return SKColor(hex: "#F4D35E")
case .purple: return SKColor(hex: "#7B2D8B")
case .orange: return SKColor(hex: "#E76F51")
case .green: return SKColor(hex: "#2A9D8F")
Additionally, add a white inner ring for secondary-color blocks. After setting the block's fill color, add:
if !block.color.isPrimary {
let ring = SKShapeNode(rectOf: CGSize(width: size.width - 8, height: size.height - 8),
cornerRadius: cornerRadius - 2)
ring.fillColor = .clear
ring.strokeColor = SKColor.white.withAlphaComponent(0.4)
ring.lineWidth = 2
ring.zPosition = 1
addChild(ring)
}
Verify visually in the simulator: place a World 1 level with a pre-placed purple block (edit a level JSON temporarily) and confirm it renders with the inner ring. Revert the JSON after verifying.
Task 6 — Merge animation in GameScene
File: Scene/GameScene.swift
Find the method that processes GameEvent items for animation (likely a playEvents or animateEvents function). Add a case for .merged:
case .merged(_, _, let resultColor, let position):
// 1. Both source blocks have already slid/are at position — their nodes need to burst out
// 2. Scale both up 1.2× (0.1s), then scale + fade to 0 (0.1s), remove nodes
// 3. Create a new BlockNode for resultColor at `position`, scale 0 → 1.15 → 1.0 (0.15s spring)
// 4. Play merge.wav
let burstScale = SKAction.scale(to: 1.2, duration: 0.1)
let burstFade = SKAction.group([
SKAction.scale(to: 0.0, duration: 0.1),
SKAction.fadeOut(withDuration: 0.1)
])
let burstRemove = SKAction.removeFromParent()
let burst = SKAction.sequence([burstScale, burstFade, burstRemove])
// Run burst on both source nodes (look them up by their current grid positions)
// Then after burst completes, spawn merged node with spring pop:
let spawnDelay = SKAction.wait(forDuration: 0.2)
let spawnSpring = SKAction.sequence([
SKAction.scale(to: 0.0, duration: 0),
SKAction.scale(to: 1.15, duration: 0.12),
SKAction.scale(to: 1.0, duration: 0.05)
])
// Create new BlockNode, add to scene at correct sprite position, run spawnSpring
Sequence these so the burst finishes before the new block appears. Use SKAction.sequence with a wait equal to the burst duration (0.2s).
Total animation time: ~0.35s. Do not block player input during this time — use the existing input-lock pattern from the slide animation.
Task 7 — Add merge.wav
File: Resources/SFX/merge.wav and Audio/SFXManager.swift
-
Add a
merge.wavplaceholder toResources/SFX/. If you don't have an audio file yet, create an empty file as a placeholder — the app will not crash on a missing SKAction sound, it will just play nothing. Add a// TODO: replace with real audiocomment. -
In
SFXManager.swift, add:
func playMerge() {
playSound("merge")
}
- Call
SFXManager.shared.playMerge()from the.mergedanimation case in Task 6.
Task 8 — World 2 level files
Directory: Levels/world2/
Create this directory and add level_01.json through level_12.json. All 12 files are provided in Levels/world2/ alongside this task file. Copy them into the Xcode project the same way World 1 levels were added — as a folder reference (blue folder), not individual file entries.
Xcode setup reminder:
- Right-click
Levels/group → Add Files → select theworld2/folder → choose "Create folder references" (blue folder) - Confirm
world2/appears as a blue folder in the Levels group - Do NOT add individual JSON files — only the folder
Task 9 — Update metadata.json
File: Levels/metadata.json
Add the World 2 entry to the worlds array:
{
"id": 2,
"name": "Fusion",
"description": "Discover what happens when two colors collide.",
"new_mechanic": "merging",
"unlock_stars": 15,
"levels": [
{ "id": "w2_01", "title": "First Contact", "file": "world2/level_01.json", "is_challenge": false },
{ "id": "w2_02", "title": "Bound Together", "file": "world2/level_02.json", "is_challenge": false },
{ "id": "w2_03", "title": "The Third Color", "file": "world2/level_03.json", "is_challenge": false },
{ "id": "w2_04", "title": "Sun Stone", "file": "world2/level_04.json", "is_challenge": false },
{ "id": "w2_05", "title": "Color Theory", "file": "world2/level_05.json", "is_challenge": false },
{ "id": "w2_06", "title": "Triad", "file": "world2/level_06.json", "is_challenge": false },
{ "id": "w2_07", "title": "Inheritance", "file": "world2/level_07.json", "is_challenge": false },
{ "id": "w2_08", "title": "Reflection", "file": "world2/level_08.json", "is_challenge": false },
{ "id": "w2_09", "title": "Cascade", "file": "world2/level_09.json", "is_challenge": false },
{ "id": "w2_10", "title": "Chain Reaction", "file": "world2/level_10.json", "is_challenge": false },
{ "id": "w2_11", "title": "The Long Way", "file": "world2/level_11.json", "is_challenge": false },
{ "id": "w2_12", "title": "Prism", "file": "world2/level_12.json", "is_challenge": true, "stars_required": 15 }
]
}
Also update the LevelLoader / wherever metadata.json is parsed to handle the new unlock_stars and new_mechanic fields. These are optional — existing World 1 entry has neither and must still load correctly.
Task 10 — World selector UI
File: Views/LevelSelectView.swift
Add a world selector at the top of the level select screen.
@State private var selectedWorld: Int = 1
// World 2 unlock condition
private var world2Unlocked: Bool {
progressStore.totalStars() >= 15
}
Render two pill-shaped buttons: "World 1" and "World 2". When World 2 is locked:
- Pill is grayed out (opacity 0.4)
- Show a lock symbol (SF Symbol:
lock.fill) and the text "15 ★" - Tapping the locked pill does nothing (or shows a brief shake animation)
When World 2 is unlocked, tapping it switches selectedWorld to 2 and the level grid below shows World 2 levels.
Keep the implementation simple — the world metadata is already loaded, just filter levels by selectedWorld.
Task 11 — Level loading tests (World 2)
File: CollapseLogicTests/LevelLoaderTests.swift (add to existing)
func testAllWorld2LevelsLoad() {
for i in 1...12 {
let id = String(format: "w2_%02d", i)
let level = LevelLoader.load(id: id)
XCTAssertNotNil(level, "Failed to load \(id)")
}
}
func testPreplacedSecondaryColorsLoad() {
// Load w2_07 (first level with pre-placed secondary blocks)
// Verify the purple block in that level loads with color == .purple
}
func testWorld2MetadataLoads() {
let metadata = LevelLoader.loadMetadata()
XCTAssertEqual(metadata.worlds.count, 2)
let world2 = metadata.worlds.first(where: { $0.id == 2 })
XCTAssertNotNil(world2)
XCTAssertEqual(world2?.levels.count, 12)
}
All must pass before marking World 2 complete.
Task 12 — Full playthrough and regression check
Manual steps before closing World 2:
- Run all unit tests. Zero failures.
- Build and launch on iPhone 15 Pro simulator.
- Play World 1 levels 1, 5, and 10 — verify no regressions.
- Earn 15 stars. Verify World 2 unlocks in the level select.
- Play all 12 World 2 levels from start to finish.
- For each World 2 level: verify the level is solvable at par.
- Trigger at least one of each merge type (red+blue, red+yellow, blue+yellow) and confirm the animation plays correctly.
- Undo a merge — verify the board restores correctly.
- Build and run on iPhone SE (3rd gen) simulator — verify layout is not broken.
- Confirm no crashes on any level.
Definition of Done — World 2 is complete when all 12 tasks are done and all manual steps above pass.