Way more than anyone wants to know about Round 3

Caveat 1: This post is about Round 3 of Huntinality. Huntinality is a puzzle hunt (now over) run by the team Cardinality, which I am a member of, and viewable at https://huntinality.com. I have a very “the information you want is probably somewhere in here because everything is somewhere in here” writing style, but I hope this is well-organized enough that if you're wondering about a specific thing you can find it quickly enough.

Caveat 2: I had finals about 2-3 weeks before hunt starts. Others picked up a lot of slack then. Thus, stuff done then isn't brought up as much, and this document is a lot more focused on stuff I personally did than the actual round is. The character images and audio are especially glossed over. I say "we" almost everywhere unless something was very especially my responsibility (reading through, it looks like the stupid decisions, e.g. the initially impossibly hard minipuzzles, were me and the good decisions/fixing of the stupid decisions were everyone else).

Game design

Generally speaking, this game owes the most to Antimatter Dimensions, for which I am a tester, and to a lesser extent Synergism and Cookie Clicker. The UI layout generally is pretty similar to that of Antimatter Dimensions (grids of buttons/achievements, a table of producers of the main currency).

Math of the game

Our basic idea was to have a number of multipliers that were polynomial in WarioCoins (that is, WarioCoins to some small power) that didn't quite multiply out to WarioCoins itself, and some smaller multipliers that only grew logarithmically or were constant to fill the gap. The multipliers which are polynomial in WarioCoins are boosts (roughly WarioCoins^0.3), the Goomba upgrade multipliers (slightly more than WarioCoins^0.4), and the multiplier from unspent Goombas (WarioCoins^(1/6)). The remainder was roughly WarioCoins^0.1 (times a constant factor), in which all the other multipliers (for example, accelerants, achievements, tasks) fit. This basic balancing scheme was there more or less from the start. The most important balancing change was probably to remove a large multiplier from solved puzzles (10^(number of solved puzzles)). We did this because the first few testers found that not having the multiplier made progress very annoying if you weren't solving puzzles. The main compensation for this multiplier removal was an increase in the exponent on the multiplier from unspent Goombas. Since the game was also originally too slow, there were also other increases to constant multipliers, such as an increase to the per-achievement multiplier and a 2x multiplier per Goomba upgrade.

The rest of this section is some random math notes, without much organization. Hopefully if you have a math question about the game it's answered somewhere in them.

Accelerants and kickstarters weren't used in any puzzle; they were added to avoid the optimal liquidation strategy being to liquidate as fast as possible. Without them, WarioCoin gain, and thus Goomba gain, would for a long time be too slow to support long liquidations as a viable strategy. (The formula for Goomba gain is (highest WarioCoins held at once this liquidation / 1e10)^(1/3), which requires WarioCoin gain to be cubic to support long liquidations. This does eventually happen but it requires almost all the Goomba upgrades.) However, since accelerants reset on liquidation, WarioCoin gain over a liquidation has a faster growth rate and long liquidations become viable. However, since you still have to liquidate sometimes, these "long liquidations" don't become longer than a few minutes until after 1e100 WarioCoins, where the puzzle unlocks end.

An oft-asked question is when to spend Goombas and when not to. The production multiplier from Goombas is proportional to their square root. Since most things you buy with Goombas have a significant effect, spending Goombas on something is usually good unless it costs 90% or more of your Goombas. However, kickstarters are fairly weak, so buying them whenever you can is not so wise (as an achievement name eventually says). You get an overall multiplier of about (1 - fraction of Goombas spent on 1 kickstarter / 2)(1 + accelerant effect power / number of kickstarters) per kickstarter (first term from production multiplier from Goombas, second term from extra accelerants), so you want (fraction of goombas spent on 1 kickstarter / 2) to be (accelerant effect power / number of kickstarters). In reality, you don't lose that much from letting fraction of goombas spent on 1 kickstarter be 1 / number of kickstarters (it's not exactly optimal but if you look at the actual numbers it ends up being within a few percent of optimal). In fact, you can even not buy kickstarters at all for quite some time and it could be hard to notice. However, a lot of this is moot before 1e100 because buy max is the quickest option and isn't a catastrophe, so when you're rushing through everything it's preferable.

Goomba upgrades give boosts exponential in number of miners of certain types, as has been mentioned above. This means that pretty quickly, the main criterion in buying a miner below Miner 8 is whether you have a Goomba upgrade giving a boost based on it. Indeed, far enough into the game, the main advantage of buying even Miner 8 is the extra Goomba upgrade multipliers, rather than the direct extra WarioCoin production.

The initial miner properties are initial costs powers of 10 (from 10 until 1e8), cost increases 1.11, 1.12, ..., 1.18, and production per purchase per second 1, 5, 25, ..., 5^7. This meant that once boosts become affordable, getting enough WarioCoins to get the next miner is more or less a repeating pattern (until you get Miner 8); the next miner costs 10x as much, exactly compensated for by the 5x extra production and the 2x boost.

Endgame

