top of page
Matt

How to create level selection screens for a mobile game in iOS (with automated tests!) - Part 1



Updated: Feb 19

Introduction

When developing games, it's logical to introduce different areas or worlds with various themes that enhance the game's narrative and introduce interesting mechanics. For instance, in a sequel to our game Falling Sky (check it out [https://apps.apple.com/app/id6446787964] if you haven't already!), we could introduce new planets for exploration, each with unique challenges. Once a planet is rescued, Orion, the protagonist, can travel to the next planet. This article demonstrates how to achieve this in Swift for iOS using UIKit. We'll also delve into implementing automated tests. Part 2 will focus on level selection views after a world has been chosen.


World Selector Carousel

There are various UX designs to choose from, but a common approach involves a scrolling view—either rendering worlds as a vertical stack of buttons or a horizontal scroll. We'll showcase the horizontal approach with left and right navigational buttons, and a central button for the world itself. This carousel design is a familiar concept that users will find easy to understand and use.


Locked Worlds

As players progress through the game and complete levels, they unlock new worlds. We'll explore different approaches, such as a star rating system for each level, where worlds unlock upon achieving a set number of stars. Alternatively, in-game currency can be used, and in-app purchases can expedite world unlocking—an effective monetisation strategy for apps.


Automated Tests

Following a test-driven approach, let's define the tests. For a detailed guide on setting up tests for a Swift iOS project, check out [https://www.kingdomarcade.co.uk/post/how-to-write-effective-ui-tests-for-ios-apps]. These tests also serve as a valuable tool for understanding features and required behaviour.


These are the tests we will write for our world selection feature:

  • testICanNavigateThroughToAllWorlds - This is the happy path; once implemented, the player can navigate to all worlds

  • testFirstWorldIsSelectableByDefault - This test ensures the first world is selectable by default

  • testICanSelectWorld2IfAllLevelsInWorld1Completed - This test checks that the next world is playable once unlocked

  • testWorld2IsLockedIfOnlySomeLevelsInWorld1Completed - This test checks that the next world is locked if the first world is yet to be completed

Once these tests are implemented, we'll have a working world selection screen with the feature of unlocking worlds. If your logic is based on in-app currency or in-app purchases, updating these tests will reflect that behaviour.


Now, let's get to the code!

On the HomeViewController of our app, we're going to add a component called "Worlds." This will contain our carousel for selecting the world to play in. It needs to be passed functions indicating whether the world is locked or unlocked and should handle when a world has been selected.


Worlds.swift:

import UIKit


class Worlds: UIView {
    // this scrollview controls and hosts the pagination of the worlds
    var scrollView: UIScrollView! = nil
    // this is the callback we fire when a world is selected enabling the HomeViewController to load the level selection screen for the world in question - in part 2 this will get refactored to "onLevelSelected"
    var onWorldSelected: ((Area) -> ())! = nil
    
    convenience init(appF: AppFunctions, onWorldSelected: @escaping (World) -> ()) {
        self.init()
        self.onWorldSelected = onWorldSelected
        
        scrollView = UIScrollView()
        self.addSubview(scrollView)
        
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        
        //the track view contains the world buttons which we paginate through
        let stackView = UIStackView()
        stackView.axis = .horizontal
        stackView.spacing = 0
        stackView.translatesAutoresizingMaskIntoConstraints = false
        scrollView.addSubview(stackView)
    
        let screenBounds = UIScreen.main.bounds
        let buttonWidth = screenBounds.width * 0.6
        
        //here we get the detail on whether a world is locked or unlocked, the gets passed down so can render the "locked" button view, perhaps with a padlock
        let levelResults = try! appF.getLevelResults()
        let worlds = World.allCases.map {world in
        //world button is the button to select the world. this could be a more interesting animated view
            WorldButton(width: buttonWidth, world: World, isUnlocked: levelResults.isWorldUnlocked(world), onTouch: onWorldButtonTouch)
        }
        
        for item in worlds {
        // we put each world button centered into a view.
            let itemView = UIView(frame: scrollView.frame)
            itemView.addSubview(item)
            item.translatesAutoresizingMaskIntoConstraints = false
            item.centerXAnchor.constraint(equalTo: itemView.centerXAnchor).isActive = true
            item.centerYAnchor.constraint(equalTo: itemView.centerYAnchor).isActive = true
            stackView.addArrangedSubview(itemView)
            
            itemView.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true
            itemView.heightAnchor.constraint(equalTo: scrollView.heightAnchor).isActive = true
        }


        scrollView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        scrollView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
        scrollView.topAnchor.constraint(equalTo: topAnchor).isActive = true
        scrollView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
        
        stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor).isActive = true
        stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor).isActive = true
        stackView.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true
        stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true
        stackView.heightAnchor.constraint(equalTo: scrollView.heightAnchor).isActive = true
        
        scrollView.contentSize = CGSize(width: CGFloat(areas.count) * frame.size.width, height: frame.size.height)
        scrollView.isPagingEnabled = true
        
        //add a left button to scroll left
        let leftButton = UIButton()
        leftButton.setTitle("Left", for: .normal)
        leftButton.addTarget(self, action: #selector(scrollToLeft), for: .touchUpInside)
        leftButton.backgroundColor = .black
        leftButton.setTitleColor(.white, for: .normal)
        leftButton.frame = CGRect(x: 10, y: 10, width: 100, height: 40)
        self.addSubview(leftButton)

        // add a right button to scroll right
        let rightButton = UIButton()
        rightButton.setTitle("Right", for: .normal)
        rightButton.addTarget(self, action: #selector(scrollToRight), for: .touchUpInside)
        rightButton.backgroundColor = .black
        rightButton.setTitleColor(.white, for: .normal)
        rightButton.frame = CGRect(x: screenBounds.width - 100 - 10, y: 10, width: 100, height: 40)
        self.addSubview(rightButton)
        
        leftButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 16).isActive = true
        leftButton.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 16).isActive = true


        rightButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 16).isActive = true
        rightButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -16).isActive = true
    }
    
    //handle when world button is selected and trigger the callback
    func onWorldButtonTouch(world: World) {
        onWorldSelected(world)
    }
    
    @objc func scrollToLeft() {
        let currentOffset = scrollView.contentOffset.x
        let itemWidth = scrollView.frame.size.width
        let previousPage = max(currentOffset - itemWidth, 0)
        scrollView.setContentOffset(CGPoint(x: previousPage, y: 0), animated: true)
    }


    @objc func scrollToRight() {
        let contentWidth = scrollView.contentSize.width
        let currentOffset = scrollView.contentOffset.x
        let itemWidth = scrollView.frame.size.width
        let nextPage = min(currentOffset + itemWidth, contentWidth - scrollView.frame.size.width)
        scrollView.setContentOffset(CGPoint(x: nextPage, y: 0), animated: true)
    }
}

