Skip to main content
  1. Writing/

Medals In OpenSpartan

This blog post is part of a series on exploring the Halo game API.

As folks might know, in my free time I am building a Halo Infinite companion app called OpenSpartan. The next part (out of many to be built) of the OpenSpartan experience is more or less ready for testing, and this time it’s related to medals - the little awards players can earn inside Halo Infinite if they accomplish some kind of in-game feat, like a multikill, tagging an enemy and then headshotting them moments after, or doing something stupid like trying to grapple a weapon while facing an enemy around the corner. Don’t ask me how I know there is a medal for this.

“Deadly Catch” medal in Halo Infinite

To be honest, I actually thought that I stole the shock rifle from the fella’s hand, but I don’t think that’s a real mechanic in this game. One can dream. Anyway, let’s talk medals.

A short intro to medals #

Looking at medal stats is fun. All of them are captured as part of the player service record, and can be obtained by looking at the core stats for a given account. Think of core stats as match-agnostic data - they represent the overall performance, and include things like accuracy, kills-deaths-assists (KDA) ratio, number of betrayals, and so on.

Among all these things, the API that handles service record data does return a JSON blob that contains a Medals property, that looks something like this (not an exhaustive list - there are a lot of medals):

"Medals":
[
  {
      "NameId": 3233952928,
      "Count": 931,
      "TotalPersonalScoreAwarded": 0
  },
  {
      "NameId": 3091261182,
      "Count": 236,
      "TotalPersonalScoreAwarded": 0
  },
  {
      "NameId": 2852571933,
      "Count": 154,
      "TotalPersonalScoreAwarded": 0
  },
  {
      "NameId": 2123530881,
      "Count": 1433,
      "TotalPersonalScoreAwarded": 0
  },
  {
      "NameId": 2625820422,
      "Count": 701,
      "TotalPersonalScoreAwarded": 0
  }
]

As you can see from the JSON blob above, this is a very barebones representation of medal information - there is no medal metadata that allows us to discern what medal does what. NameId is the unique medal ID, Count is the count of medals earned over the specific service record snapshot period (e.g., overall or season-based), and TotalPersonalScoreAwarded is the number of experience points earned for the medal. At this time, the game doesn’t actually award experience for medals.

So, from the description above, it seems like we’d need another API to get medal metadata. Luckily, there exists a metadata endpoint that contains the full medal mapping. The JSON content from said endpoint looks like this (once again - there are more medals, I just don’t want to have 17 pages reserved for a JSON snippet):

