Back to posts

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.

The automatically entered seed

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