// the World enum, to add a new world is very simple, we can now just add a new entry this enum and it will get automatically added to the selection screens
enum World: Codable, CaseIterable  {
    case world1
    case world2
    case world3
}

// simple extension to get the label to display if need be. This could be refactored further to contain more information.
extension World {
    func getLabel() -> String {
        return switch self {
        case World.world1:
            "Lumina Prime"
        case World.world2:
            "Astra-Veridia"
        case World.world3:
            "Nebula Nexus"
        }
    }
}

then it gets used like so:


HomeViewController.swift:

import UIKit

class HomeViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.backgroundColor = .darkGray
      
        addWorlds()
    }
    
    func onWorldSelected(world: World) {
        let viewController = ...
        
        navigationController!.pushViewController(viewController, animated: true);
    }
    
    private func addWorlds() {
        let worlds = Worlds(appF: appF(), onWorldSelected: onWorldSelected)
        view.addSubview(worlds)
    }
}

This looks like:



With the happy path test, the test checks that the worlds are navigable. Here's what it looks like:

If, for whatever reason, we break the code and the worlds can't be reached, this test will fail due to the regression. Here's the full code for the test:

    func testICanNavigateThroughToAllWorlds() throws {
        app.launch(with: createTestAppF())
        
        assertWorldPresent(expectedWorld: World.world1)
        
        navRight()
        assertWorldPresent(expectedWorld: World.world2)
        
        navRight()
        assertWorldPresent(expectedWorld: World.world3)
        
        navRight()
        assertWorldPresent(expectedWorld: World.world3)
        
        navLeft()
        assertWorldPresent(expectedWorld: World.world2)
        
        navLeft()
        assertWorldPresent(expectedWorld: World.world1)
        
        navLeft()
        assertWorldPresent(expectedWorld: World.world1)
    }
    
    fileprivate func assertWorldPresent(expectedWorld: World) {
        let worldLabel = expectedWorld.getLabel()
        let actualWorld = app.buttons[worldLabel]
        XCTAssertTrue(actualWorld.isHittable)
    }

    fileprivate func navRight() {
        let rightButton = app.buttons["Right"]
        rightButton.tap()
    }
    
    fileprivate func navLeft() {
        let rightButton = app.buttons["Left"]
        rightButton.tap()
    }

In the next post, we'll explore what happens once the world is selected—we navigate into the level selection screen itself with a nice parallax effect when the user scrolls across.


How to support


This content will always remain free, and if you find it valuable, please consider sharing it with others. Additionally, downloading our games and leaving honest reviews greatly supports us. Feel free to reach out with any questions or feedback, and we'll do our best to respond.


Download Falling Sky from the Apple App Store today: https://apps.apple.com/app/id6446787964


11 views
bottom of page