{
    "difficulties": [
        "normal",
        "heroic",
        "legendary",
        "mythic"
    ],
    "types": [
        "spree",
        "mode",
        "multikill",
        "proficiency",
        "skill",
        "style"
    ],
    "sprites": {
        "small": {
            "path": "medals/images/medal_sheet_sm.png",
            "columns": 16,
            "size": 72
        },
        "medium": {
            "path": "medals/images/medal_sheet_med.png",
            "columns": 16,
            "size": 128
        },
        "extra-large": {
            "path": "medals/images/medal_sheet_xl.png",
            "columns": 16,
            "size": 256
        }
    },
    "medals": [
        {
            "name": {
                "value": "Double Kill",
                "translations": {
                    "pt-BR": "Abate Duplo",
                    "zh-CN": "双连击",
                    "zh-TW": "雙殺",
                    "de-DE": "Doppelter Abschuss",
                    "fr-FR": "Double frag",
                    "it-IT": "Doppia uccisione",
                    "ja-JP": "ダブル キル",
                    "ko-KR": "두 명 잡음",
                    "es-MX": "Doble muerte",
                    "nl-NL": "Dubbele kill",
                    "pl-PL": "Podwójne zabójstwo",
                    "ru-RU": "Двойное убийство",
                    "es-ES": "Doble muerte"
                }
            },
            "description": {
                "value": "Kill 2 enemies in quick succession",
                "translations": {
                    "pt-BR": "Mate 2 inimigos em sequência",
                    "zh-CN": "快速连续击杀 2 名敌人",
                    "zh-TW": "快速連續擊殺 2 個敵人",
                    "de-DE": "2 Gegner schnell hintereinander eliminieren",
                    "fr-FR": "Tuez 2 ennemis d'affilée.",
                    "it-IT": "Uccidi 2 nemici in rapida successione",
                    "ja-JP": "連続で 2 体の敵を倒す",
                    "ko-KR": "빠르게 연속해서 2명의 적을 처치하십시오",
                    "es-MX": "Abate 2 enemigos en sucesión rápida",
                    "nl-NL": "Dood snel achter elkaar 2 vijanden",
                    "pl-PL": "Zabij 2 przeciwników w krótkich odstępach czasu.",
                    "ru-RU": "Быстро убейте 2 врагов.",
                    "es-ES": "Mata a 2 enemigos en una rápida sucesión."
                }
            },
            "spriteIndex": 64,
            "sortingWeight": 100,
            "difficultyIndex": 1,
            "typeIndex": 2,
            "personalScore": 50,
            "nameId": 622331684
        },
        {
            "name": {
                "value": "Triple Kill",
                "translations": {
                    "pt-BR": "Abate Triplo",
                    "zh-CN": "三连击",
                    "zh-TW": "三殺",
                    "de-DE": "Dreifacher Abschuss",
                    "fr-FR": "Triple frag",
                    "it-IT": "Tripla uccisione",
                    "ja-JP": "トリプル キル",
                    "ko-KR": "세 명 잡음",
                    "es-MX": "Triple muerte",
                    "nl-NL": "Driedubbele kill",
                    "pl-PL": "Potrójne zabójstwo",
                    "ru-RU": "Тройное убийство",
                    "es-ES": "Triple muerte"
                }
            },
            "description": {
                "value": "Kill 3 enemies in quick succession",
                "translations": {
                    "pt-BR": "Mate 3 inimigos em sequência",
                    "zh-CN": "快速连续击杀 3 名敌人",
                    "zh-TW": "快速連續擊殺 3 個敵人",
                    "de-DE": "3 Gegner schnell hintereinander eliminieren",
                    "fr-FR": "Tuez 3 ennemis d'affilée.",
                    "it-IT": "Uccidi 3 nemici in rapida successione",
                    "ja-JP": "連続で 3 体の敵を倒す",
                    "ko-KR": "빠르게 연속해서 3명의 적을 처치하십시오",
                    "es-MX": "Abate 3 enemigos en sucesión rápida",
                    "nl-NL": "Dood snel achter elkaar 3 vijanden",
                    "pl-PL": "Zabij 3 przeciwników w krótkich odstępach czasu.",
                    "ru-RU": "Быстро убейте 3 врагов.",
                    "es-ES": "Mata a 3 enemigos en una rápida sucesión."
                }
            },
            "spriteIndex": 65,
            "sortingWeight": 150,
            "difficultyIndex": 2,
            "typeIndex": 2,
            "personalScore": 100,
            "nameId": 2063152177
        }
    ]
}

Remember how I said that medals don’t actually reward the player with experience points? As it turns out, the metadata endpoint thinks they do - every medal has a score assigned to it in the form of personalScore, but that doesn’t seem to be used anywhere.

In this list of things associated medals, pay close attention to nameId, a property we saw earlier. Given its availability in both the service record and metadata endpoints, we can “join” the data sources to get a comprehensive medal picture.

We’ll get into the details of each medal in a second, but first - let’s take a look at the sprites property in the metadata response. In the Halo Infinite API surface, medals are not represented by individual image assets but are instead provided as part of a spritesheet. In a spritesheet, every image is captured in a grid. To obtain a specific image from it it’s possible to write code that calculates the position of the image, as long as you know the image index and the size of each “sprite.”

For Halo Infinite, this grid looks like this, returned from the Halo Waypoint CDN:

Medal sheet for Halo Infinite

Notice the copious amount of white space - it’s likely intentional to accommodate for any future medals. Using a spritesheet for medals is not new to the game, by the way. In the Halo 5 days, the same approach was taken for showing medals in the web experience by having a very similar format for all medals, albeit with less chrome-like shine:

Medal sheet for Halo 5

Fun fact - the Halo Infinite spritesheet does not contain medals for Infection, the new game mode released during Season 4. You can see the medal counts in the game and you can even see the medal IDs reflected in the service record JSON blob, however they don't have matching entries in the medal sheet or the medal metadata.