A lot of teams got over 1e200 WarioCoins, which takes a few hours. The top team got around 1e234. The JS Javascript builtin number limit is slightly above 1e308, which we knew would take several months to reach manually (from our testing it), so we weren't too worried about teams reaching this limit. Above 1e120, the game reaches a sufficiently slow pace that the optimal strategy for progress is mostly playing the WarioCoin minigame over and over, because even though it's hard to play the minigame and do something else at the same time, there's almost nothing else to do in the game. This is fairly active, boring, time-consuming, and soul-crushing, and some teams complained about it. (This is kind of a metaphor for real-world cryptocurrency mining, except that's done by computers rather than people, but we weren't really thinking of it as such when we made the puzzle). Also, we learned that the minigame does not work on mobile; we didn't know this before but weren't shocked, and we didn't know exactly what to do to fix it or want to take the risk of breaking something else at the same time. In the end, we hoped that the minigame being necessary to be at the top of the leaderboard didn't sour anyone's experience.

Puzzles

The puzzles are described in unlock order. We first thought of places we could put puzzle content (even before really thinking about the game itself), then put puzzles there while making the game.

General setup

We used Vue.js for doing UI updates every tick. We didn't do this in a particularly idiomatic way, but it worked well; we didn't get any reports of the game lagging, and all the changing numbers were correctly updated without us having to think about it that much. However, we could have used React instead, which most of the members of our team were more familiar with; we used Vue.js because I'd previously worked with it in making other similar games, but in retrospect if we do this kind of thing in the future we'll probably use React just for familiarity's sake.

We didn't use any other JS libraries, except for the ubiquitous jQuery. Some typical libraries for incremental games are break_infinity (a fast big number library) and, as mentioned above Antimatter Dimensions Notations; in game design we decided not to make the numbers in the game big enough to need break_infinity, and given the game's limited notation options Antimatter Dimensions Notations rather than a hand-crafted implementation seemed like overkill.

Characters

We decided to theme this puzzle more heavily and add characters from various media to it. This got rid of some complaints about insufficient theming and allowed for more hinting at minipuzzle answers/solve paths. It did lead to some teams going down a character/puzzle association rabbit hole in testsolving, which we tried to fix by having multiple characters per puzzle in a sufficiently chaotic manner as to stop teams from concluding anything (which seems to have almost entirely worked). We also made a wonderful round art picture of even more character that we didn't include in puzzles.

Characters

The primary concern we had with having so much logic on the client-side was that someone could manipulate their gamestate to progress faster than should be possible. Obfuscating the code was mostly a means to making progress manipulation harder; the puzzle answers weren't embedded in the code, so we didn't worry extremely much about a team seeing content they hadn't unlocked (though we also didn't want it to be trivial), but being able to read the code seemed like it might also make some gamestate manipulation methods easier.

Unfortunately, there were a lot of ways to manipulate gamestate. Among the ones we considered were using the console directly, using the debugger to stop the code at some breakpoint and then using the console, changing the save stored in localStorage, sending fake save HTTP requests to the server, manipulating computer time to skip waiting, and changing JS builtin methods that the game used (such as Math.pow). We only handled some of these methods, due to the round being sufficiently short that cheating would not give a team much benefit.

To avoid using the console directly being helpful, we put all the code in a function closure. (During internal testing a week before the hunt, however, Akira realized that copying the inside of the function and pasting it in the console would allow for calling game functions, even if not for cheating directly.) To avoid changing the save stored in localStorage or sending fake save HTTP requests to the server being helpful, we stored saves in a way that was nontrivial to decrypt and obfuscated the save-parsing code (as with the rest of the code). Since the obfuscated code was all on one line, the easiest way to place a breakpoint in most browsers (clicking on a line of source code) didn't work, but one team, after completing the hunt, placed a breakpoint in another way, which made progress manipulation fairly easy. We didn't do anything about time manipulation (but we don't think any team used it) or, generally, JS builtin methods (however, most JS builtin methods that were used, like Math.pow, were used more than once, so changing them would be more likely to break the game or affect progress unexpectedly than do the desired thing).

As for the code obfuscation itself, we wanted to hide the many string templates in the code (used by Vue to generate elements), and to also hide game property names and function names. Since the JS obfuscation libraries we found didn't obfuscate strings, we used a JS parser/generator to generate the code's syntax tree, recursively descend through the tree renaming variables and transforming strings into concatenations of character lists, and then re-generate code from the modified syntax tree. We then ran the result through a minifier (checking by hand that it didn't undo our obfuscation) to modify the code's structure slightly more, and put it in a function closure. To obscure some especially important stuff at the start of the code, we added an implementation of the Strassen algorithm to the start of the code; this was unused, but we hoped it would distract teams that tried to read the code from the start. (Sorry for not including a more optimal matrix multiplication algorithm, but those are pretty complicated and I can write nothing at 150 LOC/h). We considered instead including apparent puzzle content that wasn't actually used, and actually added some such content at some point, but we decided this would draw teams' attention to the code and was thus a bad idea.