Kwirk is a block-pushing puzzle game for Game Boy. This is a speedrun of the game's "Heading Out?" mode, which is a time attack of small puzzles, drawn randomly. The goal of this run is to clear all 120 unique floors of this mode as fast as possible. Besides solving each individual floor quickly, this requires manipulating RNG to access unplayed floors.

Game objectives

  • Emulator used: BizHawk 2.9.1
  • Clears every floor in Heading Out? mode at least once
  • Aims for fastest real time
  • Manipulates luck
Suggested screenshot: frame 21507

Comments

Kwirk has two game modes. Going Up? mode is the "adventure mode": you play 30 full-size floors in a fixed order across three difficulty levels, Easy, Average, and Hard. Heading Out? mode is the "time attack mode": you choose a difficulty level and a number of floors, and the game gives you that many mini-floors to play, with time-based scoring. The floors in Heading Out? mode are drawn randomly from a pool of 120, distributed over the three difficulty levels: 30 in Easy, 50 in Average, and 40 in Hard.
I started writing a Kwirk solver with the idea of applying it to Going Up? mode. This turned out to be harder than expected, as some of the floors are combinatorially complex. Luckily, #8905: Nitrodon, ZenicReverie, CyberShadow, Sand, Alyosha & Deadcode's GB Kwirk "Going Up?" in 15:15.89 appeared and did Going Up? better than I would have, using a tuned solver and large amounts of computation and storage.
That still leaves Heading Out? mode, which does not have a published TAS. The puzzles in Heading Out? are smaller, enough that my relatively simple solver could find optimal solutions for all of them. While the floors are smaller, there are more of them: 120 compared to 30 in Going Up? mode. And while you can choose the difficulty and number of floors (from 1 to 99), the actual floors you get are randomly selected by the game.
RTA runners play Heading Out? in a single segment of 10 or 99 floors, taking whatever floors the RNG gives them. (Which entails playing some floors more than once, in the 99-floor case.) For the TAS I decided to do something different: clear all 120 floors at least once, over multiple segments (allowing to re-seed the RNG between segments, at the cost of time). This is a new, invented category, but one I think is well-suited to a TAS. It adds another dimension to the optimization, because in addition to playing each floor optimally, you have to coax the RNG into giving you floors you haven't seen yet.
The video encode attached to this submission has a tracker that shows which floors have been cleared so far.

Floor solver

The solver for individual floors is nothing very special. It is the typical breadth-first, uniform cost search kind of thing, with a reimplementation of the game rules and knowledge of the cost in frames of each action, such as: 9 frames to step into an empty tile, 28 frames to push a block into a hole, 12 frames to rotate a turnstile. As there is no randomness within floors, all solutions can be pre-computed as fixed move sequences. I am confident the per-floor solutions are optimal.
The game counts "steps" as well as frames. This run tries to minimize real time (frames), not steps or in-game time.

Random floor scheduling

The floors in Heading Out? are internally numbered 30–59 for Easy, 60–109 for Average, and 110–149 for Hard. Floor numbers 0–29 are used for Going Up? mode.
The RNG is seeded, at the start of each segment, with a frame counter mod 60. This means the only possible seeds are 0–59, and you can get any seed you want by waiting at the confirmation menu for up to 1 second. A complete schedule of floors is generated as soon as you hit Start. There is no way to affect the RNG while playing a segment, only between segments. In the video encode attached to this submission, you can see the constantly updating frame counter that is used to set the RNG seed (in gray), and the seed used for the current segment (in white).
Each floor has a 50% random chance of being inverted (flipped vertically). This does not have much of an effect on gameplay or routing. The inverted version of a floor has essentially the same solution as the normal version, but it may be slightly faster or slower to solve, because inversion affects the shape of the passages into and out of the central puzzle area.
It might seem that there are only 60 possible floor schedules per difficulty level, which is what I thought at first. But there's an important complication. The game keeps track of the 20 most recently played floors, and will never repeat a floor that is in that set. This filter remains in effect across segments and even across difficulty levels, because the set of recently played floors is indexed relative to the start of the difficulty level. This means, for example, that if you have just cleared floor 35 (which is index 5 in Easy) and started a new segment, none of the next 20 floors can be 35 (index 5 in Easy), 65 (index 5 in Average), or 115 (index 5 in Hard). Massaging the random floor generation algorithm with this cross-segment carryover is critical for getting long sequences of not-yet-played floors.
But it's not simple to optimize. (If you think of it as a graph search problem, the branching factor is about 181, because after clearing a floor you have the option of continuing in the current segment, or starting a new segment in one of 3 difficulty levels and one of 60 random seeds.) The route used in this run is the result of a computer search. In the end I turned to a genetic algorithm to find the segment route shown below, which has 8 segments and 1 repeated floor. I am pretty happy with this route, but it has no proof of optimality. It may be possible to improve on this route, whether by using fewer segments, repeating no floors, repeating a shorter floor, getting more favorable inverted floors, or spending less time waiting for RNG seeds to roll around.
Seed 42 in the Easy difficulty level is extremely lucky. On a freshly started game, seed 42 hits all 30 floors exactly once with no repeats. The route starts there, because it's hard to beat for efficiency. It only works when it's the first segment, and the history of recently played floors is empty. If you've already played a segment, the recently played floors filter will cause you to get a different sequence of floors that no longer covers all 30 possibilities cleanly.

