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.
The Code
But enough of the background, onto the code. You can view it fully on my github page below:
https://github.com/defaultroot1/Python-Scripts/blob/master/mtga_bot.py
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:
https://code.tutsplus.com/tutorials/how-to-build-a-python-bot-that-can-play-web-games–active-11117
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:
Deck Select
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.
In Match
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…
The Results
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
I would like to request help for the missing grayscale values. I would love something like this to automate the daily grind of the 15 wins and this is the closest I have found. I have racked my brain all week trying diffrent ways of getting the grayscale values based on your zone locations but I have not been able to duplicate any that you have left in so I know im not on the right track. You would be freeing up so much of my spare time I would be eternally grateful.
I am using this to get a screen cap of the zones.
from PIL import ImageGrab
import os
import time
class Zone:
# Maintain co-ordinates of zones/boxes that will be analysed for grayscale value
but_play = (1705, 1000, 1708, 1005) # On opening screen at game launch
but_play_sidebar = (1900, 670, 1905, 675) # After you press play to choose deck
friends_icon = (30, 1005, 35, 1015) # In match, Match Victory, or Match Defeat
match_result = (1835, 1020, 1868, 1038) # Match is over and awaiting click
undo_but = (1860, 830, 1870, 840) # Undo button, appears when not sufficient mana to cast card
p1_main_phase = (830, 872, 840, 882) # Main phase icon, indicating your turn, or not first main
p1_second_phase = (1080, 870, 1090, 880) # Second phase icon
p2_main_phase = (850, 118, 860, 128) # Opponent Main phase icon
p2_second_phase = (1058, 118, 1068, 128) # Opponent Second phase icon
mulligan_button = (764, 857, 766, 877) # Confirms start of match Mulligan/Keep
shield_icon = (1770, 824, 1780, 834) # Shield icon, black when having to choose No/All Attack
block_order = (1316, 783, 1329, 785) # Screen when opponent chooses multiple blockers
harmonix_name = (98, 1030, 99, 1040) # Player name at bottom left of combat screen
smiley_face = (1236, 426, 1240, 455) # Last ‘h’ on smiley face “Did you have fun” screen
def screenGrab():
box = (30, 1005, 35, 1015)
im = ImageGrab.grab(box)
im.save(os.getcwd() + ‘\\full_snap__’ + str(int(time.time())) +
‘.png’, ‘PNG’)
def main():
screenGrab()
if __name__ == ‘__main__’:
main()
then this to get the grayscale values but nothing is matching:
from PIL import Image
img = Image.open(‘play.png’).convert(‘P’) # convert image to 8-bit grayscale
WIDTH, HEIGHT = img.size
data = list(img.getdata()) # convert image data to a list of integers
# convert that to 2D list (list of lists of integers)
data = [data[offset:offset+WIDTH] for offset in range(0, WIDTH*HEIGHT, WIDTH)]
# At this point the image’s pixels are all in memory and can be accessed
# individually using data[row][col].
# For example:
for row in data:
print(‘ ‘.join(‘{:3}’.format(value) for value in row))
# Here’s another more compact representation.
chars = ‘@%#*+=-:. ‘ # Change as desired.
scale = (len(chars)-1)/255.
print()
for row in data:
print(‘ ‘.join(chars[int(value*scale)] for value in row))
Hi Jon,
The grayscale values were frustrating to get for sure, as I said in the article it’s probably what took the most amount of time. I’m sure there’s a better way to do it than I did, which was very hacky with lots of trial and error. I simply used the print output for the various zones in the scan_screen() function eyeballed it.
Comment out any other distracting print outputs and have just the scan_screen() prints displaying in the console, it’s readable enough that you can get a sense of the min and max values. Round them up/down a bit and plug them into the Range class. Some of the values are forgiving (you have a cushion of hundreds either side) while some of them are very very tight (single digits either side). In hindsight, it would probably have been worth writing a small function that took the values from the get_greyscale_value() calls and maintained the min/max values over the course of a few games, but you can definitely get a sense of the range “by eye”. Also make sure you have the graphics on low and running at 1920 x 1080.
Just a warning though; I stopped using this bot at the end of the last rotation, I’ve no idea if it still works, although in saying that I was running this for months without a need to tweak it. I haven’t logged in since; any kind of UI or graphical update will almost surely break the bot.
Hey I was interested in the Greyscale guide. I’m hoping I can try this out too grind some gold, can’t play as much now with a new baby in the house. Lots of having to concede when she about to fall down the stairs! Thanks! I appreciate it!
Hi,
Going to just copy the same reply I gave to Jon above:
The grayscale values were frustrating to get for sure, as I said in the article it’s probably what took the most amount of time. I’m sure there’s a better way to do it than I did, which was very hacky with lots of trial and error. I simply used the print output for the various zones in the scan_screen() function eyeballed it.
Comment out any other distracting print outputs and have just the scan_screen() prints displaying in the console, it’s readable enough that you can get a sense of the min and max values. Round them up/down a bit and plug them into the Range class. Some of the values are forgiving (you have a cushion of hundreds either side) while some of them are very very tight (single digits either side). In hindsight, it would probably have been worth writing a small function that took the values from the get_greyscale_value() calls and maintained the min/max values over the course of a few games, but you can definitely get a sense of the range “by eye”. Also make sure you have the graphics on low and running at 1920 x 1080.
Just a warning though; I stopped using this bot at the end of the last rotation, I’ve no idea if it still works, although in saying that I was running this for months without a need to tweak it. I haven’t logged in since; any kind of UI or graphical update will almost surely break the bot.
I bet it took you longer to program this bot out of shear boredom/challenge and spent more time coding this vs. actually using it to play mtga. I guess this because you sound similiar to myself in regards to games/mtg. I also wonder how long the people trying to solve the missing values have spent. Their doing it for all the wrong reasons and just want a simple way out as most people these day do. This is a coding post, not a post bragging about making a bot and the OP is proud of what he accomplished.
Think of an artist mixing colors (photoshop actions for digital artists) to create an amazing piece of art. You can see the end result but the color mixing is the artists secrets.
I wouldn’t quite say I spent more time coding this than playing MTGA, but it was certainly a better use of my time! But yes, you’re right, in the end I enjoyed coding this more than actually playing the game, and it’s no coincidence that I barely played a single manual game of MTGA as soon as I started this bot. Even while tracking the gold/gems over the months, I knew I was never really going to get back into it. MTG is a great game, just too much of a time and money sink for me.
Would be very surprised if this bot still works, so hopefully anybody who is trying to figure out those grayscale values has enough Python knowledge to be able to debug the code and see what other parts need to be fixed.
I’d love to have one of these just so I could build up the gold to be able to take a few shots at the Arena Opens when they happen every month or two. They’re like 20000 gold to enter, which is a TON of gold. If I could build up a pile of gold on a couple accounts and be able to take a bunch of freeroll shots at them, instead of like $20+ per shot, which could end in like 1-2 games, I’d be so happy.
dude im so happy im not the only one who thought of this I just got mine fully working, it was a tuff time working out all that
any chance you could lend a hand? This has been stumping me
Yoda#1111
Why would you not want to use an easier way to grab colors at a location? Maybe even using a program that is meant for this, like AutoIt or AHK (both of which can look for colors in a certain location).
After following the tutorial linked in this post, I just wanted to apply the same techniques to MTGA to see if they worked. I’ve no doubt there are easier ways to do this!
I would like to fork this possible?
could i also get some pointers on the gray scaling? i got the bot running but I’m struggling to line up the cancel button for when something is cast without means, please contact me directly if you want to know my intent or anything jsawdon@kennedytech.com
I don’t know if you tracked it, but I’m curious to know what the winrate of the bot was. Would also be fun to know the splits by color or by play/draw. My guess would be somewhere in the 15-25% range.
I have one question, not regarding the bot itself but regarding the automation of the task.
You said you used the bot between 1am and 4am. What did you use to auto start / stop the script?
Are you able also to auto open/close the game or did you have the game always open and the you ran the script at those intervals?
I actually should have written about that in the article, because now that I’ve moved onto a new PC, I’m struggling to remember the process! The PowerShell script to launch the game and bot was simple:
cd "E:\Program Files\Wizards of the Coast\MTGA\MTGALauncher\"
.\MTGALauncher.exe
Start-Sleep 5
python.exe "C:\path\to\script\mtga_bot.py"
As for how I launched it at specific times, I’m struggling to remember, because it wasn’t as simple as running the above script as a Scheduled Task. It can be, if you’re happy to leave your PC running overnight. But I had some process involving a separate profile on my PC, a small script that set autologin registry key to that profile and rebooted at 1am, and then the reverse to reboot back to the Windows login screen at 4am. Two scripts I was using:
disableAutoLogon.ps1
Set-Itemproperty -path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -Name 'AutoAdminLogon' -value 0
shutdown /r /t 0
enableAutoLogon.ps1
Set-Itemproperty -path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -Name 'AutoAdminLogon' -value 1
Set-Itemproperty -path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -Name 'DefaultUserName' -value "autologin"
shutdown /r /t 0
As to exactly why I was doing that…I have absolutely no idea! It was probably something to do with bringing my PC in and out of sleep, but I can’t remember at this stage, sorry. But no, the game was never open all the time, it only launched to run the bot. To be honest there were weeks where I completely forgot it was running every night.
I have everything set up in a VM and it’s working perfectly.
I added some changes to use screenshot matching and it’s working well.
The problem I have is I have to run the powershell with admin rights for some reason.
Did you have that issue too?
There is a command you can add to the top of your script to check and re-run as admin. I will trade for this bot to be adapted to god’s unchained
Hello, FYI I tested the slow mode for a big chunck of hours and got a warning from MTGA for exceptional stalling
So I recommend not using it
Interesting, thanks for confirming that. I would imagine they’re basing the stalling detection on the continuous playing/unplaying of cards, or maybe the incessant clicking.
I believe it is more simply a timeout expired vs playing actual thing ratio
btw, there will be a GUI update soon with the launch of Alchemy, AFAIK might not change the place of strategic boxes. But good to know just in case š
also, I’m still stuck (I didnt invest much time) on the “click No block” part, I have 70% of the script working. If someone would be nice enough to help me on this, would be great
BTW, @default_root I changed the way of playing a card to a sort of drag and drop (works better in case of inaccurate card or land), works far better
and I forked the script by adding a label window with pygame to display visually the values for debugging, works great , I recommend !
Also thanks a lot for sharing the script
jek.services@outlook.com
If anyone wants to join efforts into making a valid version, you can contact me at the email above
Best regards
@default_root or anybody , by any chance could you provide me the missing coordinates/ranges I have for being able to detect the block ?
I have 70% done and can proove it by providing you my script š
I really would like to get this fully working and with twins at home, Im struggling to find time to spend grayscale and coord testing
would be super appreciated
merry Xmas to all !
If anyone is willing to sell me their working arena bot, capable of grinding dailies, please contact me at ***@gmail.com
Thank you.
I want to thank all of you- now I have all your IP addresses, I can permanently ban you all from MTG Arena permanently. See, I was hired to find this code, which I have (in completion) and in 45 days time- there will be a significant changes to the game denying anyone access via VPN or using any 3rd party scripts or injectors while playing MTG Arena.
Have A Nice Day!
Mark S. Richards
MTG Arena new assistant executive programmer
Great detective work Mark (or Markus?), thanks for dropping by! Let us know how you get on with that! š