Voltorb Flip
What is voltorb flip
In one of the most formative video games of my childhood, there is a minigame in which you can win coins which can in turn be exchanged for pokemon or super powerful otherwise unattainable moves. This minigame immediately captured my attention. It hits the puzzle solving part of my brain like sudoku and minesweeper both do, however there still is a luck component meaning you can’t truly solve the game. That being said, because you can occasionally complete the game without taking risks and you’re also able to quit early to avoid large penalties, I always wondered how optimally you can play the game.
Recently, I’ve been replaying this game, pokemon soul silver, and again - as soon as I made it to the area where it is playable (It can be found in the game corner, located in goldenrod city) it stole all of my, attention and it again had me consider what optimal play would look like. I’ve found online solvers, however these don’t make any effort in guiding your play other than telling you the lowest risk path forward - never when its better to stop. As such, I would like to try to solve this game such that I can maximize the number of tokens I get in a single sitting.
Considerations
Before solving for optimality, you must first understand the reward scheme for the game, which thankfully is very simple. The game is presented as a 5x5 grid of squares that either contain a number 1 - 3, or a voltorb on on the face. The goal of the game is to find all of the 2’s and 3’s without flipping over a voltorb. In order to do this, the game also tells you the number of voltorbs in each row / column, as well as the sum of points located in the row. Using this information, you can easily eliminate many of the tiles from the game - if the number of points + the number of voltorbs in a row/column is equal to 5, there is only 1’s or voltorbs in that column. The number of coins you get is equal to the multiple of all of the tiles you flip - so flipping a 1 over doesn’t hurt you, but also doesn’t increase your reward.
Once you clear a level (by finding all of the 2’s and 3’s) you progress to the next level where you have an opportunity to win even more coins. This can carry over up to a maximum of level 7. Lastly, if you flip over a voltorb you lose all of the coins you would have gotten from your current level and are occasionally punished by dropping down to an earlier level. In order to avoid this punishment, you can quit early, which allows you to keep all of the coins you’ve gotten from the current level however it will occasionally punish you by droppping your level, although this punishment seems to be less than if you have flipped over a voltorb.
In order to properly evaluate an optimal strategy, I first need to understand the punishment mechanism and then I need to evaluate the cost/benefit of playing safely. My theory is that SOME boards are going to be too risky to be worth playing out, however an optimal strategy will always take some smaller risks.
How does Voltorb Flip Punish players
The rules for rewarding players is simple in voltorb flip, simply multiply the value of all of the flipped cards over - Once the player reveals all non-voltorb and non-one value cards the game ends, but how does the game decide how to punish a player for flipping a voltorb? Well, the simple answer is that you get no coins and the game resets - additionally, the game may or may not adjust the level you’re playing at. This, however, is not always consistent. For us to actually optimize play, we need to understand the punishment schedule of this game.
This can be solved one of two ways - Firstly, I could play a large volume of voltorb flip (already done) and record the results of all of my failures (I forgot to record my data….). Or secondly, I could try to dig into the source code for this project and use it as an excuse to learn a bit about reverse engineering games (A win win!)
Thankfully, pokemon heart gold is an extremely popular game in an already extremely popular series, so I was very easily able to find an existing project doing this work already. Here is the project on github that I have been looking at.
Fortunately, the voltorb flip code has already been mostly reverse engineered and inside of the project you can find section of code which is used to calculate the next level to send the user to:
// True if the boardId corresponds to at least level `level`.
#define LEVEL_AT_LEAST(boardId, level) (boardId >= 10 * (level - 1))
static int CalcNextLevel(GameState *game) {
int i;
u32 boardId;
RoundOutcome roundOutcome;
RoundSummary *prevRound = PrevRoundSummary(game);
roundOutcome = prevRound->roundOutcome;
...
}
This looks like exactly what we need! Here we can see we’re just defining our variables and instantiating the important structs about our the game we just played, nothing interesting… yet!
if (roundOutcome == ROUND_OUTCOME_WON && LEVEL_AT_LEAST(prevRound->boardId, 8)) {
return 0; // Lv. 8
}
And here we have the first return case and in it we should note the comment (Thanks to the reverse engineering community at pret!) that there is actually a possible 8th level, which I had NO idea about! From this code, if you’re already on level 8 (represented by the int for level being 0) and you win the previous round, you can continue play another round 8 game. From my experience in the game, higher levels yeild more money, so surely this level is optimal for maximizing return, but how do we get there?
boardId = prevRound->boardId;
// You can reach Lv. 8 if you're at Lv. 5 or higher now and if in each of
// the last 5 rounds you:
// - Did not lose
// - Flipped at least 8 cards
if (LEVEL_AT_LEAST(boardId, 5)) {
for (i = 0; i < 5; i++) {
RoundSummary *round = &game->boardHistory[i];
if (round->cardsFlipped < 8 || round->roundOutcome == ROUND_OUTCOME_LOST) {
break;
}
}
if (i == 5) {
return 0; // Lv. 8
}
}
Well, there you have it - I guess you can enter level 8 if and only if you’re at level 5 or higher AND each of your last rounds have been wins where you flip at least 8 cards. (These comments are making my job EASY!)
if ((LEVEL_AT_LEAST(boardId, 7) && prevRound->cardsFlipped >= 7) || (LEVEL_AT_LEAST(boardId, 6) && roundOutcome == ROUND_OUTCOME_WON)) {
return 1; // Lv. 7
}
if ((LEVEL_AT_LEAST(boardId, 6) && prevRound->cardsFlipped >= 6) || (LEVEL_AT_LEAST(boardId, 5) && roundOutcome == ROUND_OUTCOME_WON)) {
return 2; // Lv. 6
}
if ((LEVEL_AT_LEAST(boardId, 5) && prevRound->cardsFlipped >= 5) || (LEVEL_AT_LEAST(boardId, 4) && roundOutcome == ROUND_OUTCOME_WON)) {
return 3; // Lv. 5
}
if ((LEVEL_AT_LEAST(boardId, 4) && prevRound->cardsFlipped >= 4) || (LEVEL_AT_LEAST(boardId, 3) && roundOutcome == ROUND_OUTCOME_WON)) {
return 4; // Lv. 4
}
if ((LEVEL_AT_LEAST(boardId, 3) && prevRound->cardsFlipped >= 3) || (LEVEL_AT_LEAST(boardId, 2) && roundOutcome == ROUND_OUTCOME_WON)) {
return 5; // Lv. 3
}
if ((LEVEL_AT_LEAST(boardId, 2) && prevRound->cardsFlipped >= 2) || (roundOutcome == ROUND_OUTCOME_WON)) {
return 6; // Lv. 2
}
return 7; // Lv. 1
And here we have the interesting bits - If you’re at least on level X and have flipped X cards OR you’re on level X - 1 and won the round, you get put to level X. OTHERWISE, you get sent back to level 1.
Immediately my strategy has been updated - Historically, I’ve avoided flipping cards which are obviously 0’s because I didn’t think there was value in doing so (as any reward multiplied by 1 returns the same
reward). However, there is very clear value in flipping over cards that are 100% guarenteed to be 1’s if it means protecting the progress of your game.
Updating Our strategy
Now that we understand the gameplay a bit more, we can already update our strategy to hugely reduce the chance of losing progress. When facing a decision with unknown outcome, we can more confidently take the risk if we have already flipped the number of cards required to protect our progress. This means that until we hit that number of cards, it is always worth it to flip 100% known number cards. Now we just need to identify how quitting early works and then we should be able to identify an optimal strategy.
Quitting early
In this same project, we can find the state machine used to represent the game in the file voltorb_flip_workflow.c and voltorb_flip_workflow.h:
Here we can find the possible states that make up our workflow
typedef enum Workflow {
WORKFLOW_UNK_0,
WORKFLOW_NEW_ROUND,
WORKFLOW_SELECT_MAIN_MENU,
WORKFLOW_SELECT_GAME_INFO,
WORKFLOW_HOW_TO_PLAY,
WORKFLOW_HINT,
WORKFLOW_ABOUT_MEMO,
WORKFLOW_RENDER_BOARD,
WORKFLOW_AWAIT_BOARD_INTERACT,
WORKFLOW_FLIP_CARD,
WORKFLOW_UNK_10,
WORKFLOW_AWARD_COINS,
WORKFLOW_REVEAL_BOARD,
WORKFLOW_UNK_13,
WORKFLOW_UNK_14,
WORKFLOW_QUIT_ROUND,
WORKFLOW_UNK_16,
WORKFLOW_TERMINATE = 65534,
WORKFLOW_NONE = 65535,
} Workflow;
And here is the game’s representation of the workflow:
// The WorkflowEngine starts with the first non-NULL task provided.
typedef struct VoltorbFlipWorkflow {
// Optional. Runs over multiple frames.
VoltorbFlipTask task1;
// Optional. Runs over 1 frame.
VoltorbFlipTask task2;
// Required. Runs over multiple frames.
VoltorbFlipTask task3;
// Optional. Runs over 1 frame.
VoltorbFlipTask task4;
// Optional. Runs over multiple frames.
VoltorbFlipTask task5;
} VoltorbFlipWorkflow;
Each state consists of a series of tasks that get executed in order. This means that using this information, we just have to track down the tasks that make up our workflow WORKFLOW_QUIT_ROUND and then we can understand the game behavior!
[WORKFLOW_QUIT_ROUND] = { PrintAreYouSureYouWantToQuit, ov122_021E6900, AwaitQuitYesNoSelection, ov122_021E69DC, NULL },
Alright - So the first thing that happens (over multiple frames, not that its important to our goals) is the printing of “Are you sure you want to quit?”. Then we do something and then we await the user’s input, do one more thing
and then we’re done! That tells me that the details we care about are all contained in the function ov122_021E69DC since thats the only thing that happens after the user decides what to do.
BOOL ov122_021E69DC(WorkflowEngine *workflow, VoltorbFlipAppWork *work) {
YesNoPrompt_Reset(work->unk13C);
return TRUE;
}
Well, this isn’t what we’re looking for - this appears to just be used to reset the selector after user. Lets go back one step to AwaitQuitYesNoSelection!
BOOL AwaitQuitYesNoSelection(WorkflowEngine *workflow, VoltorbFlipAppWork *work) {
int var1 = YesNoPrompt_HandleInput(work->unk13C);
switch (var1) {
case 1: // YES
int payout = GamePayout(work->game);
SetRoundOutcome(work->game, ROUND_OUTCOME_QUIT);
if (payout == 0) {
BgClearTilemapBufferAndCommit(work->bgConfig, 3);
ov122_021E78B4(&work->unk25C);
EnqueueWorkflow(workflow, WORKFLOW_REVEAL_BOARD);
} else {
EnqueueWorkflow(workflow, WORKFLOW_AWARD_COINS);
}
return TRUE;
case 2: // NO
BgClearTilemapBufferAndCommit(work->bgConfig, 3);
ov122_021E78B4(&work->unk25C);
if (work->unk238 != 0) {
EnqueueWorkflow(workflow, WORKFLOW_UNK_13);
} else {
EnqueueWorkflow(workflow, WORKFLOW_AWAIT_BOARD_INTERACT);
}
return TRUE;
}
return FALSE;
}
Perfect! Here we can see that if the player does in fact select quitting as their option, we pay them out, we set the round outcome to ROUND_OUTCOME_QUIT (note - this is NOT ROUND_OUTCOME_LOST) and then we
either reveal the board, or show the coin reward screen. Because I wanted to ensure that there were no other consequences to the ROUND_OUTCOME_QUIT status I did a quick search through the code for all mentions.
There are two locations where this value is used:
/1. voltorb_flip_game.h where it is defined
ROUND_OUTCOME_NONE,
ROUND_OUTCOME_QUIT,
ROUND_OUTCOME_WON,
ROUND_OUTCOME_LOST,
} RoundOutcome;
- voltorb_flip_game.c in the
AwaitQuitYesNoSelectionfunction we just looked at
int var1 = YesNoPrompt_HandleInput(work->unk13C);
switch (var1) {
case 1: // YES
int payout = GamePayout(work->game);
SetRoundOutcome(work->game, ROUND_OUTCOME_QUIT);
Despite its mention only twice in code, there is one other location where it meaningfully could impact strategy. If you remember the calculations we do for moving someone to level 8 in CalcNextLevel, before granting access to
level 8, we check a player’s history for two things:
if (LEVEL_AT_LEAST(boardId, 5)) {
for (i = 0; i < 5; i++) {
RoundSummary *round = &game->boardHistory[i];
if (round->cardsFlipped < 8 || round->roundOutcome == ROUND_OUTCOME_LOST) {
break;
}
}
if (i == 5) {
return 0; // Lv. 8
}
}
Firstly, we check if they’ve flipped fewer than 8 cards, secondly we check if they have a round outcome of lost. If either of these conditions are met, then we break out of this codepath and do normal level calculations.
How much is the secret level worth?
After about 2 hours of trying on my ds copy of the game, I was not able to get a game from start to finish of levels 1-5 without getting a loss. In this experimentation, I would typically be forced to guess before I had already flipped 8 cards. Doing this meant a large proportion of my games was spent in the level 1-4 range, which didn’t really yield that much money. This feels to me like I would have been better off by just continuing to guess with no resets as the time spent resetting and starting back at the lower levels was just not worth it. The only way I could change this piece of my strategy is to improve the accuracy of my gameplay, however experimentation I have done, I’ve used the voltorbflip.com solver, which I have assumed is mathematically making optimal decisions, however I’ll need to investigate the board generation logic and the problem solving logic to see if things match. This will have to be a subject of another blog though