Speedrunbuddy: A Postmortem Analysis

Taking a look back at a project which I adore to this day.
Speedrunbuddy↗ is an open source, retired Twitch bot which integrated with therun.gg↗, a speedrun statistics site, in order to provide viewers of a channel with fun and useful commands about that streamer’s speedrunning stats.
The bot enjoyed a minor place within the speedrunning community, foremost helping to eliminate the need to manually update a !pb
command to get
information about a runner’s latest personal bests (PBs) in a given game and / or category.
It’s now been a couple of years since I last touched the project, so please forgive me if this seems disorganized - my memory is flaky in some areas.
The Foundation
Before I could even start with Speedrunbuddy, I had to get two things settled:
- What will I use to interface with Twitch’s IRC API for my bot?
- How will I pull data from therun.gg?
therun.gg data
To make my life and other people’s lives easier, I decided to write a Node.js wrapper↗ for therun.gg’s API.
This made things so much nicer in the end. It meant instead of handling all the HTTP calls via fetch
myself, which would inevitably become a
spaghettified mess that I would never properly implement nor maintain for the project, I could use simple functions instead which would even have typed
information returned thanks to TypeScript!
Need to get a user’s profile? Easy:
import { getUserProfile } from 'therungg';
const userProfile = await getUserProfile('greensuigi');
Twitch IRC
I settled on Twurple↗ as the Twitch IRC wrapper for this project. Specifically, I utilized
@twurple/auth
and @twurple/auth-tmi
.
The benefit of going with this solution is that it can handle auth for you, which is one of the biggest pains to implement yourself.
What’s particularly helpful is its RefreshingAuthProvider
- which, as the name suggests, will handle automatically refreshing any tokens
needed to sustain / continue a session.
This is incredibly important for such an application, as when a user types a command, they expect a quick response from the bot. Sure, re-establishing an auth session may not take long - but it removes another variable from the pipeline of when a user first requests something from the bot.
The best part is that, under the hood, it’s just using TMI.js↗, which is also a Twitch IRC wrapper. So, in essence, it is a wrapper of a wrapper (meta!); but really, this means a well-documented, familiar library is still in reach.
The Rewrite
There was quite a bit of rewriting that went on, but I want to focus on one crucial aspect: command handling.
Oh, goodness me. Prior to starting on this section, I thought, “well, it would be a good idea to look back and see how bad my code was in the first iterations of Speedrunbuddy.” As expected - it was bad.
I’m not even ashamed - I think a lot of it was just seeing if the idea would stick; a lot of it also came down to maturing in how I thought about architecture.
Except…
switch (command) {
case 'speedrunbuddy':
if (niceChannelName === client.getUsername()) break;
// Command stuff
break;
case 'join':
if (niceChannelName !== client.getUsername()) break;
// Command stuff
break;
case 'leave':
if (niceChannelName !== client.getUsername()) break;
// Command stuff
break;
case 'pbs':
if (niceChannelName === client.getUsername()) break;
// Command stuff
break;
case 'personalbest':
case 'pb':
if (niceChannelName === client.getUsername()) break;
// Command stuff
break;
}
Okay…there’s definitely obvious issues here. This is how I first handled commands.
A command from a user would follow this syntax: !<command> [arguments]
. The exclamation point is what lets the bot know to check for a possible command (after all, someone could type ”!!!! WOW”, which is not a command, so we don’t care).
All we do to grab the command name is get rid of that exclamation mark, and split all empty characters. Then, of course, we have to figure out what to do based off that command.
So, what’s wrong with this? Doesn’t look bad necessarily, right? It’s valid code, it runs, no performance issues.
The first problem is that the code is WET.
Huh, WET? What’s that?
Somebody clever, but deeply pained
Not DRY
“Write Everything Twice”
The word ‘wet’ but capitalized
Essentially - this is the biggest offender.
if (niceChannelName === client.getUsername()) break;
This line of code makes sure that we are not in the bot’s chat. We have to repeat this for every command (except for in two cases, where we want the opposite effect).
WET code can be helpful when refactoring, though. What does the repeating of this line of code suggest?
All commands need to decide to run depending on whether they are in the bot’s chat or someone else’s chat.
We have identified a shared behavior between commands, which means we’ve identified the abstract concept of a command. We’ve now entered object-oriented programming (OOP) territory, but more on that later.
The other problem we have with this code is that it’s just simply cumbersome, right? Each time we want to add a command, we have to update this switch statement. What if we have 20, or 100, or 1,000 commands? That is one BIG switch statement, and it quickly becomes a mess to add or modify commands because of how oppressively large the statement becomes.
So, what if there was a way we could:
- Have each command’s code / context be isolated
- Automatically load them when starting the bot from these isolated places
- Establish shared behaviors between commands
This is exactly the final approach I took when rewriting this, and of course using OOP concepts!
The Command
So now there is the concept of a Command
. We know some things about a Command:
- They have a name
- They have an alias
- Arguments can be passed to them
- We need to know whether we should listen for the command in the bot’s chat, and whether the command can ONLY be executed within’s a bot’s chat
And, to accomplish automatic loading, we need each command to expose a function that will allow it to be executed.
This is what I settled on:
import {
ClientWrapper,
CommandProperties,
} from '../services/command-dispatch.service';
export default interface ICommand {
name: string;
alias?: string;
regex?: RegExp;
customNameFromDatabase?: string;
listenInBotChat?: boolean;
botChatOnly?: boolean;
execute(wrapper: ClientWrapper, properties: CommandProperties): Promise<void>;
}
Fantastic. Now we can create our first command. !join
seems sensible, so the bot can join people’s channels.
import {
ClientWrapper,
CommandProperties,
} from '../services/command-dispatch.service';
import Speedrunbuddy from '../speedrunbuddy';
import ICommand from './ICommand';
import { ChannelService } from '../services';
import Constants from '../constants';
export default class JoinCommand implements ICommand {
public name = 'join';
public botChatOnly = true;
public async execute(
wrapper: ClientWrapper,
_: CommandProperties
): Promise<void> {
const client = Speedrunbuddy.client;
const channel = wrapper.channel;
const commander = wrapper.userstate.username;
if (await ChannelService.doesChannelExist(commander)) {
client.say(
channel.ircChannelName,
"Thanks for inviting me over, but I'm actually already in your chat!"
);
return;
}
try {
await client.join(commander);
} catch (e: unknown) {
client.say(channel.ircChannelName, Constants.SOMETHING_WENT_WRONG_MSG);
return;
}
const addedChannelToDatabase = await ChannelService.addChannel(commander);
if (addedChannelToDatabase) {
client.say(
channel.ircChannelName,
"Sweet, I've joined your chat! Thanks for inviting me!"
);
client.say(`#${commander}`, Constants.INTRODUCTORY_MSG);
} else {
client.say(channel.ircChannelName, Constants.SOMETHING_WENT_WRONG_MSG);
}
}
}
This is what the !join
command looks like currently. It lives in src/commands
, as its own well-named file, join.command.ts
.
You can see we have it implementing our ICommand
interface that I demonstrated earlier. The command’s name is join
, it can only run in the
bot’s own chat, and there is an execute
method on it. Sweet.
You might be thinking, this seems like more work, right? At face value, it kind of does seem like it. That’s a lot more boilerplate. But wait until you see…
The Command Dispatcher
The command dispatcher is what…well, dispatches commands!
The pipeline for how a command gets executed in the newest iteration looks something like this:
- Register all commands in the
CommandDispatchService
on application startup by importing eachCommand
class from files that end in.command.ts
- When the bot receives a message, call the
execute
method inCommandDispatchService
, passing the message. - The
CommandDispatchService
will separate out the command’s name from its arguments. - If a command by that name exists, it will check what channel we’re in. It will decide whether the command should continue on to execution based on how that command is configured.
- The
CommandDispatchService
will finally get theObject
of the command to execute, and call itsexecute
method, passing along properties about the command execution which include its arguments.
This allows us to achieve separation of concerns (SoC) with commands. All commands have to worry about is what the command itself does - everything
else is handled by the CommandDispatchService
!
I’m really proud of this system. The code is very clean. Registering the commands on startup is as simple as
await CommandDispatchService.registerAll();
and that’s it. I can add new commands at any time by putting them in the src/commands
folder.
Deployment
Deployment for the bot was…lackluster, at best. You will see the project is Dockerized, which made things a lot easier, but there was no proper continuous deployment (CD) process for this project. So, how did I deploy it?
Manually, by SSH, to an EC2 instance running Ubuntu. Brilliant, I know.
The justification for this was that I wasn’t really making many frequent modifications - the truth is my CI/CD skills were incredibly amateur years back when I made Speedrunbuddy. Truth sure does hurt.
There are a million downsides to manually deploying - the biggest one was that each time there was a change, I had to SSH into the EC2 instance,
resume the screen
instance, shut down the bot, pull down the latest code from git
, and restart the Docker instance. Manageable, but not fun.
So, I thought to myself one day - hey, what if we set up a cron
job to just git pull
and restart the Docker instance nightly at midnight EST?
That would work, right? Nightly deployments would allow me to restart at a time that should be least impactful to users, and I don’t have to manually
go back into the instance and re-deploy anymore!
Well, it did work - but the problem was that since the EC2 instance had very little disk space, each time I redeployed the Docker instance it would just take up more and more space due to the old instances laying around. Unfortunately, this is where my memory fails me, as I do recall attempting to address this issue to no avail.
In the future, I’d likely try to take better advantage of the fact this is Dockerized. The container meant that I could have used something like ECS within AWS - why I didn’t go that route to begin with confounds me as I write this, but again, I lacked the DevOps skills I do today.
So What Happened to Speedrunbuddy?
Speedrunbuddy was chugging along for a while without much need for new features, bug fixes, etc - I had 67 users who I never heard from much. Of course, not all of those users were monthly active users (MAUs) - many tried the bot once in their stream and the bot just never had any of its commands used.
But then, one day, my credit card I used for AWS expired.
It’s totally on me for missing their emails. They eventually suspended my account - when I realize what had happened, I paid my overdue balance only to find they had deleted my EC2 instance and RDS database. AWS claims they don’t do this in the event of suspensions, so I have no idea what happened. Again, I should have kept backups of the database.
So why did this effectively kill the bot? All my registered users were in the database that got deleted.
I admit, I was crushed. I decided, though, this could be a good opportunity to bring Speedrunbuddy a monumental upgrade - a website, a new Twitch bot that had LLM integration, and a Discord bot.
This never came to fruition as I had some difficulty deciding on how to architect it, and users weren’t really reaching out to me which suggested there was a lack of interest in the bot anyhow.
Speedrunbuddy thus remains an open source bot that others can use if they wish - I just do not host it anymore.
Conclusion
I would like to give special thanks to ShinyZeni, who was instrumental in finding bugs in the bot early on.
It, of course, would be remiss of me to not give thanks to Joey of therun.gg. Joey’s site not only made this bot exist in the first place, but he gave me a newfound excitement for software development through this bot and the site’s very existence. His approach to software via democratization has been an inspiration.