KSSU Splits Timer GUI Challenge
Recently, I’ve been trying to flex my GUI-building skills by writing a speedrunning timer for Kirby Super Star Ultra’s True Arena boss rush mode. My guess is this is completely meaningless to most people, and that’s okay—you don’t need to know any of that to implement what I’m going to describe. I do want to go over the basics so that you understand the problem I’m trying to solve.
The True Arena is a boss rush mode where the objective is to get the best time.
You always fight the same 10 bosses, but the first 6 are fought in a random order each time. The final 4 bosses are always fought in the same order.
Time is tracked using an in-game timer that stops between bosses. This is convenient, since it means a player can stop between each boss to enter their time.
- Times are tracked in a
YYis seconds, and
ZZis hundreths of a second.
- Times are tracked in a
I want a timer that automatically keeps track of my best time per boss. This is useful, since the game only displays the running total in the in-game timer, and since the first 6 bosses are fought in a random order, it can be tricky to know if I am ahead or behind my average time for a given run.
Hopefully that all makes sense. With that out of the way, let me describe the basics of what the application should do.
To solve this problem, I want an application that automatically supports entering these times and running the relevant calculations. I want it to show me what my best times are for each boss, and during a run, I want to know if I’m ahead or behind.
Before getting into the details, here are a couple additional general points of note:
The application itself should be “graphical”, but my definition of a graphical application is quite loose. It can be a truly graphical application that runs in a native windowing system, it can be a webapp, or it can even be an ncurses-style terminal application. I don’t really care, so long as the interface generally resembles what I describe.
To keep the problem simple, I don’t care about persistence at all. On application startup, you can assume the user has not entered any times yet, and while times should be saved in-memory between runs, once the application is closed, it’s fine to discard all of the current records.
The application generally consists of two panels: a splits display, and a menu. When the application starts up, it should look something like this:
┌─────────────────────────┬────────────────────────────────────────────────────┐ │ Splits │ Menu │ │ │ │ │ Boss 1 (none) │ > Start Run │ │ Boss 2 0:20:47 │ Quit │ │ Boss 3 (none) │ │ │ Boss 4 (none) │ │ │ Boss 5 (none) │ │ │ Boss 6 (none) │ │ ├─────────────────────────┤ │ │ Boss 7 (none) │ │ │ Boss 8 (none) │ │ │ Boss 9 (none) │ │ │ Boss 10 (none) │ │ ├─────────────────────────┤ │ │ sum of best (none) │ │ │ │ │ └─────────────────────────┴────────────────────────────────────────────────────┘
The left hand side displays the best times per boss and a total. Since times may not exist if the user hasn’t gotten to that boss yet, some of the times may display
(none), but once the player has completed the boss rush once, all of the times should be filled in.
On the right hand side should be a menu. This menu is not particularly complex or interesting, but it should be simple to navigate. The menu should be navigable using the keyboard, since a user will want to quickly enter times, though having mouse support is fine, too.
Once a run is started, the application switches into a different mode, an “active run mode”. The base screen for that mode should look like this:
┌────────────────────────────────────┬─────────────────────────────────────────┐ │ Splits │ Menu │ │ │ │ │ Boss 1 (none) │ > Enter Time │ │ Boss 2 0:20:45 +0:00:02 │ Cancel Run (& save new bests) │ │ Boss 3 (none) │ Discard Run (& undo bests) │ │ Boss 4 (none) │ │ │ Boss 5 (none) │ │ │ Boss 6 (none) │ │ ├────────────────────────────────────┤ │ │ Boss 7 (none) │ │ │ Boss 8 (none) │ │ │ Boss 9 (none) │ │ │ Boss 10 (none) │ │ ├────────────────────────────────────┤ │ │ sum of best (none) │ │ │ │ │ └────────────────────────────────────┴─────────────────────────────────────────┘
The left panel should expand to show a new column, which represents the differences between the player’s best run and their current time, per boss. For the first six, this can be a tad confusing, since the diffs can occur in any order, but I’m fine with the UX being a little odd for that.
On a bad run, it is likely the user will likely want to quit the run early and start over, but if they had any best times for a certain segment, they should be able to save them. The
Cancel Run option does that, and it returns to the main menu with any new bests “committed”.
If a run is bad because the times entered are somehow invalid, the
Discard Run option returns to the main menu and throws away any best segments. It would be nice to offer a finer-grained way to fix improper times, but this is fine to start.
The main option is the
Enter Time menu, which allows the user to log the current time on the in-game timer, as the name implies. If the user has not yet completed five bosses, this should proceed to the Boss Selection menu, otherwise it should proceed directly to the Time Entry screen for the relevant boss.
When a user selects
Enter Time during a run, but they have not yet completed five bosses, the application should prompt the user to ask which boss they want to log a time for:
┌────────────────────────────────────┬─────────────────────────────────────────┐ │ Splits │ Menu │ │ │ │ │ Boss 1 (none) │ > Boss 1 │ │ Boss 2 0:20:45 +0:00:02 │ Boss 3 │ │ Boss 3 (none) │ Boss 4 │ │ Boss 4 (none) │ Boss 5 │ │ Boss 5 (none) │ Boss 6 │ │ Boss 6 (none) │ │ ├────────────────────────────────────┤ │ │ Boss 7 (none) │ │ │ Boss 8 (none) │ │ │ Boss 9 (none) │ │ │ Boss 10 (none) │ │ ├────────────────────────────────────┤ │ │ sum of best (none) │ │ │ │ │ └────────────────────────────────────┴─────────────────────────────────────────┘
Note that the menu should not display any bosses the user has already logged a time for.
Once a boss has been selected, this should proceed to the Time Entry screen.
The most important piece of functionality for the application is being able to actually enter times. This screen can be extremely simple:
┌────────────────────────────────────┬─────────────────────────────────────────┐ │ Splits │ Menu │ │ │ │ │ Boss 1 (none) │ Entering time for: Boss 4 │ │ Boss 2 0:20:45 +0:00:02 │ │ │ Boss 3 (none) │ > ___:__:__ │ │ Boss 4 (none) │ │ │ Boss 5 (none) │ Save Time │ │ Boss 6 (none) │ Cancel │ ├────────────────────────────────────┤ │ │ Boss 7 (none) │ │ │ Boss 8 (none) │ │ │ Boss 9 (none) │ │ │ Boss 10 (none) │ │ ├────────────────────────────────────┤ │ │ sum of best (none) │ │ │ │ │ └────────────────────────────────────┴─────────────────────────────────────────┘
The user interface should provide a time-entry field that supports the standard
XXX:YY:ZZ format, and it should only allow numbers. If the user selects
Save Time, the time for that boss should be saved, and the application should return to the main Active Run menu, unless that was the time for the last boss, in which case the run is finished, and it should return to the main menu. If the user selects
Cancel, the application should return to the main Active Run menu without saving any times.
Remember that the user enters their times as a running, cumulative total (since that’s what’s displayed on screen), but boss times must be tracked per-boss. Therefore, you’ll have to do some trivial time arithmetic to calculate the relevant diff for the current boss.
In terms of specifications, that’s everything you need to worry about. There’s nothing else! However, in order for me to consider this a satisfactory solution to the problem, you’ll want to keep in mind the following constraints:
No boilerplate. Obviously, this is a little subjective, so use your best judgement, but ideally components should “just compose” without needing unnecessary plumbing. This means a giant, top-level function that dispatches events to every sub-component will be strongly frowned upon. Similarly, the need to manually tag components with unique, user-specified names is illegal.
Single source of truth for all state. “Synchronization” logic where two components duplicate a piece of state and have to manually keep it in sync is not permitted.
Separation of rendering logic from state updates. Rendering logic should be entirely composed of pure functions from state to some pure notion of a view. If some imperative drawing logic is absolutely required, it is possibly alright, but it must be pure “in spirit”, and it cannot mutate the application state, only “presentation” state.
Minimal duplication. Alternately, component encapsulation. The “menu” component is used frequently, and ideally it should be possible to write a generic menu component that can be embedded any place in the application without needing to duplicate code. A menu “component” that can be “instantiated” multiple times for each screen is preferable to a single component that keeps track of its current options as a piece of state. (Both approaches are technically fine here, but the former demonstrates a much more flexible component model.)
Declarative navigation. Navigating between application states should be done via a notion of state transition, not by manually adding/removing components directly. In this sense, state transitions should be decoupled from rendering logic and the notion of a “component hierarchy”, since the component hierarchy should ideally be a function of state.
It’s okay if a state transition results in a manipulation of the component hierarchy, but this should be kept as an implementation detail from the perspective of the state processing code.
No spaghetti code. This should be obvious, but it’s important that the application is structured in a way that allows it to be extensible without drastically altering other pieces of code. Whether or not this is possible is a good test of the above requirements.
For example, adding a new subscreen to the application should only involve touching the component’s direct parent if it does not need any new shared state. It should not require changes to the entire hierarchy of components above the one being added, and it definitely shouldn’t touch any sibling components.
Another thing to avoid is event spaghetti, where components receive huge numbers of events that are irrelevant to them, but must ignore them or pass them to parent or child components as part of their processing. Components should ideally recieve precisely the information they need, but no more or less.
Many of these things are subjective, so obviously, use your best judgement. Just try and demonstrate that the code is modular, testable, extensible, and understandable.