Horse Race Game, Part 2
Continuing from previous part, I converted the old BASIC game from Casio FX-790P into SwiftUI.
Introduction
In Part 1, I converted an old BASIC game from Casio FX-790P into Swift programing language. By the end of that part, I had the beginnings of a MVVM app. The Horse and Player classes were the Models, the horseRaceGame() method was the ViewModel, and the 16 display and prompt messages were the Views. In this part, I am going to put it all together in a proper SwiftUI application.
Here is the original game description.
We are going to try to stick to this as much as possible. The goal is not to improve the game or the UI, but to see how far and different modern languages and UI frameworks have become since the 90s.
1. Start Game
Our very first view is the display of the game title. Here’s the code from part 1.
extension FX790...
private func displayGameTitle() {
PRINT("< Horse Race >")
for _ in 1...5 {
BEEP()
BEEP(1)
}
}
To convert this to SwiftUI, we need to start with the @main App.
@main
struct HorseRaceApp: App {
var body: some Scene {
WindowGroup {
HorseRaceView()
}
}
}
All our game display logic will go inside HorseRaceView. I wanted to create same view as original monospaced, single line, text display.
struct HorseRaceView: View {
var body: some View {
Text("< Horse Race >")
.font(.system(size: 28, design: .monospaced))
.frame(width: 400, alignment: .leading)
.padding()
.background(Color.white)
.border(Color.gray, width: 1)
}
}
The width of 400 will fit 23 characters, as our original game. Here’s what is rendered.
All our displays will happen inside this box. So I decided to take this box and make it a base view called TrackView, (as in a “horse track”)
struct TrackView<Content: View>: View {
let fontSize: CGFloat = 28
let content: () -> Content
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content
}
var body: some View {
content()
.font(.system(size: fontSize, design: .monospaced))
.frame(width: trackWidth(), alignment: .leading)
.padding()
.background(Color.white)
.border(Color.gray, width: 1)
}
}
And then our HorseRaceView can use this base view –
struct HorseRaceView: View {
var body: some View {
TrackView {
Text("< Horse Race >")
}
}
}
Also notice that that I make the width of the track dynamic with a function trackWidth() –
struct TrackView...
private func trackWidth() -> CGFloat {
let font = NSFont.monospacedSystemFont(
ofSize: fontSize,
weight: .regular)
let charWidth = "X".size(withAttributes: [.font: font]).width
return charWidth * CGFloat(GameConfig.goalPosition)
}
"X".size(withAttributes:) calculates the width of a single character, which we multiply by the number of characters (GameConfig.goalPosition) to get the total track width in pixels. The frame(width:) now dynamically fits our 23-character track.
2. Display Horses
The next display is the horses description —
extension FX790...
private func displayHorses(_ horses: [Horse]) {
PRINT() // clear screen
PRINT("HORSE")
for horse in horses {
PRINT(" \(horse.id)\(horse.symbol)")
}
TIMER()
TIMER()
}
We don’t need a whole new View type every time the text changes. SwiftUI encourages state-driven UIs, not manually swapping whole views. The view’s body automatically re-renders when its state changes. That’s the natural way to do timed or gesture changes. In our game, the new display message is just a different state of the same HorseRaceView. This is much easier to animate, control timing, or even loop messages later.
So we will keep everything inside the HorseRaceView and just drive it with @State. The state will be driven by a HorseRaceViewModel. In BASIC, the code was very procedural, which we cannot do in SwiftUI. So we will have to do some state management. We will need an enum to track the current state.
enum GameState {
case displayTitle
case displayHorses
}
The current state will be maintained by the view model.
@Observable
class HorseRaceViewModel {
private var state: GameState = .displayGameTitle
private var horses: [Horse] = Horse.all()
var displayText: String {
return switch state {
case .displayTitle: "< Horse Race >"
case .displayHorses:
"HORSES \(horses.map(\.label).joined(separator: " "))"
}
}
func moveToNextState() {
switch state {
case .displayTitle: state = .displayHorses
case .displayHorses: state = break
}
}
}
The horses are obtained from the static method all() in the Horse model, and the label is extension property.
extension Horse {
var label: String { "\(id)\(symbol)" }
static func all() -> [Horse] {
[
Horse(id: 1, symbol: "♠"),
Horse(id: 2, symbol: "♥"),
Horse(id: 3, symbol: "♦"),
Horse(id: 4, symbol: "♣")
]
}
}
And finally, our view is —
struct HorseRaceView: View {
@State private var viewModel = HorseRaceViewModel()
var body: some View {
TrackView {
Text(viewModel.displayText)
}
.onKeyPress(.return) {
viewModel.moveToNextState()
return .handled
}
}
}
On pressing RETURN, our state is moved to the next state.
We need to capture the key press, and for that the view must be in focus. So I made the entire TrackView focusable.
struct TrackView...
@FocusState private var isFocused: Bool
var body: some View {
content()
.font(.system(size: fontSize, design: .monospaced))
.frame(width: trackWidth(), alignment: .leading)
.padding()
.background(Color.white)
.border(Color.gray, width: 1)
.focusable()
.focused($isFocused)
.focusEffectDisabled()
.onAppear {
isFocused = true
}
}
3. Number of Players Prompt
Next is the prompt to get the user input for number of players.
extension FX790...
private func promptNumPlayers() -> Int {
PRINT()
BEEP()
while true {
let p: Int = INPUT("How many players ")
if (GameConfig.minPlayers...GameConfig.maxPlayers).contains(p) {
return p
}
}
}
Let’s add a new state for this.
enum GameState {
case displayTitle
case displayHorses
case promptNumPlayers
}
Our displayText and moveToNextState() needs a new case statement for the promptNumPlayers.
class HorseRaceViewModel...
var displayText: String {
return switch state {
...
case .promptNumPlayers: "How many players?"
}
}
private func moveToNextState() {
switch state {
case .displayTitle: state = .displayHorses
case .displayHorses: state = .promptNumPlayers
case .promptNumPlayers: break
}
That’s it. Just couple lines of code, and now our prompt shows up.
4. Blinking Cursor
Next we need to figure out how do we capture user input. We could add a TextField after the question mark. But I want to make it as close as the single line console display. I want to show a blinking underscore to tell the user that an input is expected after the question mark, just like in a console. The simplest approach is to append a _
that blinks on and off. We can simulate a blinking cursor in SwiftUI 5 using a Timer with a simple toggle state.
struct BlinkingCursorText: View {
@State private var showCursor = true
let text: String
let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
init(_ content: String) {
self.text = content
}
var body: some View {
Text(text + (showCursor ? "_" : ""))
.font(.system(size: 28, design: .monospaced))
.onReceive(timer) { _ in
showCursor.toggle()
}
}
}
We can use this for only those displayText which are prompts. I added a helper field in GameState enum, because I know there are 3 more prompts coming later.
struct HorseRaceView...
var body...
TrackView {
if viewModel.state.isPrompt {
BlinkingCursorText(viewModel.displayText)
} else {
Text(viewModel.displayText)
}
}
enum GameState...
var isPrompt: Bool {
switch self {
case .promptNumPlayers: return true
default : return false
}
}
}
The _
toggles every 0.5s, giving the classic blinking cursor effect. This works nicely with “console-style” layout without needing a real TextField.
5. Capturing User Input
Next, we need to capture key presses from the users. We already have a way to capture user input of RETURN key. Instead of just that one key, we need to open it up for any key, and then have a Switch statement for the key pressed. So I created a handleKeyPress(:KeyPress) method in the view model.
struct HorseRaceView...
var body: some View {
TrackView...
.onKeyPress(action: viewModel.handleKeyPress)
}
class HorseRaceViewModel...
func handleKeyPress(_ keyPress: KeyPress) -> KeyPress.Result {
switch keyPress.key.character {
case "\r": moveToNextState()
case let char: handleUserInput(char)
}
return .handled
}
The handleUserInput() method will need to store the input. I added a field userInput which will be used the track the input from the user.
class HorseRaceViewModel...
private var userInput: String = ""
var displayText...
case .promptNumPlayers: "How many players? \(userInput)"
private func handleUserInput(_ char: Character) {
guard state.isPrompt else { return }
userInput.append(char)
}
Now we can see user input when I press ‘4’ and ‘5’.
But what if user made a mistake and delete the input. Like here I want to delete ‘5’. We need to handle backspace. That’s just another case in handleKeyPress.
class HorseRaceViewModel...
func handleKeyPress(_ keyPress: KeyPress) -> KeyPress.Result {
switch keyPress.key.character {
case Keys.returnKey: moveToNextState()
case Keys.backspace, Keys.delete: handleBackspace()
case let char: handleUserInput(char)
}
return .handled
}
private func handleBackspace() {
guard state.isPrompt else { return }
guard !userInput.isEmpty else { return }
userInput.removeLast()
}
enum Keys {
static let returnKey: Character = "\r"
static let backspace: Character = "\u{8}"
static let delete: Character = "\u{7F}"
}
Keys is as an enum with no cases, it’s an uninstantiable namespace. This prevents someone from ever making a Keys() instance, which is nice when we only want constants.
6. Instantiating Players
Now after the user is satisfied with their input, they will press RETURN, which will call moveToNextState(). We need to read the userInput, convert it to Int, do validations, and create Players.
class HorseRaceViewModel {
private var players: [Player] = []
private func moveToNextState() {
switch state {
case .displayTitle: state = .displayHorses
case .displayHorses: state = .promptNumPlayers
case .promptNumPlayers:
if addPlayers() {
state = ... // Move to next state
}
clearUserInput() // RETURN should clear the input
}
}
private func clearUserInput() {
userInput = ""
}
private func addPlayers() -> Bool {
guard let numPlayers = Int(userInput) else { return false }
guard numPlayers >= GameConfig.minPlayers else { return false }
guard numPlayers <= GameConfig.maxPlayers else { return false }
players.removeAll()
for p in 1...numPlayers {
players.append(Player(id: p))
}
return true
}
We are done with this step. We have laid some good groundwork for capturing and processing user input. This will help us in upcoming stages. I think they will go fast.
7. Initial Player Holdings
Next is displaying initial player holdings.
extension FX790...
private func displayInitialHoldings() {
PRINT("ALL PLAYERS HAVE $\(GameConfig.startingMoney)")
TIMER()
}
This requires just 4 lines of code change.
enum GameState...
case displayInitialHoldings
class HorseRaceViewModel...
var displayText...
case .displayInitialHoldings:
"All players have $\(GameConfig.startingMoney)"
private func moveToNextState...
case .promptNumPlayers:
if addPlayers() {
state = .displayInitialHoldings
}
userInput = ""
case .displayInitialHoldings: break
This displays —
8. Race Number
Next we need to display the race number, and reset horses and players for a new race.
private func displayRaceNumber(_ R: Int) {
PRINT()
print("<RACE \(R)>")
TIMER()
}
We add a new field to keep track of race numbers. Rest is simple.
enum GameState...
case displayRaceNumber
class HorseRaceViewModel...
private var raceNumber: Int = 1
var displayText...
case .displayRaceNumber: "<Race \(raceNumber)>"
private func moveToNextState...
case .displayInitialHoldings:
resetForNewRace()
state = .displayRaceNumber
case .displayRaceNumber: break
private func resetForNewRace() {
horses.forEach { $0.reset() }
players.forEach { $0.reset() }
}
This displays —
9. Individual Player holdings
Next step is showing each individual player’s remaining money before they choose their horse and bet money on it.
extension FX790...
private func displayPlayerHoldings(_ player: Player) {
PRINT()
BEEP()
PRINT("PLAYER \(player.id) HAS $\(player.money)")
TIMER()
}
So we introduce a new state.
enum GameState...
case displayPlayerHoldings(Player)
We need the Player as the associative value to know which player to show the holdings for. Let’s implement this new state —
class HorseRaceViewModel...
var displayText...
case .displayPlayerHoldings(let p):
"Player \(p.id) has $\(p.money)"
}
}
private func moveToNextState...
case .displayRaceNumber:
if let player = players.first() {
state = .displayPlayerHoldings(player)
}
case .displayPlayerHoldings(let player):
state = players.next(after: player)
.map { .displayPlayerHoldings($0) }
.orElse(...) // move to next state
}
}
I implemented a couple of helpers first() and next(after:) on the player collection. This way I can loop through each player. In the BASIC code it had skipped the players who had $0 money.
class Player...
var isBroke: Bool { money == 0 }
var isActive: Bool { !isBroke }
extension Array where Element == Player...
func first() -> Player? {
first(where: \.isActive)
}
func next(after player: Player) -> Player? {
guard let curr = firstIndex(of: player) else { return nil }
return self[(curr + 1)...].first(where: \.isActive)
}
}
Now we can show each individual players holdings.
End of Part 2
We have come have about half way in our SwiftUI implementation of this game. Lets pause here for a minute and see how the game is shaping up. We can see that this moveToNextState is turning out to be orchestrator of this whole game. The logic here is matching the original game. It is the core game flow driver.
class HorseRaceViewModel...
private func moveToNextState() {
switch state {
case .displayTitle: state = .describeHorses
case .describeHorses: state = .promptNumPlayers
case .promptNumPlayers:
if addPlayers() {
state = .displayInitialHoldings
}
clearUserInput()
case .displayInitialHoldings:
resetForNewRace()
state = .displayRaceNumber
case .displayRaceNumber:
if let player = players.first() {
state = .displayPlayerHoldings(player)
}
case .displayPlayerHoldings(let player):
state = players.next(after: player)
.map { .displayPlayerHoldings($0) }
.orElse(.displayTitle)
}
}
Games have different architecture traditions than MVVM. A very common one is Finite State Machine (FSM). This is exactly what this method doing now. Each game state (e.g. displayTitle, describeHorses, promptNumPlayers, etc.) has logic for transitions which is often implemented with enums and switch statements (like we have), or as a State pattern, which is the OO version of FSM, where each state has its own object/class with enter(), update(), exit() methods. Small/indie games often use FSM (like we’re doing). Bigger games use OO State Pattern or Entity-Component-System (ECS). ECS is popular in larger games (Unity, Bevy, etc.), where logic is separated into components (data, like Health or Position) and systems (logic, like MovementSystem or RenderSystem). ECS is more complex but very scalable for simulation-heavy games.
We will continue the rest of the game development in Part 3. There we will also see if we should refactor moveToNextState to a FSM.