The route

The route consists of 8 segments. Only 1 floor is repeated, floor 90 in the Average difficulty level. An "i" suffix means the inverted version of a floor.
SkillSeed# FloorsFloors
Easy423032i, 35i, 55i, 46, 59, 45i, 49i, 58, 56, 33, 30i, 57, 48i, 31i, 47i, 51i, 37, 41, 38, 44i, 50i, 34i, 42i, 53, 43, 54i, 40i, 36, 52i, 39i
Hard3422138i, 110i, 139, 113i, 146, 148i, 111i, 144i, 143, 140, 131, 142i, 136i, 149i, 112i, 147, 121, 129i, 127i, 141, 135i, 115i
Hard2210128i, 123i, 145i, 117, 114i, 125, 119, 120, 133, 137
Hard588132, 122i, 126, 124i, 134i, 130, 118, 116
Average3712109i, 100, 106, 104i, 98i, 65i, 63i, 93, 90, 73i, 95i, 96
Average21589i, 108, 71, 94, 87i, 102, 86, 92i, 80, 103i, 60i, 91, 97, 64, 62i
Average41568, 82i, 83i, 74i, 99, 70i, 76, 84i, 101, 77, 67, 90, 69, 107, 105i
Average17978, 75, 61i, 79, 88, 72, 85, 81i, 66i
There are routes with no repeats, but they are slower overall, because they require more segments. The scrolling animation between floors in a segment takes about 3.5 seconds, while starting a new segment (with the associated fanfares, menuing, etc.) costs around 11.5 seconds. For perspective, this route with 1 repeat is 171 frames (2.9 seconds) faster than the fastest route I found with no repeats. It's a small fraction of the overall time, but once you've optimized every individual floor, the only improvements can come from better routing.
The sum of in-game times per segment is 23:39.
SkillSeed# FloorsIn-game time
Easy42304:30
Hard34225:37
Hard22102:34
Hard5881:37
Average37122:16
Average2153:02
Average4152:31
Average1791:32
total 23:39

Floor comments

I won't comment on every floor, just some of the more noteworthy ones.

01:42 Easy floor 30i

Floor 30i before solving
Floor 30i is the shortest floor in Easy, with 26 steps and 240 frames.
Perhaps surprisingly, it is not the shortest floor overall, which is rather floor 76 in Average.

04:25 Easy floor 52i

Floor 52i before solving
Floor 52i is the longest floor in Easy, with 56 steps and 528 frames.

06:13 Hard floor 148i

Floor 148i before solving
Floor 148i is the longest floor in Hard, and the longest of the run overall, with 132 steps and 1239 frames.

07:40 Hard floor 131

Floor 131 before solving
Floor 131 is the shortest floor in Hard, with 34 steps and 318 frames.

09:53 Hard floor 141

Floor 141 before solving
Floor 141 is the only floor that is not sight-readable, by which I mean it violates a convention that is observed everywhere else in the game. In the initial configuration, the west spoke of the "T" turnstile covers a hole tile, which never happens in any other floor, whether in Going Up? or Heading Out? mode. Although you cannot see it at first, without that hole the floor is unsolvable.
I know this because my map extraction script did not handle this case correctly at first :)

