iOS Programming – Portfolio 2

by Markus Jakobsen (mamja18)

Outline

1. Introduction
2. Method & Materials
3. Results
4. Discussion

1 – Introduction

This report describes the efforts made towards creating an iOS application for the course iOS Programming at University of Southern Denmark. The aim was to create an application using Swift and XCode which utilised at least one sensor. The game Flying at an Angle was developed towards this end. It is a game which utilises the gyroscope to control the player towards collecting coins which increase the movement speed for a time. The game should also include the use of the CoreData framework, contain multiple Screens/Views, have a structural View such as a TableView, and contain a collection of relevant buttons and labels. In addition to this, optional elements such as graphics and audio should also be included where relevant.

For the code examples presented later in this report, it is worth noting that for some reason certain characters in some code sections are being replaced by character strings. Most notably less than ‘<‘ and greater than ‘>’, but also occasionally left square bracket ‘[‘, . Please refer to the original source code where needed.

2 – Methods

This section covers the methods and techniques used throughout this project.

2.1 – Brainstorming

The first step made in the project was to brainstorm what types of games would be feasible for the scale of this project, and what types of sensory data could be included in a meaningful way. This was done by first bringing all different ideas to the board and then filtering them away one by one until a reasonable concept was left.

2.2 – Prototypes

Two prototypes were made to explore the feasibility of the ideas. First, a purely visual representation of the game was created using Adobe XD to demonstrate and test the flow of the application, and to serve as a visual impression of the future functionality. The second prototype was made as a barebones application to explore the controls and feeling of the game.

2.3 – Use Case Diagrams

Use case diagrams were made to describe the core uses of the application, with focus on the different multiplayer and community interactions.

2.4 – Requirements

Both functional and non-functional requirements were gathered for the application to serve as a foundation for the architecture and implementation.

2.5 – Asset Creation

The visual and auditory assets used in the application were made specifically for this project. The visual assets were created using Draw.io, which is a free design and diagramming tool. The sound effects were generated using the free and opensource BFXR tool, which enables easy modification and generation of simple sound effects.

2.6 – Architecture

For the design of the software itself, several diagrams were created. First, an MVC diagram was made to indicate the MVC structure of the application. Second, a class diagram was created to represent the key gameplay classes.

2.7 – Implementation

2.7.1 – Frameworks

For the implementation of the application, the following frameworks were utilised to achieve the desired results.

  • SpriteKit
    • SpriteKit provides the functionality to conveniently create animated graphics on the screen and is well suited to make 2D games. Of particular significance to this project is the ability to draw images (sprites) to the screen, and the provided update loop, which functions well as a core game loop.
  • UIKit
    • UIKit provides the overall functionality of the interactivity and visibility of the application.
  • CoreMotion
    • CoreMotion provides access to the device’s gyroscope, which is how the player interacts with the game world.
  • CoreData
    • CoreData provides data persistence within the application between launches.
  • AVFoundation
    • AVFoundation provides functionality for playback of audio and sound effects within the application.
  • GameplayKit
    • GameplayKit provides access to Apples GameService, which in turn is able to provide highscore services to games.

2.7.2 – Server

A server was created to store and handle the functionality related to highscores within the application.

2.8 – Evaluation

The prototypes were tested against potential users and consequently refined to provide a better gameplay experience.

3 – Results

3.1 – Brainstorming

The brainstorming resulted in an idea of a game controlled by the gyroscope sensor. The type of game was decided to be similar to games like Mega Jump and Doodle Jump (see images below), where a player attempts to get as high as possible without colliding with obstacles or falling down. The idea of some sort of multiplayer functionality was also introduced at this point.

Mega Jump
Doodle Jump

3.2 – Prototypes

Starting off from the result of the brainstorm, the first prototype phase resulted in the creation of two very similar digital paper prototypes. One with and one without the possibility for multiplayer. Prototype A consisted of a main screen with a play button and a highscores button. The play button transitions directly to the game, and the highscores button transitions to a global and local highscores table. Prototype B contains the same functionality, but adds a multiplayer feature to it. Due to the nature of the gameplay, the multiplayer feature was envisioned as a concurrent game session with another player, where each player would see the other as a live score at the top of the screen.

Prototype A

Demo Link

Prototype B

Demo Link

While these prototypes were not explicitly playable, the gameplay mechanics were defined as a game where you tilt the phone from side to side to move the player across the screen to collect coins. The player starts off with an initial velocity, which is counteracted by gravity. In order to reach new heights, the player must repeatedly collect coins which provide a boost to the velocity. If the player fails to maintain this velocity, they lose. To make the game progressively harder, each collected coin makes the next one spawn a little farther away, creating a theoretical maximum possible score.

The next prototype phase was a crude implementation of the game, with a gyroscope-controlled player able to move around collecting coins. Here, user feedback became more easily tested, and based on almost unanimous agreement, the player was decided to be restricted to the screen space, instead of the original idea of looping around the edges of the screen. This prototype eventually morphed into the final game.

3.3 – Use Case Diagrams

Based on the brainstorming, the first prototype, and discussions with users, the following use case diagram was designed.

Use Case Diagram of core player interaction

3.4 – Requirements

3.4.1 – Functional

  • F1: The player should be able to control the game using the gyroscope sensor
  • F2: The player should be able to collect coins to increase speed
  • F3: The player should be able to restart the game immediately when they lose
  • F4: The player should be able to record new highscores
  • F5: The player should be able to view global highscores
  • F6: The player should be able to play against an opponent over the internet

3.4.2 – Non-Functional

  • N1: Highscores should be persisted locally and on a server
  • N2: The game should become gradually more difficult over time
  • N3: The gameplay should be enhanced by playing sound effects
  • N4: There should be obstacles for the player to avoid
  • N5: There should be powerups to help the player reach new heights

3.5 – Assets

Using the sound effect program BFXR, a single sound effect for the collection of coins were created.

Draw.io was used to create illustrations of the player and logo. The coins and particles were not designed specifically and are only coloured rectangles generated programmatically.

Game Logo
Player Character

3.6 – Architecture

The resulting game architecture was refined several times. The first few iterations attempted to separate the game logic from its presentation in the spirit of true MVC. This proved challenging and caused the code to be not easily maintainable or extensible as the behavioural logic is so tightly connected with how something looks. Particularly, the size of the player and coins were required to deal with collisions and the size of the GameScene determined when the player would collide with a wall or loop around it. Therefore, some core game data was separated out, such as score, highscore, and gravity. The resulting MVC architecture is described in the following diagrams. First, the GameViewController displays the SKView, which in turn presents the GameScene. There is not a completely clear MVC relationship between SKView, GameScene and GameModel, as there is a bit of state and presentation in GameScene, as well as some controlling aspects of the game. In both diagrams, the pruple box represents the parent class of the controller provided by the UIKit framework. For the highscores this is much clearer. Here, the controller obtains the highscores from the server and stores this data, which is then provided to the Tableview for displaying.

MVC diagram of the Game view
MVC diagram of the Highscores view

The resulting code architecture for the parts of the application surrounding the gameplay is presented in the following high-level class diagram. The majority of properties and functions have been omitted for clarity. The purple classes represent classes provided by the SpriteKit framework. Here, the GameScene object is at the centre of the diagram and controls the objects within itself.

Class Diagram of the GameScene and associated classes

3.7 – Implementation

3.7.1 – Frameworks

UIKit
UIKit is used as the backbone of the game. It utilises a variety of views and controllers to present the main views, such as the main menu, highscores and the game view. The main menu and highscores are nested within a UINavigationView, enabling convenient navigation between the two. The game view is separate to this to not have a back-button at the top left of the screen, but instead provides a back button when a game is over.

SpriteKit
SpriteKit is used to create the entire game. Each visible component within the game is related to this framework. As seen in the class diagram in section 3.6, each external inherited class is prefixed with SK to indicate this. An SKNode is any object able to be added as a child to an SKScene object. To enable some simple physics behaviour, an intermediary class, called GameNode, inheriting from SKNode was created to hold functionality related to movements, such as acceleration, velocity and gravity. See code snippet below. The Player and GameParticle classes inherits from this class. Both classes also have an SKSpriteNode member which is their visual presentation in the scene. The CoinSprite inherits directly from the SKSpriteNode as it does not require any movement functionality.

An update function was also added to these classes to provide frame-by-frame updates of their behaviour, which was called from the GameScene (see GameLoop – section 3.7.5).

// Abbreviated code
class GameNode : SKNode {
    var velocity = CGPoint(x:0, y: 0)
    var acceleration = CGPoint(x: 0, y: 3) // Old: 13
    var maxVelocity = CGPoint(x: 9999, y: 9999)
    var minVelocity = CGPoint(x: -9999, y: -9999)
    var useGravity = true
    var limitAccelerationToVelocityCap = true
    
    func update(_ deltaTime : Float) {
        // Updating position
    }
    
    func reset(){
        // Restting to default
    }
}
// Abbreviated code
class GameParticle : GameNode {
    private let sprite : SKSpriteNode
    var size : CGSize {
        didSet {
            sprite.size = self.size
        }
    }
    
    init(_ game: GameScene, size: CGSize = CGSize(width: 16, height: 16)) {
        sprite = SKSpriteNode(texture: nil, 
            color: #colorLiteral(red: 0.9372549057, 
                green: 0.3490196168, 
                blue: 0.1921568662, 
                alpha: 1), size: size)
        self.size = size
        super.init(game)
        self.addChild(sprite)
    }
    
    override func update(_ deltaTime: Float) { }
}