That's the reason why you don't see them on Halo Waypoint.

For Halo Infinite, the spritesheet grid is 16 by 16 pixels (as defined by the columns property), and available in three formats: small (72x72 pixels), large (128x128 pixels), and extra large (256x256 pixels). So, for OpenSpartan to be able to present medals I need to be able to “slice” the spritesheet into its respective parts. Let’s take a look at what that looks like in practice.

Writing the code #

Now, let’s say that I already had the code to acquire the service record and store it locally - I mentioned in a previous blog post that I use SQLite for storage and analysis. Because there are regular service record snapshots, I can query the data directly from the database file to get the latest state of player-acquired medals. When the application launches I take a snapshot and then pull from the list of historical snapshots locally when necessary.

Medals stored in a local SQLite database

This is nice because not only to I get to track personal progression but I also get to track the evolution of medal counts which is not something the Halo Infinite API offers. For example, I can easily see what medals are the fastest growing and what the potential trajectory would be (say, if you were really keen about those Ninja medals). But that’s diving a bit into the data storage and processing. What about the medal spritesheet?

To get individual medals, every time the application starts I am re-downloading the spritesheet (in case there are updates, like new medals) and splitting it up with the help of SkiaSharp. The specific image extraction snippet looks like this (where spriteContent is a byte array representing the medal spritesheet):

using MemoryStream ms = new(spriteContent);
SkiaSharp.SKBitmap bmp = SkiaSharp.SKBitmap.Decode(ms);
using var pixmap = bmp.PeekPixels();

foreach (var medal in compoundMedals)
{
    string medalImagePath = Path.Combine(qualifiedMedalPath, $"{medal.NameId}.png");
    if (!System.IO.File.Exists(medalImagePath))
    {
        FileInfo file = new FileInfo(medalImagePath);
        file.Directory.Create();

        // The spritesheet for medals is 16x16, so we want to make sure that we extract the right medals.
        var row = (int)Math.Floor(medal.SpriteIndex / 16.0);
        var column = (int)(medal.SpriteIndex % 16.0);

        SkiaSharp.SKRectI rectI = SkiaSharp.SKRectI.Create(column * 256, row * 256, 256, 256);

        var subset = pixmap.ExtractSubset(rectI);
        using (var data = subset.Encode(SkiaSharp.SKPngEncoderOptions.Default))
        {
            System.IO.File.WriteAllBytes(medalImagePath, data.ToArray());
            Debug.WriteLine($"Wrote medal to file: {medalImagePath}");
        }
        
    }             
}

And yes, I know - I am hardcoding the sprite size and column numbers. This is not ideal but for the current implementation it’s good enough. I don’t want to re-download the medal metadata unnecessarily just to get the sizes.

All medals are stored as individual PNG files, 256 by 256 pixels each - I chose the largest size to be able to showcase them in the clearest way possible. Each medal file name is that well-familiar by now nameId associated with the medal we saw in the metadata response.

Medals images stored in the app data folder

Once the medals are properly cross-checked with local copies (the app does everything to avoid re-downloading things over and over), they are grouped and displayed in the UI.

Medals images stored in the app data folder

One thing that OpenSpartan does differently here is actually taking into the account the medal difficulty that is not shown in other medal tracker sites. Every medal can be normal, heroic, legendary, or mythic. These are actually the values from the metadata response under difficulties and if you’ve been playing the latest versions of Halo Infinite you might’ve also noticed the fact that medals have colors in the background when looking at the post-game carnage report (PGCR):

Medal colors in the post-game carnage report

These colors represent the difficulty above, with green medals being normal, heroic medals - blue, legendary - purple, and mythic - red. This is shown in OpenSpartan as well, using the exact same color scheme you’d see in-game.

Conclusion #

This was a fun part of the experience to build because I had to deal with some image manipulation logic and also spot inconsistencies between the game and the API. Now, onto showing match details, battle pass data, and some rudimentary insights. Stay tuned!

If you want to be among the first people to try the app when it hits production, go to the OpenSpartan homepage and put your email in the waitlist form.