Introduction
In Part 2, we were half way done with our SwiftUI implementation of the Horse Race game. Let’s finish it up in this last part. This is how the UI looked on the old BASIC computer.
1. Choosing Horse
We ended the previous part where we were showing the odds on horses. Next we show the prompt to the player to choose one of these horses.
extension FX790...
private func promptChooseHorse(for player: Player) -> Int {
while true {
let choice: Int = INPUT("P \(player.id)->")
if (0...4).contains(choice) {
return choice
}
}
}
The new state is –
enum GameState...
case chooseHorse(Player)
The displayText is –
class HorseRaceViewModel...
var displayText...
case .chooseHorse(let p):
"P \(p.id)→ \(horses.map(\.oddsLabel).joined(separator: " "))"
}
}
And moving to this state is –
class HorseRaceViewModel...
private func moveToNextState...
case .displayOdds(let player):
state = .chooseHorse(player)
This shows –
I did not make this an actual prompt with the blinking cursor, because we will just need the user to press a number from 0-5 and that will move to the next screen, without needing to press the RETURN key.
2. Capturing User Input
We need to capture the key press of 0-5 from the user. This is what we had from old game.
let choice = promptChooseHorse(for: player)
if choice == 0 { continue } // 0 means pass
player.choose(horse: horses[choice - 1])
First we need to capture user input without the RETURN key press. For this we will modify the handleUserInput method.
class HorseRaceViewModel...
private func handleUserInput(_ char: Character) {
switch state {
case .promptNumPlayers:
userInput.append(char)
case .chooseHorse:
userInput.append(char)
moveToNextState()
default: return
}
}
In the previous promptNumPlayers state we were waiting for user to press the RETURN before processing their input. In chooseHorse state we do moveToNextState after one key press.
class HorseRaceViewModel...
private func moveToNextState...
case .chooseHorse(let player):
if chooseHorse(player) {
state = ... // next state
}
}
}
private func chooseHorse(_ player: Player) -> Bool {
guard let choice = Int(readInput()) else { return false }
guard choice >= 0 && choice <= horses.count else { return false }
if choice == 0 { return true } // 0 means pass
player.choose(horse: horses[choice - 1])
return true
}
3. Placing Bet
After choosing a horse, the game immediately takes the user to place a bet.
extension FX790...
private func promptBetMoney(for player: Player) -> Int {
BEEP()
PRINT()
while true {
let bet: Int =
INPUT("PLAYER \(player.id) \(player.chosenHorse!.symbol) MONEY ")
if bet >= 0 && bet <= player.money {
return bet
}
}
}
Let’s create the new prompt state.
enum GameState...
case promptBetMoney(Player)
var isPrompt: Bool {
switch self {
case .promptNumPlayers: return true
case .promptBetMoney: return true
default : return false
}
}
}
The displayText and moving to this state becomes –
class HorseRaceViewModel...
var displayText...
case .promptBetMoney(let p):
"PLAYER \(p.id) \(p.chosenHorse!.symbol) MONEY? \(userInput)"
}
}
private func moveToNextState...
case .chooseHorse(let player):
if chooseHorse(player) {
state = .promptBetMoney(player)
}
This returns in below screen where it shows the chosen horse and user can input money.
To process the bet, we need to add a new betMoney method.
class HorseRaceViewModel...
private func betMoney(_ player: Player) -> Bool {
guard let betAmount = Int(readInput()) else { return false }
return player.placeBet(betAmount)
}
class Player...
func placeBet(_ bet: Int) -> Bool {
guard bet >= 0 && bet <= money else { return false }
self.bet = bet
self.money -= bet
return true
}
Now we can handle the moveToNextState.
class HorseRaceViewModel...
private func moveToNextState...
case .promptBetMoney(let player):
if betMoney(player) {
state = players.next(after: player)
.map { .displayPlayerHoldings($0) }
.orElse(...) // Go to race start
}
}
Notice that after a player has placed a bed, we need to move to the next player, and take them back to the displayPlayerHoldings. If no players remain, then we go to race start.
4. Display Race Start
All the bets have been placed. Lets start the race.
extension FX790...
private func displayRaceStart() {
PRINT()
PRINT(" < START! >")
for _ in 1...10 {
BEEP()
}
PRINT()
}
This is a simple display only screen.
enum GameState...
case displayRaceStart
class HorseRaceViewModel...
var displayText...
case .displayRaceStart: " < START! >"
private func moveToNextState...
case .promptBetMoney(let player):
if betMoney(player) {
state = players.next(after: player)
.map { .displayPlayerHoldings($0) }
.orElse(.displayRaceStart)
}
}
Just 3 lines of code change, and the display is done.
5. The Actual Race!
Finally we have come to the most exciting part of game, where the race will actually take place. I am hoping we will get to do some animation here. Here’s the old implementation -
extension FX790...
private func race...
repeat {
for horse in horses {
if horses.hasNoWinner {
clearHorsePosition(horse)
horse.move()
}
displayHorsePosition(horse)
}
} while horses.hasNoWinner
private func clearHorsePosition(_ horse: Horse) {
PRINT(csr: Int(horse.position), " ")
}
private func displayHorsePosition(_ horse: Horse) {
PRINT(csr: horse.position, "\(horse.symbol)")
}
This code is running a little race loop. It keeps repeating as long as no horse has reached the finish line. For each horse, it first checks again if the race still has no winner, and if so, it clears the horse’s old spot and moves it forward. It then shows the horse’s position on the track. Once a winner is found the loop ends.
Lets create a new state.
enum GameState {
case race(Horse)
We have a Horse as an associated value, since I want to move one horse at a time. So the state race(Horse) will be for that particular horse movement.
Next, we need to display the horses position in this view.
class HorseRaceViewModel...
var raceTrackString: String = ""
var displayText...
case .race: "\(raceTrackString)"
This creates a string raceTrackString, which will show each horses position. As the horses move, their positions will change, and we will redraw that string. The view is observing the HorseRaceViewModel, and every time this string raceTrackString updates, the view will be updated. That will give the illusion of them moving right in the string.
Now we implement the horse movement.
class HorseRaceViewModel...
private func moveToNextState...
case .displayRaceStart:
state = .race(horses[0])
case .race(let horse):
if horses.hasNoWinner {
moveHorse(horse)
state = .race(horses.next(after: horse))
} else {
state = ... // go to race end state
}
}
private func moveHorse(_ horse: Horse) {
guard horses.hasNoWinner else { return }
horse.move()
redrawRaceTrack()
}
private func redrawRaceTrack() {
var track =
[String](repeating: " ", count: GameConfig.goalPosition + 1)
horses.forEach { track[$0.position] = $0.symbol }
raceTrackString = track.joined()
}
On every RETURN key press, the moveHorse() method will be called for each horse in turn. And it will remain in the race state, until a winner is reached. I created a helper next(:Horse) method, that will loop around picking one horse at a time.
extension Array where Element == Horse...
func next(after horse: Horse) -> Horse {
let index = firstIndex(where: { $0.id == horse.id })!
let nextIndex = (index + 1) % count
return self[nextIndex]
}
}
This is how it looks –
6. Auto Horse Movement
We obviously don’t want to be pressing RETURN to move the horses. Lets implement a ticker that will move the horse automatically. I implemented that in the HorseRaceView.
struct HorseRaceView...
let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
var body: some View...
...
.onReceive(timer) { _ in
viewModel.handleTick()
}
.animation(.easeInOut(duration: 0.1),
value: viewModel.raceTrackString)
class HorseRaceViewModel...
func handleTick() {
switch state {
case .race: moveToNextState()
default: return
}
}
The handleTick() method is called every 0.1 seconds, and it moves the horse. I also added a little animation property to show smoother transition.
7. Display Goal
When the race ends, the UI displays “GOAL!”. This is the old code.
extension FX790...
private func displayGoal() {
PRINT(csr: 0, "GOAL!")
for _ in 1...7 {
BEEP()
BEEP(1)
}
TIMER()
}
Lets create a new state and move there when the race ends.
enum GameState...
case displayGoal
class HorseRaceViewModel...
var displayText...
case .displayGoal:
"\(raceTrackString.replaceFirst(with: "GOAL!"))"
private func moveToNextState...
case .race(let horse):
if horses.hasNoWinner {
moveHorse(horse)
state = .race(horses.next(after: horse))
} else {
state = .displayGoal
}
I created a helper String extension replaceFirst.
extension String {
func replaceFirst(with replacement: String) -> String {
let cutIndex = index(startIndex, offsetBy: replacement.count)
return replacement + self[cutIndex...]
}
}
And now it displays –
8. Winners Collect Prize
Next we distribute prizes to all the winner. This is from old code.
extension FX790...
private func race...
for player in players {
if player.madeAnyBet() {
let M = player.collectPrize(if: horses.winningHorse!)
displayPlayerPrize(player, prize: M)
}
displayPlayerHoldings(player)
}
This code intermingles the collecting prize and displaying total money. I will break it up into 2 states –
enum GameState...
case displayPrize(Player, Int)
case displayTotal(Player)
class HorseRaceViewModel...
var displayText...
case .displayPrize(let p, let prize):
"Player \(p.id) → PRIZE $\(prize)"
case .displayTotal(let p): "Player \(p.id) has $\(p.money)"
Next we loop though each player, collecting prizes, and displaying total.
class HorseRaceViewModel...
private func moveToNextState...
case .displayGoal:
if let player = players.first() {
state = player.madeAnyBet
? .displayPrize(player, collectPrize(player))
: .displayTotal(player)
}
case .displayPrize(let player, _):
state = .displayTotal(player)
case .displayTotal(let player):
if let nextPlayer = players.next(after: player) {
state = next.madeAnyBet
? .displayPrize(next, collectPrize(next))
: .displayTotal(next)
} else { // next }
private func collectPrize(_ player: Player) {
player.collectPrize(if: horses.winningHorse!)
}
This will display the following -
And the total is –
9. Replay
The next screen is prompt to ask player if they want to replay.
extension FX790...
private func promptReplay() -> Bool {
PRINT()
BEEP()
while true {
let ans: String = INPUT("REPLAY (Y/N)?")
if ans == "Y" { return true }
if ans == "N" { return false }
}
}
Lets create a prompt state.
enum GameState...
case chooseReplay
class HorseRaceViewModel...
var displayText...
case .chooseReplay: "Replay (Y/N)?"
This is like when choosing a horse, there is no blinking cursor, and a single key press moves to next state.
class HorseRaceViewModel...
private func handleUserInput...
case .chooseHorse, .chooseReplay:
userInput.append(char)
moveToNextState()
private func moveToNextState...
case .displayTotal(let player):
if let next = players.next(after: player) {
state = next.madeAnyBet
? .displayPrize(next, collectPrize(next))
: .displayTotal(next)
} else {
state = .chooseReplay
}
Now we implement the processing of the replay –
class HorseRaceViewModel...
private func moveToNextState...
case .promptReplay:
if let replay = replay() {
if replay {
state = .displayRaceNumber // New Race
}
}
private func replay() -> Bool? {
let response = readInput().uppercased()
if response == "Y" {
raceNumber += 1
return true
}
if response == "N" {
return false
}
return nil
}
This will show the replay prompt –
And if the user pressed “y” it will increment the race number and restart the race with new odds.
10. Game Over
There are two ways a game can be over. If all players have $0 remaining or user presses “n” on the replay prompt.
extension FX790...
private func race...
if players.remaining > 0, promptReplay() {
race(num: R+1, horses: horses, players: players)
} else {
displayGameOver()
}
private func displayGameOver() {
PRINT()
PRINT("GAME OVER")
}
So our final state is going to be –
enum GameState...
case displayGameOver
class HorseRaceViewModel...
var displayText...
case .displayGameOver: "GAME OVER"
And moveToNextState() becomes –
class HorseRaceViewModel...
private func moveToNextState
case .displayTotal(let player):
if let next = players.next(after: player) {
state = next.madeAnyBet
? .displayPrize(next, collectPrize(next))
: .displayTotal(next)
} else {
state = players.remaining
? .promptReplay
: .displayGameOver
}
case .promptReplay:
if let replay = replay() {
state = replay ? .displayRaceNumber : .displayGameOver
}
clearUserInput()
case .displayGameOver: break
And we are done. Here’s the final screen.
Conclusion
This is how far I will go with this game. Its fully done. Its looks and behave very much like the old BASIC program. I wanted to see how much things have changed with UI programming. The biggest change I would say is how we are doing MVVM pattern. Plus, game programming in this pattern leads to to Finite State Machine. The whole moveToNextState method is exactly like the old BASIC, except that its changing states at every step, rather than being procedural program.