CoreMotion
CoreMotion was utilised within the GameScene and Player classes to access the data from the gyroscope. The sensor is first activated on load of GameScene. The code used to enable it was based on an example from the apple developer pages (source). This data was added directly to the velocity of the player using a linear interpolation function, creating smooth changes to the movements.

// Abbreviated code from GameScene.swift

var motion = CMMotionManager()

override func didMove(to view: SKView) {
    startDeviceMotion()
}

private func startDeviceMotion() {
    if motion.isDeviceMotionAvailable {
        motion.deviceMotionUpdateInterval = 1.0 / 60.0
        motion.showsDeviceMovementDisplay = true
        motion.startDeviceMotionUpdates(using: .xArbitraryZVertical)
    }
}
// Executed from Player.init()

private func setAccelerometerTimer() {
    timer = Timer(fire: Date(), interval: (1.0 / 60), repeats: true, block:
    {
        (timer) in if let data = self.game.motion.deviceMotion {
            self.acceleration.x = self.game.lerp(self.acceleration.x,
                CGFloat(data.gravity.x) * 60, 0.5)
        }
    })
    RunLoop.current.add(self.timer!, forMode: RunLoop.Mode.default)
}

CoreData
CoreData was utilised to store the player id from the server and the current highscore of the player. Originally this data persistence was implemented using the UserDefaults key-value storage, but as CoreMotion was listed as a required framework, it was later changed. CoreMotion is arguably not the right tool for the job it is being used for, as there is only a single record with two data fields. While the setup was more involved than the UserDefaults, the resulting functionality is indistinguishable for this project.

