Ben Dodson

Freelance iOS, macOS, Apple Watch, and Apple TV Developer

Side Project #1: Sealed

I am a huge advocate of side projects, small apps that let you test an idea in isolation usually for your own personal use. Over the course of my 14 year career as a software developer I’ve always tried to encourage new developers to work on side projects as a way of honing their craft. There are three reasons for this: firstly, building something for yourself is far more rewarding than building something for a client; secondly, it gives you an excuse to try out new technologies or methodologies that can then improve future client work without running the risk of derailing a major project1; and thirdly, it’s a great way of building up a portfolio if you’re starting out. I’ve always built bizarre little side projects and apps ranging from an iPad app to manage my wine collection to various PHP scripts that extract my time playing video games on Steam. Sometimes these side projects turn into full apps such as Music Library Tracker and Pocket Rocket but usually they are highly bespoke utilities for me that nobody else gets to see. Until now…

This year I’ve decided to start a new series of articles where I’ll show a side project I’ve built over the past month. Today’s article is all about “Sealed”, an iPad app I build in January 2020 to simulate the opening of Magic the Gathering booster packs.

I’m assuming that most people reading this article have little to no interest in Magic The Gathering and so I’m not going to explain that side of it in much detail. Suffice to say that the game consists of you opening blind packs containing 15 cards that you can play with. In sealed play, you open 6 of these blind packs (named “boosters”) and then build a 40 card deck out of the cards you opened. The idea for this app is that it will simulate this process allowing me and my good friend John (who lives in Sweden) to open 6 packs each and build a deck with the random contents within. We can then export them to the game Tabletop Simulator so we can play with them in a realistic 3D physics-based environment…

Tabletop Simulator even supports VR so we can simulate playing a few rounds in the same room even though we’re around 870 miles apart.

As with all of the side projects I’m going to be working on I’m not focused on perfect code or UI; it just needs to work. That said, I did spend a bit more time prettying this one up as I wasn’t the sole user.

A brief tour

The iPad app first does a brief download of data before opening on a selection screen that allows you to pick which expansion you want to play2. You can also choose to load one of your previously created decks.

Each physical booster pack has a specific breakdown of cards based on rarity usually comprising of 10 commons, 3 uncommons, 1 rare or mythic rare, and 1 basic land. The mythic rare is the tricky piece as there is a 1:8 chance it will replace the rare card. As I wanted things to be more “fair” in this app, I’ve fudged the numbers such that you’ll always get 5 rare cards and 1 mythic rare card; this avoids the issue (which could happen in a completely random system) of somebody ending up with far more mythic rares.

As these rare cards are usually the best of the bunch, these are shown immediately after the contents of the packs have been decided by the app:

Once you press continue, you are taken into the deck building interface with those 6 cards automatically added to your deck:

The bulk of the interface is dedicated to showing the cards you’ve opened with a number of diamonds above to show how many copies you have available; these fill in when the cards are added to the deck on the right hand side and will fade to 50% opacity if you’ve used every copy available. The top right hand section shows a “mana curve” (which is just a graph for showing the various costs of the cards in the game) along with a break down of the various types of card you’ve selected (as typically you want more creatures than anything else). Underneath is your deck in a scrollable list with a design mimicking the top of each card showing both the name and mana cost.

If you tap on an item in the deck list or long press on a card in the card picker then you’ll get a blown up version of the card which is easier to read. You’ll also be able to use the plus and minus buttons to add or remove the card from your deck (although if you only have one copy you can just tap to add them and remove them directly from the card picker as this is nearly always quicker).

The final two items are the sample hand and export; the former shows you a random draw of 7 cards from your deck to simulate the first action you take in the game3 whilst the latter generates the tiled image you’ll use to import the deck into Tabletop Simulator.

Reusing code

In total this app took around 6 hours to build mostly thanks to a huge amount of reusable code I could make use of from previous side projects.

I’m a big fan of Magic The Gathering and so last year I built myself a private app called “Gatherer” that gives me access to lots of information about each card thanks to the Scryfall API. For that app I wanted everything to work offline so I duplicated the data into my own hosted database and then made a single request when online to download all of the data and store it on the device in a Realm database. I used the exact same system here with the only new functionality being a new database table to list which expansions I wanted to be available within the app4. Within a few minutes of starting I had a local database and access to all of the card information I needed.

