Link to app: https://darren-keeley.shinyapps.io/mana_sim/
Keywords: Magic the Gathering, MTG, Simulation, Mana
Abstract: This paper presents a function that accurately simulates the probability of not missing any land drops within the first five turns for any unique deck. Analysis using the function found that the ability to mulligan and drawing an extra card help tremendously with getting consistent land drops. Additionally, the function can be used to get efficient ratios of land colors for multi-colored decks.
1. Introduction. Magic the Gathering (MTG) is a collectible card game where two players each have their own personal deck and attempt to defeat the other as if they are dueling wizards with their tomes of spells. In each deck, there are two types of cards: lands and spells. Lands produce mana, a necessary resource to cast any spells. Therefore, it is important to have the right balance of lands and spells. Too few lands, and a player is sitting on a wealth of spells with no means to cast them. Too many lands, and the player can only wait. This simulation aims to find the optimal number of lands based on how much mana a player wants. It seeks to replicate the findings in an article from Channel Fireball, as well as provide some unique insights of its own [1].
The following conditions will also be simulated: mulligans, drawing an additional card and having sufficient lands of a particular color.
2. Game Rules and Land Drops. The rules are as follows: each player draws 7 cards at the beginning of the game, and then draws 1 card per turn from the second turn onward. A player may only play one land a turn, providing one mana for the rest of the game. This action is called a Land Drop. If a player doesn’t have any lands in their hand and their turn ends, they miss their Land Drop. We will simulate this probability based on the composition of their deck.
3. Making a Deck. To generate a deck, we will choose a number of lands and then fill in the rest of the deck with arbitrary spells “x”. Below is an example of the lands in a deck. The number indicates the count, and the letter indicates the color. The color is unimportant now and will be revisited later. All that matters is the total count.
lands
<- c("10 R
10 G
4 RG")
In this deck, we have 24 lands total: 10 red, 10 green, and 4 that can produce either a red or green mana but not both at the same time. The next function takes in the character vector above and outputs a 60 card deck (a vector of 60 elements). Decks are almost always either 60 cards or 40, depending on the format being played.
# Read
the string with regular expressions and fill in the deck with spells (x's) to
make 60 cards.
library(stringr)
lands_to_deck <- function(x, size=60) {
x <- x %>% str_replace_all("\n", "
") %>% #
replace newlines with spaces
str_replace_all("\\s+", "
") %>% #
shorten any long whitespaces
str_split(" ") # divide
the string into elements
x <- x[[1]] #
convert from list to vector
# x == c("10", "R", "10",
"G", "4", "RG")
mana <- c()
for (i in seq(1, length(x),
by=2)) {
mana <- c(mana, rep(x[i+1], x[i])) # convert the counts to repetitions
}
deck <- c(mana, rep("x", size - length(mana))) # fill in deck with spelkls until 60
cards
deck
}
deck
<- lands_to_deck(lands)
deck
## [1] "R" "R"
"R" "R" "R"
"R" "R" "R"
"R" "R" "G"
"G" "G" "G"
## [15] "G" "G"
"G" "G" "G"
"G" "RG"
"RG" "RG" "RG" "x" "x"
"x" "x"
## [29] "x" "x"
"x" "x" "x"
"x" "x" "x"
"x" "x" "x"
"x" "x" "x"
## [43] "x" "x"
"x" "x" "x"
"x" "x" "x"
"x" "x" "x"
"x" "x" "x"
## [57] "x" "x"
"x" "x"
4. Simulating Land Drops for the First 5 Turns. The simulation will measure if no land drops are missed by turns 1 through 5. We will define “not missing a land drop” as having drawn at least a number of lands greater than or equal to the current turn number. So on turn 1, we hope to have at least 1 land in hand, on turn 2 at least 2, etc. To simulate this probability, we use a function rlanddrop_simple(). It starts by generating the first 5 hands of the player. Explicitly, we start with 7 cards, then have 8, then 9, etc. Next, a logical vector is calculated for whether no land drops were missed by each turn. As such, if a land drop is missed on turn 4, the resulting logical vector would be c(1, 1, 1, 0, 0). This is simulated m times, and then these logical vectors are aggregated to give the simulated probabilities.
The function:
library(data.table)
rlanddrop_simple <- function(deck, m=1000) {
size <- length(deck)
mana_df <- data.frame(t1=numeric(), t2=numeric(), t3=numeric(), t4=numeric(), t5=numeric())
# Each t will harbor the binary variable, with 1 representing all
land drops made and 0 being at least 1 missed.
# The dataframe "mana_df"
will have the means of its columns calculated to get the probabilities.
### The simulation begins.
for (r in 1:m){
# Get hands for each turn and store as list "hands"
deck <- deck[sample(1:size)] # shuffle deck
hands <- list(deck[1:7]) # first hand added to list
# Draw cards for turns i+1
starting_hand_size
<- length(hands[[1]])
for (i in 1:4){
hands <- c(hands, list(deck[1:(starting_hand_size+i)]))
}
# Each row represents a simulation
mana_row <- c()
# For each turn, count how many non-spells there are and see if
that number is >= turn number.
for (i in 1:5){
mana_row <- cbind(mana_row, sum(!str_detect(hands[[i]], "x")) >= i)
}
# Add row to dataframe
mana_df[r,] <- mana_row
}
### Aggregate the columns of the dataframe to get probabilities
mana_df <- data.table(mana_df)
mana_df <- mana_df[, j = list(t1=mean(t1), t2=mean(t2), t3=mean(t3), t4=mean(t4), t5=mean(t5))]
returned_list <- list(colorless = mana_df)
returned_list
}
Using the function for our deck:
set.seed(1)
rlanddrop_simple(deck, 10000)$colorless
## t1 t2
t3 t4 t5
##
1: 0.9772 0.9088 0.7901 0.6302 0.4637
By our calculations in the output immediately above, there’s a 97.7% chance of drawing an opening hand (7 cards) containing at least one land. And by turn 3 (9 cards), there’s a 79.0% chance that we’ve drawn 3 or more lands. Turn 5 is rather dismal at 46.4%, and if one wants to consistently have 5 mana by turn 5, more than 24 lands would be advisable.
5. The Optimal Land Count. Whether a player cares about missing their 4th or 5th+ land drop is dependent on the spells they’ve chosen for their deck. If their deck contains mostly 1, 2 and 3 mana spells, a consistent turn 4 land drop would be less important than additional spells. Conversely, a deck with expensive spells cannot afford to fall behind and leave themselves without sufficient mana. Let’s say the player has a mid-game deck, and desires to hit every land drop by turn 4 at least 80% of the time. How many lands should they include?
set.seed(1)
# Vector of numbers of lands
to be searched
lands_in_deck <- seq(20, 30)
# Empty df
landdrops_df <- data.frame(lands = character(), t1=numeric(), t2=numeric(), t3=numeric(), t4=numeric(), t5=numeric())
# Loop through possible land
counts and store as dataframe
# The "deck"
parameter will be generated for each number of lands to be searched. “U”
represents blue mana.
for (i in lands_in_deck){
out <- rlanddrop_simple(deck=c(rep("U", i), rep("x", 60-i)), m=10000)[[1]]
landdrops_df <- rbind(landdrops_df, c(lands = i, out))
}
cbind(landdrops_df$lands,
landdrops_df$t4)
## [,1] [,2]
## [1,]
20 0.4342
##
[2,]
21 0.4865
## [3,]
22 0.5395
## [4,]
23 0.5872
## [5,]
24 0.6303
## [6,]
25 0.6766
## [7,]
26 0.7051
## [8,]
27 0.7657
## [9,]
28 0.7863
##
[10,] 29 0.8289
##
[11,] 30 0.8533
library(ggplot2)
ggplot(landdrops_df, aes(x=lands, y=t4)) +
geom_line() +
geom_abline(slope=0, intercept=.8, color="green") +
scale_x_continuous(breaks = pretty(landdrops_df$lands)) +
ggtitle("Probability of hitting turn 4 land
drop")
Fig. 1: Green line
shows 80% probability of not missing first 4 land drops.
Looks like the sweet spot is 29 lands, yielding 82.7%.
6. Mulligan. A player would be crestfallen to see an opening hand containing one land. The probability of missing early (and crucial) land drops would be quite high, resulting in a lopsided game that neither wizard enjoys. To tip the odds away from such a fate, a player may mulligan their opening hand as so: they reshuffle their hand back into their deck and draw a new hand with one less card. A player may mulligan as many times as they see fit, even theoretically to 0 cards.
The Channel Fireball article offers these guidelines for when to mulligan:
• You mulligan any 7-card hand with 0, 1, 6, or 7 lands.
• You mulligan any 6-card hand with 0, 1, 5, or 6 lands.
• You mulligan any 5-card hand with 0 or 5 lands.
• You keep any 4-card hand.
• After a mulligan, you always scry a land to the top and a spell to the bottom.
The final rule mentions “scry.” This means to look at the top card of your deck and decide whether to put it at the bottom or keep it on top. The simulation will follow these guidelines exactly from hereon out using the function rlanddrops(). This function is the same as rlandrops_simple() with the addition of mulligans. It also contains two other functionalities that will be covered in Sections 8 and 9. The code for this function is much longer (over 100 lines), so it is included in the Appendix.
Below is a snippet from the function that implements mulligans:
###
Mulligan function, nested within rlandrops().
num_lands <- sum(!str_detect(hands[[1]], "x")) #
Look at how many lands are in the
first hand and then mulligan if needed.
if (num_lands < 2 | num_lands > 5) {
deck <- deck[sample(1:size)]
hands <- list(deck[1:6])
num_lands <- sum(!str_detect(hands[[1]], "x"))
if (num_lands < 2 | num_lands > 4){
deck <- deck[sample(1:size)]
hands <- list(deck[1:5])
num_lands <- sum(!str_detect(hands[[1]], "x"))
if (num_lands == 0 | num_lands == 5){
deck <- deck[sample(1:size)]
hands <- list(deck[1:4]) # Keep any 4 card hand
}
}
# Because mulligan once or more, scry a land to top and spell to
bottom.
scry_card <- deck[length(hands[[1]])+1] # Look at next card
if (scry_card ==
"x") { # If spell, pop it off and append it to end of
deck
deck[size+1] <- scry_card
deck <- deck[-(length(hands[[1]])+1)]
}
}
Mulligans have a stabilizing effect on how many lands a player draws. Will the ability to mulligan allow the player to run fewer lands and more spells? Running the same simulation before but with mulligans yields the following:
set.seed(1)
lands_in_deck
<- seq(20, 30)
landdrops_df
<- data.frame(lands = character(), t1=numeric(), t2=numeric(), t3=numeric(), t4=numeric(), t5=numeric())
for (i in
lands_in_deck){
out <- rlanddrop(deck=c(rep("U", i), rep("x", 60-i)), m=10000)[[1]]
landdrops_df <- rbind(landdrops_df,
c(lands = i,
out))
}
ggplot(landdrops_df, aes(x=lands, y=t4)) +
geom_line() +
geom_abline(slope=0, intercept=.8, color="purple") +
scale_x_continuous(breaks = pretty(landdrops_df$lands)) +
ggtitle("Probability of hitting turn 4 land drop
with mulligans")
Fig. 2: Purple line
shows 80% probability of not missing any land drops up to turn 4 while being able to mulligan opening hands.
The player can run 27 lands and afford 2 more spells while having an 80.6% chance of hitting their first 4 land drops.
7. Sanity Check. To show the simulation is behaving correctly, we’ll compare it to the calculated probabilities from the Channel Fireball article. Their table displays probabilities of turns 2 through 5, for land counts of 17 to 28. Their probabilities for land drops are the second number in each column, after the slash (the first number before the slash is when an additional card is drawn). It turns out the simulation matches their chart to at least to two digits, and often to 3.
lands_in_deck <- seq(17, 28)
fireball_results <- data.frame()
for (i in lands_in_deck){
out <- rlanddrop(deck=c(rep("U", i), rep("x", 60-i)), m=10000)[[1]]
fireball_results <- rbind(fireball_results,
c(lands = i,
out))
}
fireball_results[,
-2]
## lands t2
t3 t4 t5
##
1 17 0.9567 0.6969 0.4051 0.2058
##
2 18 0.9682 0.7388 0.4631 0.2499
##
3 19 0.9746 0.7675 0.5065 0.2938
##
4 20 0.9833 0.7928 0.5535 0.3452
##
5 21 0.9890 0.8291 0.6027 0.3860
##
6 22 0.9920 0.8442 0.6387 0.4261
##
7 23 0.9940 0.8684 0.6810 0.4757
##
8 24 0.9960 0.8892 0.7189 0.5219
##
9 25 0.9967 0.9020 0.7400 0.5670
##
10 26 0.9983 0.9143 0.7790 0.6148
##
11 27 0.9978 0.9298 0.8070 0.6514
##
12 28 0.9988 0.9473 0.8390 0.7033
Fig. 3: Calculated
table of probabilities from Channel Fireball article. Compare the numbers to
the right of the slashes with the simulated probabilities immediately above
this table.
8. Drawing an Additional Card. Starting one’s first turn before their opponent’s is a powerful advantage. To balance this, the player going second draws a card on their first turn, after mulliganing if they needed to. Our player’s deck is not the fastest, given how important their turn 4 land drop is. Perhaps they choose to go second, or they include a spell that allows them to draw another card. If they could reliably draw an additional card (or some similar effect), they may be able to cut even more lands from their deck.
The next script reveals a new parameter, “draw_cards.” Its default is 0, and it simulates drawing cards on the first turn. Here, the parameter is set to 1, so 1 extra card will be drawn.
set.seed(1)
lands_in_deck
<- seq(20, 30)
landdrops_df
<- data.frame(lands = character(), t1=numeric(), t2=numeric(), t3=numeric(), t4=numeric(), t5=numeric())
for (i in
lands_in_deck){
out <- rlanddrop(deck=c(rep("U", i), rep("x", 60-i)), m=10000, draw_cards
= 1)[[1]]
landdrops_df <- rbind(landdrops_df,
c(lands = i,
out))
}
cbind(landdrops_df$lands,
landdrops_df$t4)
## [,1] [,2]
## [1,]
20 0.6566
## [2,]
21 0.7039
## [3,]
22 0.7473
## [4,]
23 0.7757
## [5,]
24 0.8062
## [6,]
25 0.8319
## [7,]
26 0.8620
## [8,]
27 0.8876
## [9,]
28 0.9037
##
[10,] 29 0.9168
##
[11,] 30 0.9342
ggplot(landdrops_df, aes(x=lands, y=t4)) +
geom_line() +
geom_abline(slope=0, intercept=.8, color="blue") +
scale_x_continuous(breaks = pretty(landdrops_df$lands)) +
ggtitle("Probability of hitting turn 4 land drop
with mulligans and drawing 1 more card")
Fig. 4: Blue line
indicates 80% probability of not missing first 4 land drops with ability to
mulligan and after drawing a card on the first turn.
Drawing that additional card helped our player a lot. They are able to drop down to 24 lands while maintaining an 80.6% probability of not missing the first 4 land drops. 24 lands is actually the most common number of lands in 60 card decks.
9. Multi-colored Decks. The final analysis done in this project will be determining the optimal combination of different colors of lands. The player’s deck contains both green and red mana, for a total of 24 lands. Let’s say the player has several red spells that cost 2 red mana, and it’s very important for them to be able to cast them on turn 2. They want an 80% chance of 2 red mana by turn 2, while holding on to as many green lands as they can. What proportion of lands should be red, and what green?
This last script introduces the final parameter in rlanddrop(), “colors.” If colors is set to True, then the function will calculate the land drop probabilities for each color of land in the deck. The code to do this is a cascade of if statements, which makes rlanddrop() very lengthy. As such, the code is saved for the Appendix.
set.seed(1)
#
Our player’s deck will have 4 non-basic lands producing either color, and 20
basic lands. It is the proportion of
basic lands that are red that will be searched.
lands_partial
<- c("RG", "RG", "RG", "RG") # More
lands will be added
#
Mountains produce red mana
#
Searching 50-80% of basic lands in deck being mountains.
mountains_in_deck
<- seq(10, 16)
#
Define dataframes
red_df <- data.frame(mountains
= character(), t1=numeric(), t2=numeric(), t3=numeric(), t4=numeric(), t5=numeric())
green_df
<- data.frame(mountains = character(), t1=numeric(), t2=numeric(), t3=numeric(), t4=numeric(), t5=numeric())
for (i in
mountains_in_deck){
rg_deck <- c(lands_partial,
rep("R", i), rep("G", 20 - i), rep("x", 36))
out <- rlanddrop(deck=rg_deck, m=10000, colors=T)
red_df <- rbind(red_df,
c(mountains
=
i, out$red))
green_df <- rbind(green_df,
c(mountains
=
i, out$green))
}
cbind(red_df$mountains, red_df$t2,
green_df$t2)
## [,1]
[,2] [,3]
##
[1,] 10 0.6757 0.6729
##
[2,] 11 0.7218 0.6225
##
[3,] 12 0.7613 0.5708
##
[4,] 13 0.8168 0.5090
##
[5,] 14 0.8483 0.4456
##
[6,] 15 0.8853 0.3829
##
[7,] 16 0.9110 0.3210
ggplot() +
geom_line(mapping=aes(x=red_df$mountains, y=red_df$t2), color="red") +
geom_line(mapping=aes(x=green_df$mountains, y=green_df$t2), color="green") +
geom_abline(slope=0, intercept=.8, color="purple") +
scale_x_continuous(breaks = pretty(red_df$mountains))
+
ggtitle("Probability of 2 mountains by turn
2")
Fig. 5: The player will want 13 mountains in their deck, leaving 7 as
forests.
10. Conclusion. Through simulation, the optimal number of lands and combination of lands can be determined. The performance metrics are up to the player and the spells in their deck.
Bibliography
[1] Karsten, Frank. “How Many Lands Do You Need to Consistently Hit Your Land Drops?” Channel Fireball, 30 May 2017, www.channelfireball.com/articles/how-many-lands-do-you-need-to-consistently-hit-your-land-drops/.
Appendix
Below is rlanddrop(). It’s core functionality is identical to rlanddrop_simple(), however the inclusion of counting specific colors of lands necessitated lots of “if statements” so that the runtime wasn’t always sluggish.
rlanddrop <- function(deck, m=1000, colors=F, mul=T, draw_cards=0) {
size <- length(deck)
mana_df <- data.frame(t1=numeric(), t2=numeric(), t3=numeric(), t4=numeric(), t5=numeric())
### For each color, check if it's in the
deck.
### If not, then has_color will be F and then
the function will ignore making a df for that color.
if (colors==T){
has_white <- F;
has_blue <- F; has_black <- F; has_red <- F;
has_green <- F;
if (sum(str_detect(deck, "W")) > 0){
white_df <- data.frame(t1=numeric(), t2=numeric(), t3=numeric(), t4=numeric(), t5=numeric())
has_white <- T
}
if (sum(str_detect(deck, "U")) > 0){
blue_df <- data.frame(t1=numeric(), t2=numeric(), t3=numeric(), t4=numeric(), t5=numeric())
has_blue <- T
}
if (sum(str_detect(deck, "B")) > 0){
black_df <- data.frame(t1=numeric(), t2=numeric(), t3=numeric(), t4=numeric(), t5=numeric())
has_black <- T
}
if (sum(str_detect(deck, "R")) > 0){
red_df <- data.frame(t1=numeric(), t2=numeric(), t3=numeric(), t4=numeric(), t5=numeric())
has_red <- T
}
if (sum(str_detect(deck, "G")) > 0){
green_df <- data.frame(t1=numeric(), t2=numeric(), t3=numeric(), t4=numeric(), t5=numeric())
has_green <- T
}
}
### The simulation begins.
for (r in 1:m){
# Get list of hands by turn
deck <- deck[sample(1:size)]
hands <- list(deck[1:7])
### Mulligan function
if (mul==T){
num_lands <- sum(!str_detect(hands[[1]], "x"))
if (num_lands < 2 | num_lands
> 5) {
deck <- deck[sample(1:size)]
hands <- list(deck[1:6])
num_lands <- sum(!str_detect(hands[[1]], "x"))
if
(num_lands < 2 | num_lands > 4){
deck <- deck[sample(1:size)]
hands <- list(deck[1:5])
num_lands <- sum(!str_detect(hands[[1]], "x"))
if
(num_lands == 0 | num_lands == 5){
deck <- deck[sample(1:size)]
hands <- list(deck[1:4])
}
}
# Because mulligan once or more, scry a land to
top and spell to bottom.
scry_card <- deck[length(hands[[1]])+1]
if
(scry_card == "x") {
deck[size+1] <- scry_card
deck <- deck[-(length(hands[[1]])+1)]
}
}
}
### End Mulligan
### Drawing one or more cards on first turn
if (draw_cards > 0){
hands <- list(deck[1:(length(hands[[1]]) + draw_cards)])
}
### End draw additional cards
### Get hand for first 5 turns, drawing 1
card per turn.
starting_hand_size <- length(hands[[1]])
for (i in 1:4){
hands <- c(hands, list(deck[1:(starting_hand_size+i)]))
}
### Use hands to get probabilities for
colorless land drops
# Calculate whether a land drop was missed by
that turn for each turn.
# mana_df will be aggregated and returned as
output.
mana_row <- c()
for (i in 1:5){
mana_row <- cbind(mana_row,
sum(!str_detect(hands[[i]],
"x")) >= i)
}
mana_df[r,] <- mana_row
### Use hands to get probabilities for
colorful land drops
if (colors==T){
if (has_white==T){
color_row <- c()
for (i in 1:5){
color_row <- cbind(color_row,
sum(str_detect(hands[[i]],
"W")) >= i)
}
white_df[r,] <- color_row
}
if (has_blue==T){
color_row <- c()
for (i in 1:5){
color_row <- cbind(color_row,
sum(str_detect(hands[[i]],
"U")) >= i)
}
blue_df[r,] <- color_row
}
if (has_black==T){
color_row <- c()
for (i in 1:5){
color_row <- cbind(color_row,
sum(str_detect(hands[[i]],
"B")) >= i)
}
black_df[r,] <- color_row
}
if (has_red==T){
color_row <- c()
for (i in 1:5){
color_row <- cbind(color_row,
sum(str_detect(hands[[i]],
"R")) >= i)
}
red_df[r,] <- color_row
}
if (has_green==T){
color_row <- c()
for (i in 1:5){
color_row <- cbind(color_row,
sum(str_detect(hands[[i]],
"G")) >= i)
}
green_df[r,] <- color_row
}
}
}
### Aggregate finished hands for colorless
mana
mana_df <- data.table(mana_df)
mana_df <- mana_df[,
j = list(t1=mean(t1), t2=mean(t2), t3=mean(t3), t4=mean(t4), t5=mean(t5))]
returned_list <- list(colorless
=
mana_df)
### Aggregate finished hands for colorful
mana
if (colors==T){
if (has_white==T) {
white_df <- data.table(white_df)
white_df <- white_df[,
j = list(t1=mean(t1), t2=mean(t2), t3=mean(t3), t4=mean(t4), t5=mean(t5))]
returned_list <- c(returned_list,
list(white =
white_df))
}
if (has_blue==T) {
blue_df <- data.table(blue_df)
blue_df <- blue_df[,
j = list(t1=mean(t1), t2=mean(t2), t3=mean(t3), t4=mean(t4), t5=mean(t5))]
returned_list <- c(returned_list,
list(blue =
blue_df))
}
if (has_black==T) {
black_df <- data.table(black_df)
black_df <- black_df[,
j = list(t1=mean(t1), t2=mean(t2), t3=mean(t3), t4=mean(t4), t5=mean(t5))]
returned_list <- c(returned_list,
list(black =
black_df))
}
if (has_red==T) {
red_df <- data.table(red_df)
red_df <- red_df[,
j = list(t1=mean(t1), t2=mean(t2), t3=mean(t3), t4=mean(t4), t5=mean(t5))]
returned_list <- c(returned_list,
list(red =
red_df))
}
if (has_green==T) {
green_df <- data.table(green_df)
green_df <- green_df[,
j = list(t1=mean(t1), t2=mean(t2), t3=mean(t3), t4=mean(t4), t5=mean(t5))]
returned_list <- c(returned_list,
list(green =
green_df))
}
}
returned_list
}




