Analyzing Our Parameters
Our last couple articles have focused on developing an AI for the player character in our game. It isn't perfect, but it's a decent approximation of how a human would try to play the game. This means we can now play iterations of the game without any human involvement. And by changing the parameters of our world, we can play a lot of different versions of the game.
Our goal for this week will be to write some simple analysis functions. These will play through the game without needing to display anything on the screen. Then we'll be able to play different versions in quick succession and compare results.
As always, the code for this project is on a Github Repository. For this article, take a look at the analyze-game
branch.
If you're completely new to Haskell, a simple game like this is a great way to get started! But you should start with our Beginners Checklist! It'll help you get everything set up with the language on your local machine! Then you can move onto our Liftoff Series to learn more about Haskell's mechanics.
Generating a Result
The first thing we need is a function that takes a world state and generates a result for it. Our game does have a degree of randomness. But once we fix the starting random generator for a everything is deterministic. This means we need a function like:
runGameToResult :: World -> GameResult
We'll want to use our updateFunc
from the main game runner. This is our "evolution" function. It's job is to go from one World
state to another. It evolves the game over the course of one timestep by allowing each of the agents to make a decision (or wait). (Note we don't use the Float
parameter in our game. It's just needed by Gloss).
updateFunc :: Float -> World -> World
Since we want to track an ever evolving stateful variable, we'll use the State
monad. For each iteration, we'll change the world using this update step. Then we'll check its result and see if it's finished. If not, we'll continue to run the game.
runGameToResult :: World -> GameResult
runGameToResult = evalState runGameState
where
runGameState :: State World GameResult
runGameState = do
modify (updateFunc 1.0)
currentResult <- gets worldResult
if currentResult /= GameInProgress
then return currentResult
else runGameState
Analysis: Generating World Iterations
Now that we can run a given world to its conclusion, let's add another step to the process. We'll run several different iterations with any given set of parameters on a world. Each of these will have a different set of starting enemy locations and drill power-ups. Let's make a function that will take a random generator and a "base world". It will derive a new world with random initial enemy positions and drill locations.
generateWorldIteration :: World -> StdGen -> World
We'll use a helper function from our game that generates random locations in our maze. It's stateful over the random generator.
generateRandomLocation :: (Int, Int) -> State StdGen Location
So first let's get all our locations:
generateWorldIteration :: World -> StdGen -> World
generateWorldIteration w gen1 = ...
where
params = worldParameters w
rowCount = numRows params
columnCount = numColumns params
enemyCount = numEnemies params
drillCount = numDrillPowerups params
(enemyLocations, gen2) = runState
(sequence
(map
(const (generateRandomLocation (rowCount, columnCount)))
[1..enemyCount])
)
gen1
(drillLocations, gen3) = runState
(sequence
(map
(const (generateRandomLocation (rowCount, columnCount)))
[1..drillCount])
)
gen2
...
Then we have to use the locations to generate our different enemies. Last, we'll plug all these new elements into our base world and return it!
generateWorldIteration :: World -> StdGen -> World
generateWorldIteration w gen1 = w
{ worldEnemies = enemies
, worldDrillPowerUpLocations = drillLocations
, worldRandomGenerator = gen3
, worldTime = 0
}
where
...
(enemyLocations, gen2) = ...
(drillLocations, gen3) = …
enemies = mkNewEnemy (enemyGameParameters params) <$> enemyLocations
Analysis: Making Parameter Sets
For our next order of business, we want to make what we'll call a parameter set. We want to run the game with different parameters each time. For instance, we can take a base set of parameters, and then change the number of enemies present in each one:
varyNumEnemies :: GameParameters -> [GameParameters]
varyNumEnemies baseParams = newParams <$> allEnemyNumbers
where
baseNumEnemies = numEnemies baseParams
allEnemyNumbers = [baseNumEnemies..(baseNumEnemies + 9)]
newParams i = baseParams { numEnemies = i }
We can do the same for the number of drill pickups:
varyNumDrillPickups :: GameParameters -> [GameParameters]
varyNumDrillPickups baseParams = newParams <$> allDrillNumbers
where
baseNumDrills = numDrillPowerups baseParams
allDrillNumbers = [baseNumDrills..(baseNumDrills + 9)]
newParams i = baseParams { numDrillPowerups = i }
Finally, we can have a different cooldown time for our player's stun ability.
varyPlayerStunCooldown :: GameParameters -> [GameParameters]
varyPlayerStunCooldown baseParams = newParams <$> allCooldowns
where
basePlayerParams = playerGameParameters baseParams
baseCooldown = initialStunTimer basePlayerParams
allCooldowns = [(baseCooldown - 4)..(baseCooldown + 5)]
newParams i = baseParams
{ playerGameParameters = basePlayerParams { initialStunTimer = i }}
If you fork our code, you can try altering some other parameters. You can even try combining certain parameters to see what the results are!
Tying It Together
We've done most of the hard work now. We'll have a function that takes a number of iterations per parameter set, the base world, and a generator for those sets. It'll match up each parameter set to the number of wins the player gets over the course of the iterations.
runAllIterations
:: Int
-> World
-> (GameParameters -> [GameParameters])
-> [(GameParameters, Int)]
runAllIterations numIterations w paramGenerator =
map countWins results
where
aiParams = (worldParameters w) { usePlayerAI = True }
paramSets = paramGenerator aiParams
runParamSet :: GameParameters -> [GameResult]
runParamSet ps = map
(runGame w {worldParameters = ps })
[1..numIterations]
runGame :: World -> Int -> GameResult
runGame baseWorld seed = runGameToResult
(generateWorldIteration baseWorld (mkStdGen seed))
results :: [(GameParameters, [GameResult])]
results = zip paramSets (map runParamSet paramSets)
countWins :: (GameParameters, [GameResult]) -> (GameParameters, Int)
countWins (gp, gameResults) =
(gp, length (filter (== GameWon) gameResults))
We need one more function. It will read an input file and apply our steps over a particular parameter group. Here's an example with varying the number of enemies:
analyzeNumEnemies :: FilePath -> IO ()
analyzeNumEnemies fp = do
world <- loadWorldFromFile fp
let numIterations = 10
putStrLn "Analyzing Different Numbers of Enemies"
let results = runAllIterations numIterations world varyNumEnemies
forM_ results $ \(gp, numWins) -> putStrLn $
"With " ++ (show (numEnemies gp)) ++ " Enemies: " ++ (show numWins)
++ " wins out of " ++ (show numIterations) ++ " iterations."
Now we're done! In the appendix, you can find some basic results of our investigation!
Conclusion
Soon, we'll take our analysis steps and apply them in a more systematic way. We'll try to gauge the difficulty of a particular game level. Then we can make levels that get more and more challenging!
But first, we'll start exploring a few ways we can improve the player and enemy AI abilities. We'll start by implementing some basic caching mechanisms in our breadth first search. Then we'll consider some other AI patterns besides simple BFS.
For a review of the code in this article, take a look at our Github Repository. You'll want to explore the analyze-game
branch!
We'll soon be exploring machine learning a bit more as we try to improve the game. Make sure to read our series on Haskell and AI to learn more! Download our Haskell Tensorflow Guide to see how we can use tensor flow with Haskell!
Appendix
With 4 drills and 10 cooldown time:
Analyzing Different Numbers of Enemies
With 4 Enemies: 10 wins out of 10 iterations.
With 5 Enemies: 9 wins out of 10 iterations.
With 6 Enemies: 9 wins out of 10 iterations.
With 7 Enemies: 10 wins out of 10 iterations.
With 8 Enemies: 9 wins out of 10 iterations.
With 9 Enemies: 9 wins out of 10 iterations.
With 10 Enemies: 9 wins out of 10 iterations.
With 11 Enemies: 9 wins out of 10 iterations.
With 12 Enemies: 8 wins out of 10 iterations.
With 13 Enemies: 7 wins out of 10 iterations.
With 13 enemies and 10 cooldown time:
With 2 Drills: 5 wins out of 10 iterations.
With 3 Drills: 7 wins out of 10 iterations.
With 4 Drills: 8 wins out of 10 iterations.
With 5 Drills: 8 wins out of 10 iterations.
With 6 Drills: 8 wins out of 10 iterations.
With 7 Drills: 7 wins out of 10 iterations.
With 8 Drills: 8 wins out of 10 iterations.
With 9 Drills: 8 wins out of 10 iterations.
With 10 Drills: 8 wins out of 10 iterations.
With 11 Drills: 8 wins out of 10 iterations.