Whether you’re creating a turn-based mechanics or porting a board game, AI is going to be a crucial element of your design. I’m going to show you how we tackled it in Brass game. If you ever face a similar challenge – I hope this article will be your inspiration.
Brass is a medium-heavy economic boardgame taking place during industrial revolution. When designing the mobile edition (apple & android) we assumed most people are going to play against other players online and developed a basic AI just to be there. It turned out, however, many players prefer the offline mode. And there is not much fun in smashing the virtual opponents without any effort.
I took the quest to change that.
This is how I brought the AI challenge to completely new level!
Preparations - Insight tools
First I needed a pair of new eyes – tools to be able to look over AI’s shoulder during the game and be able to point out the mistakes + something to tell me whether my updates are actually helping or not. This is what I came up with:
It is basically a debug-line info showing what would AI do during manual play. The main goal is to show AI’s thinking process in human-understandable form – an insight into what’s happening behind the curtain.
This is how it looks in my Unity layout – I can instantly compare the AI’s suggestions against the situation on the board:
And the marked details:
The AI rules are sorted in priority order – AI would execute the highest one
To increase the readability:
Each rule is marked with ★ to make it visually distinguishable
1st line is a rule name - to get an instant idea
2nd line are the details for more thorough investigation
Of course in the manual mode I don’t have to follow AI’s suggestions and can try out my own strategies or just toss the AI into unexpected situations!
2. Stat runner
Running 1000 automated games would tell me the impact of improvements. It’s also a great help with tuning up the parameters (priorities, trigger values etc.). I choose the 1000 sample size because running it takes reasonable ~4 minutes on my PC (just enough to make a coffee or catch up with news; for the quick checks I was using 100 sample though). Other things to notice:
Deterministic random – each game is initialized with Random(n), where n is the consecutive game number. That’s in order to avoid the statistical noise that could bias the results. The new risk is that I’d optimize the algorithm for just that specific sample, but with size of 1000 I assumed it was ignorable (later I run a bunch of true-random tests and the stats didn’t change much).
Win rate optimization – I was aiming for the highest win numbers against other AIs. This results in the behavior where the algorithm not only tries to maximize its own score, but also suppresses the opponents. I think this ‘mean’ attitude is cooler than just optimizing for the highest individual score (and certainly will be more challenging for the human!).
Game logic & UI separation – this was an early architectural decision which actually made the statistical approach possible (running hundreds of automated games without starting the whole heavy UI system). I can’t even tell how much effort it saved me, but can easily imagine long frustrating hours of refactoring that would drain all my enthusiasm.
Each dot represents one game and the victory count is summed up for each AI player.
I had the tools, yet I needed a strategy.
The old placeholder AI was a big if-else cascade which, when played against several such opponents, gave an impression of flock of sheep. I needed something that was elastic, that would seek individual opportunities and adapt to the situation on the board.
First thought was a minimax algorithm, then a Deep Learning network. Unfortunately, both solutions would be too heavy for mobile processors paired with Brass game complexity.
Therefore, I took the iterative approach of adding specialized rules that would look for opportunities and take control if they found any. Each rule would have a priority depending on the key conditions on the board. The old AI would be demoted to the fallback option if no opportunity rules were triggered.
The main function would call the list of rules, sort them by priority & return the top one.
There are few things to notice in this approach:
● Each rule considers different key variables (there might be even rules triggered via different conditions that result in the same move, e.g. connecting 2 cities). Priority is depending on these variables. Theoretically you could mimic that in a gigantic if-else cascade, but that would be way harder to implement and the result would be long, messy & unreadable code (same reason why I’m not writing this solution in assembler).
● This brings us to another point: maintainability
With the rule-based system it’s extremely easy to experiment:
add new rules, change priorities, modify conditions, introduce new strategy flavors…
– everything is cheap!
● One of the biggest concern are complex strategies requiring several moves:
How to implement them in a simple Input -> Output system?
Fortunately this is where the priority system comes handy again.
Some actions just have a goal to enable the other rules, e.g.: connect a city to be able to build there; next turn the build rule is going to see a connected city and do its job.
Just make sure the closure rules have high enough priority, otherwise you’ll be jumping between various enablers without completing any strategy.
● And this brings us to another benefit: elasticity
If an AI happen to fail at some strategy (e.g. competition was faster with distant market sale), it can fluently move to the next at the priority list. If a new opportunity appears – compare it against the other priorities and seize it if it’s worthy.
Of course this approach has its limitations too – basic rules will only allow you a certain level of complexity and you’ll quickly reach that limit in abstract strategy games (especially with 1vs1, like chess or Go). It’s best fitted for the games with 2+ players, where some information is unknown or with degree of randomness – this is where statistical optimization can turn these factors in AI’s favor.
I took the old solution as a baseline and started optimizing new AI against it. The stats went up, but the gameplay experience didn’t improve much. I taught the algorithm how to win against placeholder opponent, but not much more.
The real progress begun once I started feeding new improvements into AI’s opponents as well. In order to get better, I had to keep finding new strategies against ever evolving algorithm - adapt or die!
This is how the workflow cycle looked like:
Player composition I used for statistical runs & optimization:
The more I optimized the algorithm, the harder it got to find new strategies.
I gave up after not being able to push it further for two consecutive days, the whole effort took ~2 months (together with preparations).
Stats & conclusions
So how is the new solution doing vs old placeholder AI?
Let’s see the win rates + distribution of game Victory Points (about Box and Whisker chart). Watch how the results change as I gradually add more advanced AIs to the player palette (each run is 1k games and uses same pseudo-random seed):
Basic AI only:
1Hard AI + 3 Basic AIs:
2 Hard AIs + 2 Basic AIs:
3 Hard AIs + 1 Basic AI:
4 Hard AIs:
Note the 2 bankruptcies – playing aggressive pays off most of the time, but if you lose… you lose hard.
I’m not sure why the yellow dude tends to be weaker than the others (same occurs on fully randomized runs) - my suspect is the player order shuffling, but I’d need to investigate.
Surprisingly, during most plays, the algorithm turns out to be better than its author – me.
I blame it on statistical optimizations which give it more boost than my gut feelings.
Getting beaten by own code is the sweetest defeat though.