A Magic The Gathering Arena Bot in Python
A bit of a departure from the usual sysadmin posts, I thought I’d share the Python code I’ve written for a Magic The Gathering Arena bot…my first gaming bot!
Below shows the bot in action for a single turn, although the bot is fully autonomous and will keep playing match after match until you stop it.
For those unfamiliar with Magic The Gathering Arena (MTGA), it’s a digital representation of the classic collectable card game. It’s free to play, with an in-game currency (Gold) with which you can purchase card packs to build your collection and strengthen your decks. You gain Gold by winning matches and achieving daily “quests” in the form of playing particular card types/colours during a match, although there is a limit on how many daily wins and quests you can complete.
So why the hell would I write a bot for MTGA? As much as I loved playing games in the past, the older I get the less I seem to be able to get back into them. I began playing MTGA with the intention of making it “the” game I’d give my attention to, but I quickly discovered I didn’t have either the time or skill to be any good at it! I still feel I might get into it when I have more free time (ha!), and that was the main trigger to write a bot, to have a pool of Gold and cards so I’m not starting from scratch.
I also wanted something a bit beefier to tackle with Python. I generally use Python for simple sysadmin tasks or web scraping, so I liked the idea of tackling something a bit more challenging. As it turns out, I enjoyed coding the bot more than actually playing the game! Not that it’s a bad game, actually it’s a great digital representation of the physical card game and one of the best CCGs around, so you should check it out if you’re curious.
But enough of the background, onto the code. You can view it fully on my github page below:
A quick warning to anybody trying to run this:
- The code is absolutely horrendous. I wrote this having no intention of ever sharing it, and looking back over it now, I struggle to even remember what some parts are doing. There’s live code in there that is completely redundant. It’s very hacked together in places.
- The bot will not work straight out of the box. It’s dependant on grayscale values at various points on the screen (explained later). I’m not providing the values I used in the code mainly because I don’t want just anybody who comes across this to be able to take advantage and run a MTGA bot. I’m posting this primarily as a record of the code, not because I want to distribute a bot. You will have to figure out the grayscale values in the Range class for yourself (by far the least fun part of this!) If there’s any interest, I’ll write a guide on how to determine the grayscale values, so leave a comment if you like.
The Main Loop
This is the main loop of the bot:
The script is constantly querying the grayscale value of screen coordinates with the scan_screen function to figure out where in the game it is; “Am I am on the start screen?”, “Is it my turn to play?”, “Am I on the match over screen?” etc.
For a far more detailed and knowledgeable explanation of this particular process, I’d recommend reading “How to Build a Python Bot That Can Play Web Games” by Chris Kiehl. I can’t take credit for any of his techniques related to capturing grayscale values for identifying game elements. Not only is it a great article on writing a Python game bot, it’s also one of the best written general Python tutorials I’ve come across, and was a lot of fun to follow. You’ll do yourself a big favour by reading his succinct tutorial before looking down through my spaghetti code! Here’s the link:
Below is the first part of the scan_screen function (prints are for basic debugging):
There are various 2-D “Zones” on the screen that are queried for their grayscale value, which in turn can tell us what screen we are on (detailed in the code comments). These values also make the bot locked to a 1920 x 1080 screen resolution. I did make an attempt to scale them with varying screen resolution, but I failed to get it working, and I knew I was only going to use this bot on a 1920 x 1080 monitor anyway.
Below is the get_greyscal_value function that returns the grayscale value of these Zones (OCD alert for naming grayscale “greyscale” in the code!)
This function uses the PIL library to essentially takes a snapshot of a 2D area, convert the result to grayscale, and return the sum of each grayscale pixel value.
The script relies on the fact that each of these Zones has a unique grayscale value (or more accurately, falls within a range of values, as you’ll see later). The 2nd part of the scan_screen function is the conditions that determines where in the game we are, based on the grayscale value provided:
As you can see, the grayscale value is tested against a range. These ranges are predefined in the below class, and were determined with a lot of trial and error. I’ve hidden the majority of the values, for the reason given previously.
Once a grayscale value falls within one of these ranges, scan_screen returns a string from the condition block identifying where we are, such as “Deck Select” or “In Match”. Back in the main loop, this string is used to trigger various actions.
Getting accurate grayscale values that could uniquely identify what screen we are on or which part of the match we are in was the most challenging part of this script. In theory, it should be simple enough to identify an area of the screen that is exclusive to the section of the game we are in, and use that area to take a unique grayscale measurement, and that was certainly the starting point but unfortunately it wasn’t quite as simple.
As an example, when we first launch the game, we want the bot to identify we are on the “Start” screen, and click “Play”. Here is the Start screen:
What this image doesn’t show is how dynamic the elements are. The background image changes regularly. The center tile automatically cycles, and along with the smaller tiles also change regularly. The daily quest/XP/Mastery counters change daily/as you play. One of the difficulties that will come up constantly when trying to find a good “zone” to analyse is the dynamic effects that are overlaid across the screen; glowing buttons, dust particles, blur effects. Sometimes these are barely perceivable, but when you begin taking grayscale measurements of an area every second for example, you soon realise the values are fluctuating wildly due to pixel value changes related to these effects. If the developers ever want to hinder this kind of bot technique, upping these effects might be one way to do it! 😉
In the end, for this Start screen, the most reliable way to identify it was to grab a grayscale value for a tiny section of the Play button. Even then, the value fluctuates due to the glow effect on the button, but is within a tight enough range (defined in the earlier mentioned Range class) that it uniquely identifies we are on the Start screen (or essentially unique; since pressing the Start button then triggers a sidebar, we also need to test a small area to the right for a particular value unique to when the sidebar is open, hence the and condition in the code).
Since we know we’re on the “Start” screen, we call the start_screen_actions function, which is a simple function that simply clicks Start, but demonstrates the mouse move and click method.
Each coordinate the script has to click is definted in the Cord class:
The function mousePos moves the mouse to the position for the Play button, by taking the x,y value of Cord.play_button and using the win32api library to set the position (the if condition for checking if mouse moves are disabled is for testing purposes):
Once the mouse is set at the correct position, the leftClick function clicks the button, by using a left button down event followed by a left button up event:
This process is repeated throughout the whole script; identify where we are in the game, decide which coordinate we need to move the mouse, and click.
Besides the start screen, I’ll detail a few other major areas:
The easiest way to win Gold in MTGA is to fulfil daily Quests. Examples of these quests are “Cast 30 blue or black spells” and “Attack with 45 creatures”. As you can see, this allows you to gain Gold without actually winning any matches…great!
In MTGA, there are five colours; blue, black, red, white and green. You can make decks mixing and matching cards of these colours, with dual-colour decks generally being the most common. The daily quests encourage you to play with every colour, so our bot can’t just keep playing with the same single colour or dual colour deck, and it’s very inefficient to play with a deck containing all 5 colours. So my solution to this was to make 5 mono colour “Autoplay” decks, one for each colour, that would be cycled through each match.
I put practically zero thought into the contents of these decks, other than the cards couldn’t have any abilities or triggers that would need player input, such as “deal damage to target creature”, in order to keep the bot as simple as possible and limit any additional logic that would be needed. These decks are simply land and cheap basic creatures. Simple as they are, you’d be surprised how often a deck like this can overrun an opponent, and they have easily fulfilled the daily Quests and win target.
Below is the function to select the deck:
There’s some redundant code in here; originally I thought I would have to keep track of which deck colour was used last, cycle through them appropriately at the end other each match, and choose the correct deck by its own coordinates. As it happens, the solution was a lot simpler. Below is the deck selection screen (names of decks redacted to hinder identifying my account for banning!):
The deck select screen is not static. When you complete a match with a deck, it automatically gets pushed to the “top” of the queue the next time you open the deck selection screen (where the blue highlighted deck is in the screenshot above) . Once we pre-populate the first 5 deck positions with our 5 mono-colour bot decks (by starting a game with each), all we need to do is select the 5th deck position each time to always pick the next colour. This is why in the code I originally had coordinates for all 5 decks, which turned out to be unnecessary, and instead just use Cord.red_deck, which happens to be the coordinates for the deck in 5th position. Once the deck is selected, we click play, and we are pitted against a random opponent.
Once we’re in the match, things get a little hairy and hacky in the code!
I decided early on that this was going to be a very dumb “brute force” bot. Initially I had messed about a little with OCR, to try reading the cards in hand and make some pre-defined plays, but it quickly became apparent that not only was that going to be incredibly difficult (for many of the same reasons it’s difficult to get a steady grayscale value), it also wasn’t worth the time and effort.
It’s generally accepted that 5 daily wins is optimum as a human player in terms of time-to-Gold earned ratio. After 5 wins, the amount of Gold you win decreases rapidly. So 5 wins a day was a good enough target for my bot. Since I was going to run this bot for a few hours every night, I came to the conclusion that the bot did not have to play well…or even at all. That was my first tactic, to just not play any cards while in a match, just sit there letting the timer run down. This did get me close to the 5 wins a day, as people who were on for a quick game would quit when they realised they were playing against a slow player (or somebody with connection issues), but I didn’t like the fact that I was wasting people’s time. I also wasn’t getting gold related to Daily Quests since I wasn’t playing any cards.
So the next option, which I went with, was to play cards blindly. This is why the deck is made up of only lands and simple creatures with no abilities; we play as many cards as we can each turn, attack with everything we can each turn, and don’t make any blocks if attacked each turn.
So with that, the general logic of the in-match loop is:
- Am I still in the match?
- If not, the match is over, so break out of the match loop
- If yes, is it my turn?
- If not, wait and keep clicking the “Resolve” button
- If yes:
- Are we in the combat phase?
- If not, cycle through cards in hand, attempting to play all of them
- If yes, click “All Attack” button
- Are we in the combat phase?
I won’t print all of the in-match loop here, as it’s fairly long and nasty code, but a few bits to highlight:
This is the code to accept or mulligan the initial hand that is dealt to you before the match actually begins. This was one of the more difficult screens to get automated, as half the time you are waiting for your opponent to select his cards for a time that is not pre-determined, and it’s also one of the more difficult screens to get a get a reliable grayscale value from. So in the end the bot simply waits for a few seconds and clicks the area where the “Accept” button is. Occasionally the opponent takes a long time deciding to mulligan or not, and we end up just running the timer down when it’s our turn to select, but it’s not a long process, and the bot gets back to normal once the match kicks off. The code related to SLOW_DRAW is redundant. I originally wanted a mixture of accepting the draw as soon as possible and taking the maximum amount of time possible to accept, for reasons I can’t remember now!
Below is the code for checking if the match is still on-going, which was a bit of a nightmare to get working. It is based on the grayscale value of the Online Friends icon.
Below is the code for checking if it is the bot’s turn:
This is based on the small black square indicators beside both player’s avatars that light up depending on which turn phase it is:
Comparing the grayscale values of these four areas (two by your opponent’s avatar, two by your avatar) lets you evaluate whose turn it is. Again, there was a lot of trial and error getting the range and conditionals right for these.
Next is the code which identifies that we are in the combat stage, and what to do:
This is based on the small shield icon that appears when you move into the combat stage, and triggers the bot to simply click “All Attack”.
The code for attack probability is meant to add some unpredictability to the bot’s actions, in case the opponent figures out he’s playing a bot that always attacks and changes his tactics accordingly, but I ended up just leaving this set to 100% attack probability, which you can define at the start of the script in some constants that are fairly self explanatory:
Below shows the bot attacking with all cards. There’s a brief click on the opponent’s avatar as the target of the attack, which prevents any problems if there’s an opposing Planeswalker in play:
When it actually comes to playing cards during the bot’s turn, the technique is very crude. In the Cords class, there are a number of x,y coordinates:
These coordinates essentially draw a line across the player’s cards at various intervals, arranged at the bottom of the screen as you play. The coordinates are used by the below chunk of looped code:
Essentially, the mouse is first placed on the center card (since you will always have a card here if you have at least one card remaining) and double clicks to play the card. If you have the resources to play the card, the card goes into the battlefield. If not, the bot detects the Undo button that appears to cancel the card, and clicks it. Either way, we move onto the next card position, which will be left of center, and repeat. The mouse positions alternate left to right, left to right, spanning further and further from the center of the cards. The amount of times this cycle from inside out is repeated can be defined in the variable MAX_CARD_CYCLES. I found that 2 cycles was enough to play all your playable cards 90% of the time, and still avoid unnecessary cycles that run the clock down. It’s a crude method, and can result in very slow play as the bot faffs about clicking areas where there are no cards, particularly at latter stages of the match when you only have 1 or 2 cards, but it does the trick. Below shows a typical card cycle, including Undo to cancel cards we don’t have the resources to play:
Once the match is over, there are a few functions for detecting the possible after-match screens, such as the match result, rewards, and even a “Did you enjoy this match” survey screen! But they just follow the same process as before, identifying some unique grayscale area and clicking the relevant button to proceed.
And that is pretty much the script! I had fun writing it, and to be honest, can’t quite look at games in the same way since! I realise the code is pretty bad in places, but it’s actually run rock solid for over four months now with very little intervention needed, so I’m happy with the results. Speaking of which…
I’ve been running this bot since mid February I think, as a scheduled task that ran each morning 1am – 4am, and it has gained me the following:
- Approx. 200,000 Gold
- Eldraine Mastery Completion
- Theros Mastery Completion (96+ Levels total)
- Ikoria Mastery Completion (87+ Levels total)
- With the above converted to opened packs, it’s given me:
- 100+ Commons
- 100+ Uncommons
- 60+ Rares
- 25+ Mythic Rares
- 3 Entry Tokens