To enable the functionality of CoreData, AppDelegate.swift had to get some additional functions. It appears to be included by default if another app template had been selected in xcode at the beginning. The solution was provided by (source) and enables the official examples from the swift documentation to work as promised.

lazy var persistentContainer: NSPersistentContainer = {
    let container = NSPersistentContainer(name: "data")
    container.loadPersistentStores(completionHandler: {(storeDescription, error) in
        if let error = error as NSError? {
            print("Error! \(error.localizedDescription)")
        }
    })
    return container
}()

func save() {
    let context = persistentContainer.viewContext
    if context.hasChanges {
        do {
            try context.save()
        } catch {
            print("Failed to save context. \(error)")
        }
    }
}

The original implementation used the UserDefaults as follows:

// Get a value
UserDefaults.standard.set(score, forKey: "highscore")
// Store a value
var highscore = UserDefaults.standard.integer(forKey: "highscore")

AVFoundation
AVFoundation was used to play a single sound effect when the player collects a coin. The file is first loaded on launch of the GameScene, and then the player calls a playCoinSound function whenever it collides with a coin. Getting this to work took some tinkering, but with the assistance of a Stackoverflow post (source), a solution was found.

// In GameScene.init()
...
guard let url = Bundle.main.url(forResource: "coin-pickup", 
            withExtension: "wav") else { print("Failed"); return }
