Skip to content

Instantly share code, notes, and snippets.

@1lann
Last active April 24, 2023 09:06
Show Gist options
  • Save 1lann/885f6b85b99d19b88bec to your computer and use it in GitHub Desktop.
Save 1lann/885f6b85b99d19b88bec to your computer and use it in GitHub Desktop.
Storing and playing back replays in League of Legends

Storing and playing back replays in League of Legends

How it works

When you spectate a game in League of Legends, you tell the client to use a HTTP server, and make HTTP requests to it to retrieve data in chunks which make up a game. But what if you could return back the exact same data at a later time, simulating the spectate but viewing it at anytime? That's the concept behind replays and that's how they work.

There is some behavior in the API which I do not fully understand yet, so there are if statements to catch these edge cases.

Current game information

Before you can even get the game's metadata, you'll need to retrieve necessary information for the game. This call is part of the official Riot Games API.

/observer-mode/rest/consumer/getSpectatorGameInfo/{platformId}/{summonerId}

API Reference Link

Example Full URL:

https://na.api.pvp.net/observer-mode/rest/consumer/getSpectatorGameInfo/NA1/<summoner id here>?api_key=<api key here>

The data you'll need to record games at this stage are

  • The game ID ["gameId"]
  • The encryption key ["observers"]["encryptionKey"] (You won't actually use this to record with, but you need it to play the recording back)
  • The platform ID ["platformId"]

The unofficially documented API

From this point on, you'll be using API calls outside of the officially documented API, they will all be under the base URL of the spectate server you're trying to record from, plus /observer-mode/rest/consumer. These API calls will not require your API key.

You can find a list of these base URLs here.

So for example, the base URL for NA will be:

http://spectator.na.lol.riotgames.com/observer-mode/rest/consumer

In the next section, I will state that the endpoint for getting the version of the game is found under /version, which you need to append to the base URL and really means:

 http://spectator.na.lol.riotgames.com/observer-mode/rest/consumer/version

This also applies to makeSpectatorCall() in my pseudocode.

Storing the data

All the data from this point on that you retrieve, will need to be stored somehow, and needs to be able to be converted into a flat file. A really easy way of doing this storing the data encoded in base64, into a dictionary whose keys will be the URL endpoint of where the data came from, which will later on be encoded in JSON and saved to the filesystem. These files will become about 15 MB in size, each.

So for example in the next section, I will state you need to get and store the version from calling /version. What this guide will assume that you're doing is taking the data from /version, encoding it in base64, then storing it an dictionary which has a key of /version.

Here's an example in JavaScript:

// Note that btoa() converts binary data to base64
var version = "5.16.1" // You will actually make a HTTP GET request here
var gameData = {}
gameData["/version"] = btoa(version)

// Store more data
JSON.stringify(gameData) // Store this into a file

Make sure that you do encoded the data in base64! As often there will be binary data which cannot be stored easily/safely in JSON.

Retrieving metadata

Before you start recording any real gameplay, you need to download some metadata about the game and its state, so you know what to record next.

The first call you need to make and store is /version. Note that recordings are not compatiable between patches (Ex. a recording of a game in patch 5.14 will [probably] not be comptiable with a client which is running on patch 5.16).

The next couple of calls, is more complex, and as a result I will use JavaScript style psuedocode instead as it's harder to explain in words.

The practices in the pseudocode I write is not the best! Please do not use them as a direct example of what you should write, specifically the getAndStore functions which uses gameData as if it's global, when it really should be local to the game that's being recorded.

Here are the functions which will be used:

var getMetadata = function(platformId, gameId) {
    var data = makeSpectatorCall("/getGameMetaData/" + platformId + "/" + gameId + "/0/token")
    return JSON.parse(data)
}

// Data from this function is not recorded into the filesystem, it's required for simulating the client
var getLastChunkInfo = function(platfromId, gameId) {
    var data = makeSpectatorCall("/getLastChunkInfo/" + platformId + "/" + gameId + "/0/token")
    return JSON.parse(data)
}

var getAndStoreChunkFrame = function(platformId, gameId, chunkId) {
    if (chunkId == 0) {
        // You cannot retrieve a chunkId of 0
        return
    }

    var data = makeSpectatorCall("/getGameDataChunk/" + platformId + "/" + gameId + "/" + chunkId + "/token")

    var dictKey = "/getGameDataChunk/" + platformId + "/" + gameId + "/" + chunkId

    // This data is actually binary data
    // This is bad practice (sorry!), figure a way to localise gameData to the game being recorded
    gameData[dictKey] = btoa(data)
}

Here's the code logic:

var gameData = {}

var metadata = getMetadata(platformId, gameId)

// Wait until game data is actually available
// i.e. This loop will continue if the game has been running for < 3 minutes. AKA spectator delay.
while (true) {
    var chunk = getLastChunkInfo(platformId, gameId)
    if (chunk["chunkId"] > metadata["endStartupChunkId"]) {
        break
    }
    // For this psuedocode, sleep() sleeps in seconds
    // Sleep for the next available chunk, plus 1 second
    sleep(chunk["nextAvailableChunk"] / 1000 + 1)
}

// The new metadata will contain the initial frames of the game
metadata = getMetadata(platformId, gameId)
// You will need to store this. (I intentionally removed the /0/token at the end).
gameData["/getGameMetaData/" + platformId + "/" + gameId] = btoa(metadata)

// The initial frames represent the data when loading into the game
// Let's get them and store them
for (var i = 1; i <= metadata["endStartupChunkId"] + 1, i++) {
    // Keep trying until the chunk is availbale
    while (true) {
        var chunk = getLastChunkInfo(platformId, gameId)

        // Wait until the chunk is available
        if (i > chunk["chunkId"]) {
            // For this psuedocode, sleep() sleeps in seconds
            sleep(chunk["nextAvailableChunk"] / 1000 + 1)
            continue // Goes back to start of while true loop
        }

        getAndStoreChunkFrame(platformId, gameId, i)
    }
}

Retrieving game data

Now we're onto the hardest part of the recording, retrieving all the game data.

Here are the functions which will be used:

// Same as retrieiving metadata above
var getLastChunkInfo = function(platfromId, gameId) {
    var data = makeSpectatorCall("/getLastChunkInfo/" + platformId + "/" + gameId + "/0/token")
    return JSON.parse(data)
}

// Same as retrieving metadata above
var getAndStoreChunkFrame = function(platformId, gameId, chunkId) {
    if (chunkId == 0) {
        // You cannot retrieve a chunkId of 0
        return
    }

    var data = makeSpectatorCall("/getGameDataChunk/" + platformId + "/" + gameId + "/" + chunkId + "/token")

    var dictKey = "/getGameDataChunk/" + platformId + "/" + gameId + "/" + chunkId

    // This data is actually binary data
    // This is bad practice (sorry!), figure a way to localise gameData to the game being recorded
    gameData[dictKey] = btoa(data)
}

// Very similar to getAndStoreChunkFrame(), except gets a different piece of data.
var getAndStoreKeyFrame = function(platformId, gameId, frameId) {
    if (frameId == 0) {
        // You cannot retrieve a frameId of 0
        return
    }

    var data = makeSpectatorCall("/getKeyFrame/" + platformId + "/" + gameId + "/" + frameId + "/token")

    var dictKey = "/getKeyFrame/" + platformId + "/" + gameId + "/" + frameId

    // This data is actually binary data
    // This is bad practice (sorry!), figure a way to localise gameData to the game being recorded
    gameData[dictKey] = btoa(data)
}

Again, here's the pseudocode in JavaScript:

var firstChunk = 0
var firstKeyFrame = 0
var lastChunk = 0
var lastKeyFrame = 0

while (true) {
    var chunk = getLastChunkInfo(platformId, gameId)

    // Set the initial values
    if firstChunk == 0 {
        if (chunk["chunkId"] > chunk["startGameChunkId"]) {
            // If you missed the beginning of the game, jump to where the game is currently at
            firstChunk = chunk["chunkId"]
        } else {
            // Otherwise, record from the beginning
            firstChunk = chunk["startGameChunkId"]
        }

        if (chunk["keyFrameId" > 0]) {
            // If you missed the beginning of the game, jump to where the game is currently at
            firstKeyFrame = chunk["keyFrameId"]
        } else {
            // Otherwise, record from the beginning
            firstKeyFrame = 1
        }

        lastChunk = chunk["chunkId"]
        lastKeyFrame = chunk["keyFrameId"]

        // Store the initial chunk and frame
        getAndStoreChunkFrame(platformId, gameId, chunk["chunkId"])
        getAndStoreKeyFrame(platformId, gameId, chunk["keyFrameId"])
    }

    if (chunk["startGameChunkId"] > firstChunk) {
        // The first chunkId must be at least startGameChunkId
        firstChunk = chunk["startGameChunkId"]
    }

    if (chunk["chunkId"] > lastChunk) {
        // Get all the chunk data from the last chunk, to the chunkId now
        for (var i = lastChunk + 1; i <= chunk["chunkId"]; i++) {
            getAndStoreChunkFrame(platformId, gameId, i)
        }
    }

    if (chunk["nextChunkId"] < chunk["chunkId"] && chunk["nextChunkId"] > 0) {
        // Some kind of weird edge case where the next chunk ID is actually
        // before the current chunk
        getAndStoreChunkFrame(platformId, gameId, chunk["nextChunkId"])
    }

    if (chunk["keyFrameId"] > lastKeyFrame) {
        // Get all the key frame data from the last key frame, to the keyFrameId now
        for (var i = lastKeyFrame + 1; i <= chunk["keyFrameId"]; i++) {
            getAndStoreKeyFrame(platformId, gameId, i)
        }
    }

    // Store custom chunk info, which simulates the client to start from the beginning, and downloading everything to the end (now)
    // Why do this now instead of when the recording ends? Just in case the recording cuts off for whatever reason
    var customChunkInfo = {
        nextChunkId: firstChunk,
        chunkId: firstChunk,
        nextAvailableChunk: 3000,
        startGameChunkId: chunk["startGameChunkId"],
        keyFrameId: firstKeyFrame,
        endGameChunkId: chunk["chunkId"],
        availableSince: 0,
        duration: 3000,
        endStartupChunkId: chunk["endStartupChunkId"]
    }

    // This dictionary key is a bit off from what I usually do
    gameData["firstChunkData"] = btoa(JSON.stringify(customChunkInfo))

    // The second part of the custom chunk info, which makes the client jump to the end
    customChunk["nextChunkId"] = chunk["chunkId"] - 1 // For some reason the client downloads up to nextChunkId + 1
    customChunk["chunkId"] = chunk["chunkId"]
    customChunk["keyFrameId"] = chunk["keyFrameId"]

    gameData["lastChunkData"] = btoa(JSON.stringify(customChunkInfo))

    // End of storing custom chunk info

    lastChunk = chunk["chunkId"]
    lastKeyFrame = chunk["keyFrameId"]

    if (chunk["endGameChunkId"] == chunk["chunkId"]) {
        // The game is over, recording terminates here.
        break
    }

    // I recommend you to write the current data into a file just in case something happens
    // If something bad does happen, you can actually still playback the recording up to where it stopped
    JSON.stringify(gameData) // Write this to a file

    // For this psuedocode, sleep() sleeps in seconds
    // Sleep for the next available chunk, plus 1 second
    sleep(chunk["nextAvailableChunk"] / 1000 + 1)
}

Playing back game data

WIP, to be completed one day. I may not have time to finish this, here's some code you can look at instead:

As a summary, you need to read back the replay data, and return the appropriate data, and add any necessary HTTP headers.

@TriggerEdge
Copy link

Hello, Is There a way to get the game mode of .rofl files (Summoner's Rift, Howling Abyss, etc...)?

@coltiebaby
Copy link

@Trigger-edge.

This is showing you how to record a replay from a live game. Riot does not have any documentation to download a .rolf file from someone else's history as its saved on their computer from my understanding.

@adamschachne
Copy link

You can determine the game mode from the .rofl using Riot's official API and the file name. The name scheme is region-matchid.rofl, e.g. NA1-1234567890.rofl

Check out https://developer.riotgames.com/api-methods/#match-v3

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment