Scripting a Spelunky 2 Speedrun | Part 4: One Level, One Roadblock
It's been quite a while since the last post, and a lot has happened. Let's dive into how far the speedrun got!
After the last post I managed to get some decent progress with the speedrun! However, I also ran into some roadblocks, one of which unfortunately meant the end of this adventure. I did still learn a lot though, and in the end it got further than I would have expected! But I’m getting ahead of myself, let’s go through what happened, starting at the beginning.
Finding (and Entering) a Seed
Before I could start crafting a run, I would have to find a world seed.
I stumbled across a big list of seeds with various properties, and picked one that seemed interesting: 5022ABE9
.
This seed apparently had a shop on level 1-2 with a teleporter.
Perfect for going fast!
Then I realised I would also have to script entering this seed into the main menu - that is, unless I wanted to do that manually every time. Luckily, that ended up being very straightforward, apart from some timings being trickier than I expected. The menu would sometimes not register certain key presses if they were too short. However, since it’s only the menu going fast didn’t really matter yet, so this was easily solved by loosening the timings a bit.
Recording > Scripting
After manually crafting a script to enter the seed (which really is only a handful of key presses), I realised there was no way I was going to be able to hand-craft a script for a full level. At the very least, I was going to need a starting point. I thought that if I could somehow record myself playing through a level, that would be a good start, and I could tweak the inputs from there.
After some searching I came across the Win32 GetAsyncKeyState function.
Using this function, I wrote a simple program to cycle through all virtual key codes every millisecond to see which ones are pressed.
I could then use that information to generate a keyScripter script with a timestamps
block, so I could easily replay all the inputs!
This seemed to work quite nicely, so I wrote a simple command-line interface around this, dubbed it keyRecorder, and put it on GitHub.
You can find the source code here: https://github.com/LucaScorpion/keyRecorder.
Now then, to record a run for level 1-1. With the keyRecorder this worked out quite smoothly. I wanted to get a run without any (major) mistakes, to try and keep the amount of modifications required to a minimum. It took a couple tries, but after a while I got a run I was happy with. Then came the hard part: improving the script. I won’t bother you with too much details here, but I before going into this I didn’t fully realise how tedious this would be. The hardest part was that the feedback loop is very long. Whenever I changed anything I would have to go to the main menu, let the script do its thing, and hope it worked. Rinse and repeat for every tiny change. Less than ideal, but workable for now.
1-1 Conquered!
After messing with the first part of the script for a while, I got it to a point where I was fairly happy with it. It was probably far from the fastest it could be, but that’s okay. Just being able to see the “1-1 completed!” screen in ~10 seconds without having to press a single key was very satisfying. The result can be seen here:
The code to run the first level ended up being only 26 lines, nice! In fact, here it is:
timestamps {
0 scKeyDown right
416 scKeyUp right
416 scKeyDown down
416 scKeyDown jump
500 scKeyUp jump
500 scKeyUp down
865 scKeyDown left
1191 scKeyUp left
1485 scKeyDown left
2027 scKeyDown jump
2182 scKeyUp jump
2539 scKeyDown jump
2756 scKeyUp jump
2818 scKeyPress attack
4430 scKeyUp left
4693 scKeyDown right
5300 scKeyPress bomb
6616 scKeyUp right
8166 scKeyDown left
8615 scKeyUp left
8848 scKeyDown right
9080 scKeyUp right
9281 scKeyDown left
9500 scKeyUp left
9670 scKeyPress use
}
Note that all the key names (like right
, down
, etc) are defined earlier in the script, so I could easily reuse those.
With 1-1 done for now, I figured I could just repeat the same process for 1-2: record a decent run, tweak the script, and done. This is where I really started to realise how bad the long feedback loop was though. Not only would I have to restart the entire script if I messed up a manual run for the recording, I would have to do the same for every small change in the script later on. But I wanted to continue for now, and see how for I could get. Finally I got a good run, entered the shop near the end of the level, grabbed the teleporter, and tried to teleport towards the exit. Tried to, because that’s when it hit me…
Teleporters are Random
This is a fairly basic bit of game knowledge about Spelunky 2: the teleporter teleports you 4 to 8 tiles in the desired direction. The actual distance it moves you is random (except when you have the four-leaf clover, but that’s not relevant for now). This really was a facepalm moment for me, because this is something I knew, but somehow I didn’t think about this at any stage during the process. I had one last hope: maybe the distance would be the same if the inputs were exactly the same? But alas, that also did not seem to be the case. This meant I now had a scripted run that would play until the end of 1-2, grab the teleporter, and… nothing.
The Current State
And that’s where I left it! I realised that if I wanted to really make a full-blown Spelunky 2 TAS I would have to use a different approach, or at least significantly improve on this one. I’m not even sure if there would be a way to make a run with the teleporter given its randomness, but even without the teleporter you would need a way to really shorten the feedback loop. This is likely something that could be done using mods, but that’s a whole different rabbit whole I was not willing to dive into.
One other problem I ran into that I didn’t even mention so far was frame timings. While the keyScripter setup seemed to be precise enough to almost always trigger inputs on the right frame, it did happen very occasionally that it would trigger an input on the wrong frame, which could cause the entire run to break. This is most likely because keyScripter doesn’t actually know which frame the game is on. All it knows is that it should simulate certain events, and wait certain amounts of milliseconds. Any deviation in this (or even the game having a slight hiccup) could cause it to desync. A better setup would be to somehow be able to detect which frame the game is on, and use that for timing instead. This is likely something that could also be achieved with mods (or potentially watching certain memory addresses). Luckily this didn’t turn out to be a huge issue, more an annoyance, but it’s also something that definitely hindered the progress.
Either way, this has been a fascinating project, and I learned a lot about Go, custom languages, and Win32. In case you’re interested, the repository containing the scripted run can be found here: https://github.com/LucaScorpion/spelunky-2-tas