do {
    try AVAudioSession.sharedInstance()
        .setCategory(.playback, mode: .default)
    try AVAudioSession.sharedInstance()
        .setActive(true)
    
    coinSound = try AVAudioPlayer(contentsOf: url, 
            fileTypeHint: AVFileType.wav.rawValue)
} catch let error {
    print(error.localizedDescription)
}
...
// Then at request - Normally from Player.swift
func playCoinSound() {
    guard let coinSound = coinSound else { return }
    coinSound.play()
}

GameplayKit
GameplayKit was originally intended to provide the highscores functionality through the provided service by Apple. However, due to the requirement of a full (paid) Apple Developer account, and challenges getting the SDU developer account working, it was not implemented. Instead a custom node server was used to host the core highscores functionality.

3.7.2 – Server

To host the highscores functionality, a node.js server was set up using express.js. express.js enables quick setup of a simple HTTP server, which the game connected to using HTTP requests. The data hosted was a list of objects containing a unique player identifier, a player name, and their highest score. The server is able to provide a /getIdentifier request which returns a universally unique identifier (UUID) (source), and /getHighscores which returns a list of sorted highscores, along with their readable usernames. It can also receive a /sendHighscore which includes the user’s UUID and the new score. The names assigned to players is at this point random and assigned sequentially by the server.

To send requests to the server, first a URL object has to be constructed, and then a task is created with this object. The task produces a data object in return which contains the information from the server. In the case of this application, it is used to return a list of highscores and UUIDs for new players. Below is the abbreviated code for obtaining the UUID for the first time.

