Using GKMinmaxStrategist

I wear many hats. One of those hats is writing games. You can find the games I’ve written as Cetuscript Systems or WeGame Corp.

Hexin is a space chess game and I wanted a reasonable computer opponent for the players. I written a chess/checkers like game previously (not currently available) and my quick and dirty AI was okay-ish but not very challenging. For Hexin, I wanted something better.

With Hexin too, I wanted to explore the new game frameworks from Apple. GameplayKit is one of those new frameworks and it supplies a number of strategists classes. The GKMinmaxStrategist looked like it fit my requirements.

Tweaking the model for GKMinmaxStrategist was interesting. For my chess-like game, it boiled down to figuring out how to calculate a score for a particular board.

At the beginning, I tried working out a score by the value of pieces in the game. Hexin has three areas of the game: hanger, deck, and board. Pieces move from the hanger to the deck to in play on the board. Where the piece is determines if it is in play or how soon it might be to be put in play. Pieces in the hanger are not as valuable as pieces on the board.

A simple value of a player’s current pieces worked but not very well. The Strategist would know to improve the value of the player’s position but it didn’t recognize disaster to the plan. The Strategist would send its pieces on suicide missions.

To adjust for this, I had to also account for the move after. I was a bit worried about this because I didn’t want to spend the time writing an AI for the game. The Strategist should give me a reasonable opponent without going Deep Blue.

But, in the end, it seemed that accounting for the move after was enough to give me a reasonable opponent.

func score(for player: GKGameModelPlayer) -> Int {
    guard let who = player as? ModelPlayer else { return GKGameModelMinScore }
    
    let disaster    = GKGameModelMaxScore * 2
    let foolish     = 0
    
    let friend = who.side
    let enemy = friend.opponent
    
    let friendReach = self.evaluateReach(side: friend)
    let enemyReach = self.evaluateReach(side: enemy)
    
    var friendTotal:Int = self.evaluateValue(friend: friend) - self.whosePlaying[friend.rawValue].value
    var enemyTotal:Int  = self.evaluateValue(friend: enemy) - self.whosePlaying[enemy.rawValue].value
    
    var friendScore:Int = 0
    var enemyScore:Int  = 0

The friendReach and enemyReach are the next moves. Based on the current game, what can a player reach.

The friendTotal and enemyTotal are initialized with the difference between the initial value for the player and the current configuration value for the player. This gives the sense of are we better or worse off than when we started to figure out our next move.

    for (coord,_) in enemyReach {
        if let unit = self.board.units[coord], unit.side == friend {
            if unit.flavour == .Station {
                enemyScore += disaster  // this is a bad move
            } else if unit.flavour == .Portal && self.clusters[friend.rawValue].station.inHanger {
                enemyScore += disaster  // this is a bad move
            } else {
                enemyScore += unit.flavour.value
            }
        }
    }

I then go through the enemy’s reach. In the game, Stations and Portals are critical to play. The object of the game is to take the opponent’s Station or prevent the opponent from getting their Station onto the board by taking their Portal. If the enemy can take your Station, it is a disaster.

    for (coord,result) in friendReach {
        if let unit = self.board.units[coord], unit.side == enemy {
            if let _ = enemyReach[coord] {
                // this is a location that the enemy can take. not the best move.
                friendScore -= foolish
            } else {
                if unit.flavour == .Station {
                    friendScore += GKGameModelMaxScore
                } else if unit.flavour == .Portal && self.clusters[enemy.rawValue].station.inHanger {
                    friendScore += GKGameModelMaxScore
                } else {
                    if result == .Power {
                        friendScore += ValueForFighterPowered
                    } else {
                        friendScore += unit.flavour.value
                    }
                }
            }
        }
    }

Foolish is putting your piece into harms way. But I found that giving foolish a non-zero value didn’t help much.

Taking the opponent’s Station or Portal wasn’t weighted the same as the disaster of losing your own. Over weighting the victory lead to suicide missions. Also, in the game, there are power-ups. Using a power-up needed to be accounted for.

    friendTotal += (friendScore * TakePowerValueFriendFactor)
    enemyTotal += (enemyScore * TakePowerValueEnemyFactor)

The TakePowerValueFriendFactor is slightly larger than TakePowerValueEnemyFactor. The friendScore is given a bit more weight than the enemyScore.

    let diff    = friendTotal - enemyTotal
    var value   = max( min( GKGameModelMaxScore - 1000, diff), GKGameModelMinScore)

There is a gap of 1000 in the pinning of the score. This allows for the move that wins the game to be detected.

    if self.isLossForSide(side: who.side) {
        value = GKGameModelMinScore
    }
    
    if self.isLossForSide(side: who.side.opponent) {
        value = GKGameModelMaxScore
    }
    
    return value
}

My goal in using the GKMinmaxStrategist was to present a computer opponent that wouldn’t be too easy to beat, that would give enough practice for a player so they could learn the game. The model that I’ve tweaked to, I think, does that.

Hexin is available for macOS and iOS. And it is free 🙂

Published by

Mark Morrill

I’ve been writing software since the 80’s. Most of my career has been on Apple products.

Leave a Reply

Your email address will not be published.