The next major piece of reused code was the design of the card cells in the deck creator. I wanted to mimic the headings of the cards in a similar way to Magic The Gathering Arena, an online version of the card game. The heading should show the title of the card (which will shrink as necessary to avoid word wrapping), the mana cost, and the border should be the colour of the card using a gradient if necessary:

Fortunately I had already built this exact design for another side project of mine, an iPad app to control the overlay for my Twitch stream:

Nearly everything in the above screenshot aside from the game and webcam is powered by an iPad app plugged into my PC capture card via HDMI. It was a fun experience to play with the external window APIs but it also allowed me to do animations to show off the deck list I’m currently playing with on MTG Arena; the sidebar scrolls every minute or so to reveal the full list and I can trigger certain animations from my iPad to show off a particular card in more detail. In any case, you’ll notice the deck list table cells on the right hand side are identical to the ones in this Sealed app. They are fairly straightforward with the most complex piece being some string replacement to convert a mana cost such as {1}{U}{U} into the icons you see.

The lesson here is that huge chunks of UI or functionality can be reused from side projects into your client projects saving you development time and speeding up your learning. As a practical example, I’m currently working on an app which requires a Tinder-style card swiping system; whilst I could embed an unknown 3rd party component which may have bugs and not be updated in future, I can instead use a card swiping system I built for a music-based side project a year ago. This saved me a significant amount of time and resulted in me being able to give a better price to my client than I might otherwise have been able to.

Random Aside

One of the areas I had a lot of fun with in this project was putting together the randomisation for opening packs. There are very specific rules when it comes to Magic packs with a set number of cards in specific rarity slots, no duplication unless there is a foil card5, and some weird edge cases for certain expansions. Here is the full code for this particular feature:

func sealTheDeal(set: String) -> Deck {
    guard let expansion = realm.object(ofType: Expansion.self, forPrimaryKey: set) else { abort() }
    let locations = expansion.locations.sorted(by: { Int($0.key) ?? 0 < Int($1.key) ?? 0})
    let max = Int(locations.first?.key ?? "0") ?? 0
    let cardsInSet = realm.objects(Card.self).filter("set = %@ and number <= %d", set, max)

    var mythics = cardsInSet.filter("rarity = %@", "mythic")
    var rares = cardsInSet.filter("rarity = %@", "rare")
    var uncommons = cardsInSet.filter("rarity = %@", "uncommon")
    var commons = cardsInSet.filter("rarity = %@ AND NOT (typeLine CONTAINS[c] %@)", "common", "basic land")
    
    switch set {
    case "dom":
        uncommons = uncommons.filter("NOT (typeLine CONTAINS[c] %@)", "legendary")
    case "grn", "rna":
        commons = commons.filter("NOT (name CONTAINS[c] %@)", "guildgate")
    case "war":
        mythics = mythics.filter("NOT (typeLine CONTAINS[c] %@)", "planeswalker")
        rares = rares.filter("NOT (typeLine CONTAINS[c] %@)", "planeswalker")
        uncommons = uncommons.filter("NOT (typeLine CONTAINS[c] %@)", "planeswalker")
    default:
        break
    }
    
    
    let mythicIndex = Int.random(in: 0..<6)
    
    var boosters = [[Card]]()
    for boosterIndex in 0..<6 {
        var cards = [Card]()
        var commonPool = Array(commons.map { $0 })
        for _ in 0..<10 {
            guard let card = commonPool.randomElement(), let index = commonPool.firstIndex(of: card) else { continue }
            cards.append(card)
            commonPool.remove(at: index)
        }
        
        var uncommonPool = Array(uncommons.map { $0 })
        for _ in 0..<3 {
            guard let card = uncommonPool.randomElement(), let index = uncommonPool.firstIndex(of: card) else { continue }
            cards.append(card)
            uncommonPool.remove(at: index)
        }
        
        let rareAndMythicRarePool = boosterIndex == mythicIndex ? mythics : rares
        if let card = rareAndMythicRarePool.randomElement() {
            cards.append(card)
        }
        
        switch set {
        case "dom":
            if let card = cardsInSet.filter("rarity = %@ AND (typeLine CONTAINS[c] %@)", "uncommon", "legendary").randomElement() {
                cards[cards.count - 2] = card
            }
        case "grn", "rna":
            if let card = cardsInSet.filter("name CONTAINS[c] %@", "guildgate").randomElement() {
                cards.append(card)
            }
        case "war":
            let uncommonChance = boosterIndex == mythicIndex ? 92 : 78
            if Int.random(in: 1...100) <= uncommonChance {
                if let card = cardsInSet.filter("rarity = %@ AND (typeLine CONTAINS[c] %@)", "uncommon", "planeswalker").randomElement() {
                    cards[cards.count - 2] = card
                }
            } else {
                if let card = cardsInSet.filter("rarity = %@ AND (typeLine CONTAINS[c] %@)", boosterIndex == mythicIndex ? "mythic" : "rare", "planeswalker").randomElement() {
                    cards[cards.count - 1] = card
                }
            }
        default:
            break
        }
        
        boosters.append(cards)
    }
    
    let boosterCards = boosters.flatMap({$0})
    let topCards = boosterCards.filter { return $0.rarity == "rare" || $0.rarity == "mythic" }
    
    let deck = Deck()
    deck.id = NSUUID().uuidString
    deck.expansion = expansion
    deck.topCards.append(objectsIn: topCards)
    
    let cards = boosterCards.sorted(by:{ $0.number < $1.number })
    var currentCard: Card?
    var quantity = 0
    for card in cards {
        if card != currentCard && currentCard != nil {
            let deckCard = DeckCard()
            deckCard.card = currentCard
            deckCard.quantityAvailable = quantity
            deck.allCards.append(deckCard)
            quantity = 0
        }
        
        currentCard = card
        quantity += 1
    }
    
    let deckCard = DeckCard()
    deckCard.card = currentCard
    deckCard.quantityAvailable = quantity
    deck.allCards.append(deckCard)
    
    deck.allCards.append(objectsIn: expansion.lands())
    
    let topIdentifiers = Array(deck.topCards.map({$0.id}))
    for index in 0..<deck.allCards.count {
        let card = deck.allCards[index]
        if topIdentifiers.contains(card.card?.id ?? "") {
            for _ in 0..<card.quantityAvailable {
                deck.add(card)
            }
        }
    }
    
    return deck
}