// From PrimaryController.swift
private func getIdentifier() {
    let url = URL(http://142.93.44.236:3000/getIdentifier")
    let task = URLSession.shared.dataTask(with: url) {
        (data, response, error) in
            guard let data = data else { return }
            self.id = String(data: data, encoding: .utf8)
    }
    task.resume()
}

Below are sequence diagrams of the different requests sent to and from the server.

Sequence diagram of getIdentifier request
Sequence diagram of getHighscores request
Sequence diagram of sendHighscores request

3.7.3 – Pooling

In order to optimise the performance of the game, a pooling system was implemented for both the simple particle system, and the coin spawning. This means that instead of instantiating a new object every time a new coin or particle was needed, it would instead only instantiate the necessary number of objects to show them all on the screen at once, and then instead of destroying them when they are not needed, they are instead deactivated. This means that when a new object is needed later, it can simply be reactivated, moved and configured as needed, saving the device from having to allocate memory for its instantiation. The activation and deactivation of an object in these pools were achieved by using the isHidden property of the SKNode class. Any object with this property set to true will not be rendered, and by checking this value, any behaviour updates could also be stopped. Below is an abbreviated example of how the pooling works when spawning coins.

// Abbreviated code
class SpawnsController {    
    var coins = [CoinSprite]()
    
    func update(_ deltaTime : Float) {
        if scene.player!.position.y > lastSpawnedHeight + spawnDistance {
            lastSpawnedHeight += spawnDistance
            spawnDistance += 10
            spawnCoin()
        }
        for coin in coins {
            if coin.isHidden { continue }
            
            if(coin.position.y < CGFloat(scene.player!.lowest)) {
                coin.isHidden = true
            } else {
                coin.update(deltaTime)
            }
        }
    }
    func reset() { }
    
    func spawnCoin() {
        let spawnPosition = // random position 
        var newCoin : CoinSprite?
        for coin in coins {
            if coin.isHidden {
                newCoin = coin
                break
            }
        }
        if newCoin == nil {
            newCoin = CoinSprite()
            scene.addChild(newCoin!)
            coins.append(newCoin!)
        }
        newCoin?.position = spawnPosition
        newCoin?.isHidden = false
    }
}

3.7.4 – Particles

As the game was developed on a Mac OS running on a virtual machine, there were some technical difficulties within xcode. Most notably, the inability to work with .sks files. The entire program would immediately crash upon even selecting an .sks file form the project hierarchy. As the particle systems provided by SpriteKit are implemented using .sks files, an alternative system had to be implemented. SimpleParticles.swift was implemented to this end and utilises the pooling system above to spawn a series of GameParticle objects at random rotations and sizes below the player to provide a better impression of speed.

// Abbreviated code
class SimpleParticles {
    private var particles = [GameParticle]()
    
    func update(_ deltaTime : Float) {
        if(timeSinceSpawn >= particleFrequency) {
            timeSinceSpawn = 0
            spawnParticle()
        }
        
        for particle in particles {
            if particle.isHidden { continue }
            if particle.position.y  GameParticle { }
    private func spawnParticle() { }
    private func createParticle() -> GameParticle { }
}
// Abbreviated code
class SimpleParticles {
    private var particles = [GameParticle]()
    
    func update(_ deltaTime : Float) {
        if(timeSinceSpawn >= particleFrequency) {
            timeSinceSpawn = 0
            spawnParticle()
        }
        
        for particle in particles {
            if particle.isHidden { continue }
            if particle.position.y < game.player!.lowest - 
                        particle.size.height {
                particle.isHidden = true
                continue
            }
            particle.update(deltaTime)
        }
        timeSinceSpawn += deltaTime
    }
    
    func reset() { }
    
    private func getAvailableParticle() -> GameParticle { }
    private func spawnParticle() { }
    private func createParticle() -> GameParticle { }
}

3.7.5 – Game Loop

At the core of the game is the game loop. The GameScene has one overridden function called update(_ currentTime: TimeInterval) which is driven by the SpriteKit framework. It is where the player and all other objects in the game are updated. This function runs approximately 60 times per second, and makes the game look and feel smooth. As the provided TimeInterval is time since the start of the application, a function for calculating deltaTime (the time between a frame and the previous frame) was produced. This deltaTime is passed down to the game objects. The idea of using a delta time value was inspired by more complex game engines such as Unity and Unreal Engine which has a similar system for updating game objects.

// From GameScene.swift
private func getDeltaTime(_ currentTime: TimeInterval) -> Float {
    return Float(currentTime) - lastFrameTime
}

override func update(_ currentTime: TimeInterval) {
    let deltaTime = getDeltaTime(currentTime)

    switch state {
    case .running:
        spawns?.update(deltaTime)
        player?.update(deltaTime)
    default:
        break
    }
    updateCameraPosition()
    
    lastFrameTime = Float(currentTime)
}

private func updateCameraPosition() {
    cameraTarget.y = lerp(cameraTarget.y, player!.position.y, CGFloat(0.12))
    if cameraTarget.y > player!.lowest {
        camera!.position.y = cameraTarget.y + 100
    }
}

// From Player.swift
override func update(_ deltaTime : Float) {
    super.update(deltaTime)
    velocity.x = acceleration.x
    
    limitToView()
    checkCollisions()
    
    trail.update(deltaTime)
    
    if highest < position.y { highest = position.y }
    if position.y < lowest - sprite.size.height - 200 {
        game.gameOver()
    }
}

3.7.6 – Highscores View

The highscores view is built using a UITableViewController. This view controller is built around a UITableView and simplifies its implementation. In order to make it work with the highscores data, a few steps had to be made. First, a custom UITableViewCell, called HighscoreTableViewCell, had to be constructed. It contained two UILabels laid next to one another horizontally. This layout was done programmatically and was inspired by the example from link, as the syntax for adding constraints this way is not particularly comprehensible. This TableViewCell class was then registered as the cell type for the TableView.

// from HighscoreTableViewCell.swift
addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|-32-[v0]-8-[v1(80)]-8-|", options: NSLayoutConstraint.FormatOptions(), metrics: nil, views: ["v0": nameLabel, "v1":scoreLabel]))
addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[v0]|", options: NSLayoutConstraint.FormatOptions(), metrics: nil, views: ["v0": nameLabel]))
addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[v0]|", options: NSLayoutConstraint.FormatOptions(), metrics: nil, views: ["v0": scoreLabel]))

Secondly, in order to get the highscore data into the table view, two functions had to be overwritten. The first determines the number of rows in a section, and the second configures a what data should be displayed in a cell of a given index.

// Abbreviated code from HighscoresController.swift
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return highscores.count
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let highscore = highscores[indexPath.item]
    let cell = tableView.dequeueReusableCell(withIdentifier: "hs", for: indexPath) as! HighscoreTableViewCell
    cell.nameLabel.text = highscore.name
    cell.scoreLabel.text = String(highscore.score)
    return cell
}

private func getHighscoresFromServer() {
    let task = URLSession.shared.dataTask(with: url) {(data, response, error) in
        let json = // deserialised json from data
        DispatchQueue.main.async {
            self.tableView.beginUpdates()
            if let dictionary = json as? [[String : Any]] {
                    for item in dictionary  {
                        self.highscores.append(Highscore(name: item["name"] as! String, score: item["score"] as! Int, id: item["id"] as! String))
                        self.tableView.insertRows(at: [IndexPath.init(row:self.highscores.count - 1, section: 0)], with: .automatic)
                }
            }        
            self.tableView.endUpdates()
        }
    }
}

Finally, the highscore data had to be obtained from the server. This was done using the HTTP request as specified in the server section, and each highscore was stored in an array of Highscore structs. As the server request is asynchronous, the table view is fully loaded by the time a reply is received, and this reply is sent on a separate thread from the main thread. UI elements have to be updated from this main thread, and therefore, once the reply was received from the server, the table update had to be queued to the main thread. An abbreviated example of this can be seen in the code above.

3.7.7 – Code Repository

All code referred to in this report and used for this project can be found on both github and bitbucket.

GitHubhttps://github.com/Zenthurion/FlyingAtAnAngle
Bitbuckethttps://bitbucket.org/iosprogrammingsdu/flying-at-an-angle/src/master/

3.8 – Demonstration

Below is a brief video demonstration of the game. It shows the highscore before the player’s first game, then as they get a low score, and finally a new highscore.

3.9 – Evaluations

Beyond the user feedback from the prototype phases, multiple users were involved with the testing of the more completed game. The following is a list of general feedback received towards the end of development.

  • The player moves too fast
  • It is difficult to feel the speed of the player
  • It is too hard to hit coins as they appear to suddenly and randomly
  • There should be obstacles and powerups to make the gameplay more interesting
  • It looks a little dull

This feedback resulted in some changes, such as a reduction of the general speed of the game. With this reduction, the distance between the coins was increased along with the speed bonus gained by hitting a coin. The size of the player and coin was also reduced to make it easier to see ahead.

4 – Discussion

The development of this game was mostly successful. Looking beyond the challenges which arose from using xcode through a virtual machine running Mac OS, and the difficulties with connecting to the SDU Apple Developer account, the majority of the project’s objectives were fulfilled.

  • Required elements:
    • CoreData is implemented to provide data persistence.
    • There are three primary views: Main Menu, Highscores, Game
    • A TableView is implemented to display the global highscores
    • Buttons and labels are part of both he Main Menu and Game views.
  • Optional elements:
    • SpriteKit is utilised to create the gameplay
    • AVFoundation is utilised to play sound effects
    • Data is also retrieved form an external server

Of the specified requirements, most were successfully implemented.

Requirement Implemented
F1 Yes
F2 Yes
F3 Yes
F4 Yes
F5 Yes
F6 No
N1 Yes
N2 Yes
N3 Yes
N4 No
N5 No

The user feedback from prototypes and the running game was instrumental in improving the experience and making a more user-friendly game. It proves it can be difficult to maintain a neutral perspective of one’s own work. Some of the feedback reflects the unimplemented requirements as specified above, such as obstacles and powerups.

4.1 – What did and didn’t work?

The game as a whole works well, although, implementing the missing requirements would make it an overall more interesting experience with far more replayability.

The game’s biggest issue gameplay-wise would be the difficulty in predicting where coins spawn, as you currently need super-human reflexes to move to where they spawn at the moment, and progress mainly by being lucky enough to be in the path of a spawned coin. Creating spawn patterns, instead of random positioning, or zoom the view of the player farther out so you can see farther ahead would likely improve the experience greatly.

4.2 – Future Work

Implementing the powerups and obstacles of F1 and F2 would be the first priority of further work. This along with improvements to the coin spawning and visibility would be of great value to the game. After this, reviewing the multiplayer feature could be considered to further increase replayability for players and make it a more social game. Finally, implementing the highscores using GameplayKit instead of a crude server would make highscore-hackers less of a likely issue, and could provide more fun elements such as achievements. If not, then at least improving the system with the option for players to enter their own name instead of the current auto-assignment. Monetisation should also be considered in the future.

4.3 – Conclusion

In conclusion, the project has been successful, with a working end product. While there are improvements to be made and features to be added, this will always be the case when developing applications. The application as it stands is well implemented and follows the best practices of Swift development as best as possible.

5 – References

Leave a Reply