Profile picture (picture of a cat)

Derock Xie

Building a League of Legends companion app that serves thousands of communities.

TypeScript Docker CI/CD Pipeline

PoroScout

During my Freshman year of High School, I got really hooked on the game League of Legends. It’s a 5v5 team-based game with deep lore and difficult mechanics. In my highschool’s Esports team, we used a platform called Discord to communicate and strategize. Part of strategizing was scouting our upcoming enemies and learning about their playstyle: including the champions they play, what role they take on, etc.

There exist tools to help with this, such as OP.gg and Mobalytics. But, I wanted to bring this data into the chat platform, instead of having to constantly take and send screenshots and links:

Example conversation using screenshots

In Discord, users can build “Bots” that perform actions when invoked, and there existed a bot for League of Legends. Upon testing it though, the data was heavily outdated and the overall state of the bot seemed unmaintained.

So, I decided to build my own.

League of Legends has a public API which lets you fetch data on a per-match basis. This is great for getting basic information about players, but aggregate statistics like overall champion winrate and the current popular playstyles are not a single API query away.

I initially prototyped an approach similar to many web crawlers approach. The architecture looked something like this:

Block crawling diagram

But, since the game has over 170 different playable characters with hundreds of different items and runes, getting a comprehensive dataset would be quite hard to do independently. Instead, I decided to reach out to Mobalytics and ask if I could piggyback on their data.

So, on a random Friday in 2021, I sent them a cold email:

Email chain with a Mobalytics exec

To my surprise, they replied within two hours! We set up a meeting and coordinated with their engineers. They gave me access to a private GraphQL endpoint to pull data from. Then began the work to create the bot.

The initial MVP was written in JavaScript, but a year later, I would rewrite everything in TypeScript. On launch, Mobalytics helped to advertise the bot, and within a month, the bot was being used in hundreds of communities.

Now, I have to tackle the problem of scalability.

Observability

Tracking usage statistics, health amongst the different shards, and slow endpoints was the first thing I tackled. PoroScout starts with a “master” process, which spawns a bunch of child node processes, each assigned to handle a set of communities that PoroScout is in.

In the master process, I added a Prometheus service discovery endpoint, and each child process ran a lightweight webserver that prometheus could scrape. Using Grafana as the frontend, I built a custom dashboard to aggregate all this data:

Grafana Dashboard

Zabbix was installed on the host machine to track overall system usage and alert me whenever something was going wrong:

Zabbix alert showing high server load

Caching

Once again, I refactored the code to decouple the business logic from the Discord gateway handling code. I used Redis as a centralized cache, and created a utility to memoize existing functions.

this.getPlayerData = this.api.cache.memoize(this.getPlayerData.bind(this), {
key: (puuid, region) => `challenges:player:${region}:${puuid}`,
ttl: /* 5 minutes */ 1000 * 60 * 5,
staleTTL: /* 1 minute */ 1000 * 60,
});

This cache manager uses Stale-while-revalidate logic. The cache will serve stale data, revalidate the result in the background, and update the cache as needed.

Code Structure

PoroScout consists of multiple microservices and webservices. A private API for external partnerships, microservice to index new champions and items into the search provider, microservice to track player statistics, etc.

To prevent code duplication, I broke the project down into a monorepo using Turbo, where packages can be reused between services.

poroscout/
|- apps/
|- poroscout/
|- api/
|- meilisearch-updater/
|- docker/
`- scheduled-tasks/
`- packages/
|- api/
|- cache/
|- database/
|- log/
|- metrics/
|- riot-api (submodule)
|- riot-rate-limiter (submodule)
`- utils/

Prior to this, different microservices had their own git repos, and submodules were used to bring in common files. But maintaining and keeping the submodules up to date turned out to be a real pain, which is why I switched to this monorepo format.

CI/CD Pipeline

PoroScout is containerized, with each app getting its own Docker container. Upon pushing the code to GitHub, a CI pipeline is spawned that builds all the targets. The tag of the image is based off the branch that it was pushed to.

GitHub actions pipeline

If a pull request is made targeting the main branch, then the pipeline will also deploy a staging version of the bot for testing. When a release tag is created, the pipeline builds the image off the main branch, and sends a webhook request to Portainer, which pulls the latest image from GitHub and starts rolling the release out to the public.

Conclusion

Running a production-grade Discord bot forced me to wear every hat: Product Manager, Backend Engineer, and DevOps Specialist. I started this project with just an idea, sent out a cold email, and somehow landed myself a partnership with a large VC-backed company. This project has gotten me many additional connections and I’ve formed partnerships with additional companies.

When I began, I had very limited experience programming large-scale applications, and learnt all the skills required along the journey.

Check out the website at https://poroscout.gg.

PoroScout