Essentially I take the following steps:

  • Build an array containing the cards at each rarity level
  • Remove any edge cases for specific expansions (i.e. removing Guildgates from Guilds of Ravnica)
  • For each booster pack, loop through each array randomly selecting a card and removing it from the pool to avoid duplication
  • For Dominaria (“dom”), change one of the uncommon cards to be a Legendary card
  • For Guilds of Ravnica (“grn”) and Ravnica Allegiance (“rna”) add a random Guildgate card.
  • For War of the Spark (“war”), replace one of the uncommon, rare, or mythic rare cards with a random Planeswalker card of the same rarity
  • Once the cards for each booster are known, group the 6 rare / mythic rare cards into their own array for UI simplicity and then bundle everything together in a nice object that groups any duplicates found across the packs

There is nothing particularly difficult about the above but it was still fun the first time I got it working to see my console filling up with cards as if I’d opened a physical pack!

Exporting

The “killer feature” of the app is the ability to export cards to Tabletop Simulator, a task that is surprisingly easy. To import custom cards, all you need to do is supply a 4096x3994px image that comprises of 10 columns and 7 rows. Here’s an example image of a 40 card deck that was exported from Sealed which uses the top 4 rows and leaves the remaining 3 blank (although it will use them if you build a deck larger than 40 cards although this isn’t usually recommended for sealed play).

In order to generate the large image I simply render UIImageViews onto a UIView that is the correct size, loop through each image and download it, and then use the snapshotting APIs to capture the view as a UIImage ready for exporting as a JPEG that usually weighs in at around 3MB. Here’s the full code:

import UIKit
import SDWebImage

class TabletopSimulatorDeck: UIView {
    
    static let cardWidth = 410
    static let cardHeight = 571
    static let maxColumns = 10
    
    var cards = [Card]()
    private var imageViews = [UIImageView]()
    private var downloadedImageCount = 0

    class func instanceFromNib() -> TabletopSimulatorDeck {
        return Bundle.main.loadNibNamed("TabletopSimulatorDeck", owner: nil, options: nil)?.first as! TabletopSimulatorDeck
    }
    
    static var fileURL: URL {
        return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("deck.jpg")
    }
    
    func export(onCompletion completionHandler: @escaping (Data?) -> Void) {
        downloadedImageCount = 0
        var row = 0
        var column = 0
        for _ in cards {
            let rect = CGRect(x: column * TabletopSimulatorDeck.cardWidth, y: row * TabletopSimulatorDeck.cardHeight, width: TabletopSimulatorDeck.cardWidth, height: TabletopSimulatorDeck.cardHeight)
            let imageView = UIImageView(frame: rect)
            imageViews.append(imageView)
            addSubview(imageView)
            
            column += 1
            if column == TabletopSimulatorDeck.maxColumns {
                column = 0
                row += 1
            }
        }
        
        for index in 0..<cards.count {
            let card = cards[index]
            let imageView = imageViews[index]
            imageView.sd_setImage(with: card.url(for: .card), placeholderImage: nil, options: .retryFailed) { (_, _, _, _) in
                self.downloadedImageCount += 1
                self.render(onCompletion: completionHandler)
            }
        }
    }
    
    func render(onCompletion completionHandler: @escaping (Data?) -> Void) {
        if downloadedImageCount != cards.count {
            return
        }
        
        DispatchQueue.main.async {
            UIGraphicsBeginImageContextWithOptions(self.bounds.size, true, 1.0)
            self.layer.render(in: UIGraphicsGetCurrentContext()!)
            let image = UIGraphicsGetImageFromCurrentImageContext()
            UIGraphicsEndImageContext()
            
            if let image = image, let data = image.jpegData(compressionQuality: 0.75) {
                try? data.write(to: TabletopSimulatorDeck.fileURL, options: .atomicWrite)
                completionHandler(data)
                return
            }
            
            completionHandler(nil)
        }
    }
}

It’s a dirty solution and it could possibly cause some memory issues on a really old iPad, but I don’t need to worry about that for this project where both devices that will use the app are more than capable of rendering all of this in milliseconds.

Once the image is generated, I use a standard UIActivityViewController to allow for simple sharing. One annoying gotcha that catches me every time is that the controller will provide a “save image” button that you can use to save to your photo library but the app will crash when this is pressed unless you’ve added a NSPhotoLibraryAddUsageDescription key to your info.plist. I’m not sure why Xcode can’t flag this in advance or why this requirement can’t be removed bearing in mind the user is making an informed action.

Conclusion

John and I have played three games of sealed using this app so far and I’m really pleased with how it’s turned out. I can build a deck in around 10 minutes whereas usually it would take 30-45 minutes using real packs. The exports work great in Tabletop Simulator and I can see us using this for a long time to come. I’ll likely add some extra functionality over time such as the ability to duplicate decks or updating the artwork to use the new showcase variants that are super rare but for now this app is definitely a success.

Whilst on the face of it this would be easy to publish to the App Store, the legal and moral implications prevent me from doing so. I’ve spent literally thousands of pounds on this game both in physical cards and digital ones on MTG Arena so I don’t have any qualms about using the artwork to play with a friend I otherwise wouldn’t be able to play with. That said, it’s very different doing something like this for your own private use than it is to publish it and enable it for others who may not have made the same investment in the real world product. For that reason it’s unlikely this will ever be available for wider consumption.

For February’s side project I’m working on something a bit different that will work as a form of learning for me; a standalone watchOS app built with SwiftUI! Be sure to check back next month to learn more about that project and to see how it ended up…

  1. “Oh that new framework looks good, I’ll try that in my next project” - Nope! I’ve learned the hard way that you do not want to use your clients as a guinea pig for the latest thing. By way of example, look at SwiftUI announced at WWDC 2019. You should not be using that in a client project, but it would be perfect in a side project. ↩︎

  2. There are around 3 expansions each year and you typically play sealed within one expansion i.e. you’ll get 6 packs of Guilds of Ravnica or 6 packs of Throne of Eldraine but you wouldn’t build a deck with 3 packs from each. This is all due to the careful balancing the games creators do to ensure that things stay relatively fair within these sealed games. ↩︎

  3. This is an important tool as it allows you to very quickly perform a few draws to see if the cards you are getting are balanced, especially when it comes to mana costs, lands, and colours. ↩︎

  4. My database is updated every morning in order to get the latest pricing information but that means I often get partial expansions if a new one is in the middle of being unveiled. This can last a few weeks so I needed the ability to hide certain expansions until they were ready for playing. ↩︎

  5. Although I made things easier by ignoring foil cards. Usually they replace a common card to give you a random card from the set with a shiny foil treatment but again this can lead to an imbalance as my opponent might end up with 3 mythic rare cards if they get lucky with the randomiser. ↩︎

« Revival