18:55 Average floor 86

Floor 86 before solving
Floor 86 is the longest floor in Average, with 128 steps and 1218 frames.

20:54 quick restart

Heading Out? Level #2 15RMS. Start / End.
There are two consecutive segments that both have a difficulty level of Average and a floor count of 15; only the RNG seed is different. Because the difficulty level and number of floors is the same, we do not even have to enter the menus between these two segments, just wait for the next RNG seed. This is not something the route optimizer was trained to look for, just a lucky coincidence.

21:58 Average floor 76

Floor 76 before solving
Floor 76 is the shortest floor in Average, and the shortest of the run overall, with 26 steps and 238 frames.

22:52 Average floor 90 repeated

Floor 90 before solving
Here we repeat floor 90, which is the only repeated floor of the run. It is a pretty short floor, 41 steps and 388 frames. It was played for the first time at 16:56.

Availability

My posts documenting the development of this run start at Forum/Posts/527972.
My source code and notes are in a Git repository:
git clone https://www.bamsoftware.com/git/kwirk.git

Thanks


nymx: Claiming for judging.
nymx: Re-uploading to correct any possible cycle count issues.
nymx: I'm a big fan of solving tools that optimize game-play. This TAS is no exception. You caught my attention, when you confirmed my [6148] C64 Match Blox by nymx in 00:55.66 solutions with math...instead of my brute forcing scripts. So naturally, I find your tools and efforts to have great strength, and I have complete confidence in this run. A lot times, I can't see the end coming, until the last moment. In my opinion, that is the sign of a true solution...where human effort can often be foreseen. On top of all this, finding that RNG seed is also important. I've observed a small group of members that really focus on finding that perfect seed. So, with all that said...I'm always cautious about saying that nothing else can be found, but it sure does look like you have clinched this game. Congratulations on excellent work! I look forward to your future submissions!
Happily Accepting!
fsvgm777: Processing. Dimon12321 is handling the encodes for this one.


TASVideoAgent
They/Them
Moderator
Joined: 8/3/2004
Posts: 15576
Location: 127.0.0.1
This topic is for the purpose of discussing #9257: Sand's GB Kwirk "Heading Out?, all floors" in 25:15.19
Alyosha
He/Him
Editor, Emulator Coder, Expert player (3821)
Joined: 11/30/2014
Posts: 2829
Location: US
Another great Kwirk project completed, nice work! Pretty cool you got it down to only a single repeat. Do you foresee the tools you used here being applicable to other projects?
Sand
He/Him
Player (143)
Joined: 6/26/2018
Posts: 175
Alyosha wrote:
Do you foresee the tools you used here being applicable to other projects?
One obvious application would be to try the floor solver and/or extractor with A-mazing Tater. I don't know the game, but from [3716] GB A-mazing Tater "Puzzle Mode" by TheRealThingy & Alyosha in 17:22.00 the mechanics look the same. For this project, I had to work out how floors are stored in the ROM (see the rip-floors script in the source code), and if it's the same format in Amazing Tater, it might be useful to extract the floors for an external solver. It would require some analysis to find the appropriate ROM offsets. But what I'm more excited about, and what I plan to make a thread at tool-assisted laboratory for, is the rudimentary data-oriented savestate manipulation library I used to script this TAS. All the inputs come from a Lua script, which does things like find the earliest frame at which each input is accepted. To do that, the script tries pressing the input, then runs forward a few frames to see if a relevant memory location has changed; if not, it rewinds to where it started, tries the same input 1 frame later, runs ahead again, scrubs back if necessary, etc., until it finds the earliest frame that accepts the input. That kind of thing is difficult to write using event callbacks and emu.frameadvance(). I found a nice way to express operations like that in Lua, one that looks more like manipulating data using straightforward imperative code, functions and loops. It's similar to rewriting callback-oriented JavaScript code to instead use promises, async, and await. I'm hoping to write more about this, but see, for example, the earliest_effective_frame function in tas.lua, and how it is called at the title screen to find the earliest frame at which you can press Start. Rather than treating the emulator as a massive global variable, the function accepts an immutable savestate as input and produces an immutable savestate as output, purely functional as long as you remain within the abstraction.
RetroEdit
Any
Editor, Reviewer, Player (169)
Joined: 8/8/2019
Posts: 152
Sand wrote:
But what I'm more excited about, and what I plan to make a thread at tool-assisted laboratory for, is the rudimentary data-oriented savestate manipulation library I used to script this TAS. All the inputs come from a Lua script, which does things like find the earliest frame at which each input is accepted. ...
This sounds interesting. I've coded something similar a few years back (not uploaded online though; never got around to it. :-( Hopefully eventually.). Having other code of this nature available to reference could be useful -- if I recall, my code also incorporated length predictions to detect if loading times varied from the baseline to look for potential longer term subtle optimizations there.
Dimon12321
He/Him
Editor, Reviewer, Experienced player (596)
Joined: 4/5/2014
Posts: 1222
Location: Romania
It's time to speed up the publication process!
TASing is like making a film: only the best takes are shown in the final movie.
Alyosha
He/Him
Editor, Emulator Coder, Expert player (3821)
Joined: 11/30/2014
Posts: 2829
Location: US
I was able to console verify this: Link to video
Post subject: GBA resync
Sand
He/Him
Player (143)
Joined: 6/26/2018
Posts: 175
Thank you Alyosha, that's great! What did you have to do to resync the video for console verification? I didn't specifically design for this, but a few days ago I found that the tas.lua script copes just fine in GBA mode, even though I originally did the TAS with Gambatte. That is, start recording a movie, load the script, watch the game play itself, and save the movie file when the script finishes. I'm curious whether this produces a movie file equivalent to what you used for the verification. Below are some rough instructions on running the script from the README file in the repository. The main thing is you need to edit tas.lua to set SAVESTATE_DIRECTORY to a filesystem path—this is a slower mode, but necessary when you want to save the final movie file.
Run the tas.lua script
BizHawk-2.9.1$ ./EmuHawkMono.sh --lua=/path/to/tas.lua kwirk.gb
How I do this is:
  1. Run BizHawk as shown above.
  2. Pause the emulator.
  3. Ctrl+R to reboot the emulator.
  4. Start recording a movie.
  5. Click the "Toggle Script" button in the Lua window to refresh tas.lua.
  6. Unpause the emulator.
  7. Wait for the emulation to finish.
You will see the game window scrubbing back and forth in time frequently – this is the TAS script trying multiple ways of doing the same action and choosing the fastest one. But beware: the bk2 movie files created this way will not be replayable. They will desync if you try to replay them. That is because, by default, the tas.lua script uses in-memory savestates that don't faithfully preserve an input log. To make the movie file replayable once you have found the best one, you will have to enable on-disk savestates as shown below. Record a movie When you're ready to record a video that plays back, you need to adjust tas.lua to make it save real on-disk savestates, rather than in-memory savestates. The difference with on-disk savestates is that they contain a full input history, which is not the case with in-memory savestates. But on-disk savestates are slower to save and restore, which is why they're not the default. Just edit this line and set SAVESTATE_DIRECTORY to some writable directory.
local SAVESTATE_DIRECTORY = "/tmp"
Record a movie as shown above. This one will be able to be played back. Afterward, you can delete the tempsavestate-* files.
Alyosha
He/Him
Editor, Emulator Coder, Expert player (3821)
Joined: 11/30/2014
Posts: 2829
Location: US
Thank you Alyosha, that's great! What did you have to do to resync the video for console verification?
I just changed the console mode in the sync settings in the bk2 to GBA mode, then deleted frames until it synced.
Post subject: Movie published
TASVideoAgent
They/Them
Moderator
Joined: 8/3/2004
Posts: 15576
Location: 127.0.0.1
This movie has been published. The posts before this message apply to the submission, and posts after this message apply to the published movie. ---- [6203] GB Kwirk "Heading Out?, all floors" by Sand in 25:15.19
Sand
He/Him
Player (143)
Joined: 6/26/2018
Posts: 175
Sand wrote:
But what I'm more excited about, and what I plan to make a thread at tool-assisted laboratory for, is the rudimentary data-oriented savestate manipulation library I used to script this TAS.
The promised thread is Thread #25755: Data-oriented savestate manipulation in BizHawk and Lua.