KBO Deok (roughly "KBO fan cred"; KBO is Korea's pro baseball league) was originally built separately as a Toss mini-app. It's a baseball quiz bank of 1,400 questions — 300 on rules, 500 on teams, 300 on records, 300 on history — with XP, levels, achievements, a wrong-answer notebook, and weekly reports attached. Of the three Toss apps, it has the most content. The job was to move it from a mini-app that only runs inside Toss to a web page anyone can open straight in a browser. I didn't have to write new questions, just move them, so I thought it would be done quickly.
The screen just came up white
I started by stripping out the Toss SDK calls. Ads, sharing, and analytics logging were already wrapped in "is this a supported environment" checks and try/catch, so in the browser they passed quietly. The problem was routing. When I loaded it as moved, only the top bar and bottom tabs showed and the body was empty. The cause was not giving React Router a basename. The original app ran at the domain root, but now it runs under play.ghmun.com/kbo-deok/, and the router was still looking only for /. One line, basename="/kbo-deok", fixed it. Finding the cause took far longer than the one-line fix.
The back button killed the whole app
Once basename was fixed, this time it was a completely white screen without even the top bar. The culprit was the graniteEvent.addEventListener("backEvent", ...) call that hooks the Android back button up to in-app navigation. This function doesn't report failure through a callback; outside the Toss app it throws an error the moment it's called. Every other Toss SDK call was wrapped in try/catch, but this one spot was missed. That's why the whole app stopped before the screen was even drawn. I wrapped the call in try/catch and made it subscribe to nothing if it fails. Write defensive code carefully in ten places and miss one, and this is what happens.
Saving progress needed work too
User data was keyed by an anonymous identifier issued by Toss and stored in Toss storage. The web has no such identifier, so I switched to the browser's localStorage. I kept the shape of the existing async functions, so the rest of the code was barely touched. The trade-off is that records stay only on that device, in that browser.
Max level was designed to take 7 years
While moving it, I opened the level file and was a little surprised. Levels run from Lv.0 to Lv.99, and every 10 levels the title changes, from "Baseball Beginner" through "Ballpark Regular" and "Stats Nerd" all the way to "KBO Old-Timer." Required XP follows a squared curve, so early on you hit Lv.10 in a week, but even someone who does every mode every day takes about 7 years to reach Lv.99. The reasoning behind the calculation is even written in a comment. Do the True Fan Test, Today's Quiz, Team Focus, Category Master, Old-Timer Challenge, and the sharing bonus without missing a single day, and that's about 858 XP a day. 858 times 365 times 7: max level is 2.19 million XP. Even Lv.90 takes about 5.8 years. Someone who only does Today's Quiz will take much longer. The comment also says "There are no real users yet, so I didn't worry about compatibility with old XP." So a 7-year plan came before there were any users. I respect the confidence. There are 12 achievements, like "First Strikeout," "Review King," and "Weekly Fan," and 6 fan types from the True Fan Test results, from "Rules Doctor" to "Old-Timer." I didn't change a single one of these numbers when moving it to the web.
How to use it
Press "Check your fan cred with 10 questions" on the home screen to start the True Fan Test, 10 questions drawn at random from the four categories. In the "Explore" tab at the bottom, you can separately pick Today's Quiz, team quizzes, category quizzes, or the Old-Timer Challenge. Questions you get wrong collect in the wrong-answer notebook for review right from the home screen. Pick a team to root for and its questions come up more often, and a team card appears on the home screen. The ad slot on the results screen is empty on the web.