Exploratory Data Analysis: Boba Bounce
October 12, 2026
This last spring, I designed and administered Boba Bounce, a video game for Chun Yang Tea, the tea shop that formerly occupied Old Union next to TAP (now replaced by UMe Tea). The game was a simple runner with gameplay inspired by Geometry Dash—though obstacles were placed randomly, much like Google Chrome's offline dinosaur game. It was available to play only inside Old Union, near Chun Yang's counter + colocated with TAP.
Since Chun Yang wanted to reward the game's top players with free drinks each week, I had to maintain a leaderboard. As it happens, this process yielded lots of interesting data that I never took the time to analyze closely. (At the time, I had Claude vibe-code an analytics page with an interactive histogram for score distribution, but I didn't take the time to visualize the data myself until now.)
Before I took a closer look at the data, I thought of a few questions that might make for interesting visualization (and that I was curious about anyway):
- From what I could tell during its operation, the game seemed to attract "regulars" each week. To what extent did frequency of play correlate with game score?
- The game machine offered all players (regardless of score) the opportunity to take a picture to receive a free stylized sticker + digital copy. Were higher scorers more or less likely to accept the sticker offer?
- I'd heard from a couple friends that they'd camp out in TAP Sunday night to play the game repeatedly in order to win first place for the week. Were there measurably more plays in the late hours of Sunday compared to other days at the same time of night?
Before I begin, I'd like to give you a run-down on the data we're getting from the game:
- The data recorded covers roughly eight weeks of gameplay, spanning April 13, 2026 - June 11, 2026.
scores.csvcontains 2,829 scores, but only 158 of them are associated with a user ID. Why?- This CSV was assembled by a script that joined two tables -
leaderboard_entriesandkiosk_events. In theory, scores were only recorded when a user submitted their name to the leaderboard. But kiosk analytics (kiosk_events) recorded scores for every single game. - Where it was possible, the script mapped a score event to its corresponding leaderboard entry. The new
user_idfield is not an artifact of the database, but something the script created from the DB backup: it is a SHA256 hash of the user's email, s.t. we can identify unique users in the data without exposing their contact info.
- This CSV was assembled by a script that joined two tables -
- With this in mind, note that any visualization considering the number of times a given user played (as you'll see below) only considers users with
user_id !== undefined. Without a user id/leaderboard entry, the number of plays for an anonymous user is uncertain. This caveat is nontrivial, since the anonymous plays could plausibly represent users who played multiple times but did not care to submit their scores to the leaderboard.- Furthermore, an identified user in the dataset could (also) be represented among anonymous, if the user played at any other point without entering an email. This also means one real-life user could be represented multiple times in the identified/pseudonymous dataset, if they used multiple emails (e.g. in hopes of gaming the leaderboard to win multiple prizes).
To explore my first question, my initial (naive) idea was to construct a scatter plot with number of plays on the x-axis and score (points) on the y-axis:
Loading chart…
This approach quickly revealed itself to be problematic, or at least suboptimal, for a few reasons:
- The number of plays is (naturally) discrete, so the data look somewhat strange in a scatterplot.
- Overlapping points occlude one other and make it difficult to properly interpret the distribution for different values of (especially for given the density of scores).
As I considered my options, I realized a box plot might better fit my constraints. I could make a chart with one vertical plot for each number of plays, like so:
Loading chart…
Though it marked a clear improvement from the scatter, especially in honoring the discreteness of the x-axis, the plots for 3–6 plays still looked tiny and squished relative to the plots for 1 and 2 plays. What the figure doesn't reveal is that the data for 3–6 represent only one player per bucket. In other words, for buckets >2, each plot shows the distribution in one player's performance. It's interesting that each of these players had such a narrow spread (not incl. outliers, see the 5-player's high score).
Maybe we can combine those four (3, 4, 5, 6) into a single bucket:
Loading chart…
This is much more legible than the un-bucketed version of this visualization: we can now see that more plays are associated with higher median performance (score) in the game. Whereas scores submitted by players who only played once contain the absolute floor (near-zero score, lowest 149), the lowest 2-play outlier (score 767) is an outlier in its distribution.
And though only four individual players are represented, this visualization demonstrates clearly that the 3+ plays bucket is cut above the rest. In fact, it contains the game's highest score ever (see outlier just above 16,000 - shoutout "Magniac!").
Now, to the question of stickers: were higher scorers more or less likely to claim a sticker?
This data should be easy enough to pull together by lining up sticker_accept and sticker_decline events with their corresponding session_id (where available - not 100% of sessions had both gameplay and sticker events, since some folks walked away before answering the sticker prompt, so their session timed out).
My first thought was to create a tick plot (vl.markTick(), dot plot-like with but with ticks), coloring accept as green and decline as red.
Loading chart…
While this provided a rough idea of the distribution, I found it difficult to infer relative rates of acceptance in each rough segment of score. This led me to the idea of bucketing scores and then calculating sticker acceptance rate for each bucket. The chart for that idea is below:
Loading chart…
Okay, this is (maybe) promising! At score buckets of size 1,500, we see a decreasing-ish trend across the data. This is even generally true at buckets of 500, though we can more clearly see a dip in acceptances around the middle (6,000-7,000) followed by a weaker rise toward the right of the chart, with some notable bumps in between (e.g. 0/16 plays accepted a sticker in the 9,500-10,000 bucket).
(If you're following along on the website, feel free to drag the slider around and see for yourself how the buckets look/change!)
I decided to include an label above each bar to show how many scores contributed to each bucket. Without these, a reasonable viewer might assume that each bucket carries equal epistemic weight. In reality, less populated buckets might mean shakier evidence. Though the labels clarify this, I'll admit that the difference in weight is not perceptually straightforward, as the buckets are otherwise visually indistinguishable.
Though the percentages do not decrease monotonically, it is generally apparent (especially with coarser bin size) that higher score buckets tend to have lower sticker acceptance rates. This gives me two (not mutually exclusive) hypotheses:
- Casual players are less interested/skilled in the game than regulars/grinders; if they're more interested in the overall experience, they might not try as hard to get a high score.
- Players achieving top scores are grinding to achieve a spot on the leaderboard, and might not care to spend time making a(nother) sticker.
Both of these hypotheses remain untested, as repeat-player histories are too sparse in my dataset. If I were to record this sort of data in the future, I'd hope to find some other way to measure (or at least estimate) repeat plays without requiring leaderboard submission. I could have guessed repeat players from my dataset by looking at closely clustered score events, but that might conflate friends playing together/against each other. Maybe a "Play Again" button would have allowed me to confidently record multiple scores for one player?
With that, I'd like to take a closer look at some of the most competitive and successful players in the data.
In Boba Bounce, winners are the top two (2) players at the end of each week - first place won two free drinks, and second place won just one. (For purposes of this project, I will be treating first- and second-place winners as equivalent, since the data are already not many.)
My first question was whether I could find a pattern in winnings for each day of the week. Naturally, I did my best to visualize it.
Loading chart…
Interesting! Sunday, Monday, and Tuesday represented only one winner each, with Wednesday-Saturday representing over 80% of winners. This might be explained in part by that, e.g., for someone to win on Monday, they must not be displaced (from the top 2) by anyone for the rest of the week. But, then, why do winners level off again after Thursday?
It was at this point I realized I hadn't accounted for players on the leaderboard who didn't win:
Loading chart…
But even this visualization has a damning flaw, not in the output but in the data inside it. This factors in only entries that made it onto the leaderboard, not plays in general! So, here's yet another visualization that captures all three of
- winners for each day of the week,
- leaderboard entries for each day of the week, and
- plays for each day of the week, even if not entered onto the leaderboard.
Loading chart…
Maybe I should have seen this coming, but I did not expect this distribution at all. Leaderboard submissions are distributed quite differently from plays overall. Whereas submissions seem to trend down over the course of the week, plays increase monotonically through Friday, then dropping down on the weekend.
I cannot say for sure, but my educated guess is:
- Earlier in the week, the leaderboard is relatively empty, so players might feel more encouraged about submitting their score (even if it will be relatively low by the week's end).
- As the leaderboard becomes more saturated/competitive later in the week, people play more rounds to place on the leaderboard (or at least in hopes of doing so).
- Chun Yang Tea is closed on the weekends, so it makes sense that playership would drop off significantly on Saturday and Sunday. (TAP is open those days, but there is likely less traffic around the boba area.)
After the game concluded for the quarter (and Chun Yang closed forever, sadly), a couple friends confessed to me they'd wait until late Sunday night to grind the game in hopes of winning for the week. Can we actually see this in the data?
Looking back over the visualizations I'd already made, I realized I already had one that was almost perfect. The daily bar graph from earlier could work, but I'd need to limit the results based on the hour. That is just what I did:
Loading chart…
I encourage you to move the slider back while watching the frequency change. You'll notice that while Sunday is a slow day in the dataset at large (consistent with the non-slider version of this visualization earlier in my write-up), it is overrepresented in late-night gameplay. From 11 PM until the end of the night, Sundays have the second-most gameplays after Thursdays, despite being the least played day of the week overall.
To visualize the overrepresentation, I made a second visualization (also with slider) that shows the share of each day's gameplay that nighttime slice represents:
Loading chart…
After 5 PM, Sunday is the clear winner in share of plays recorded during that slice of the day. Perhaps my friends were right!
I'm not sure this strategy worked for those who played it - sadly, none of the 25 post-11 PM Sunday plays ever won a prize. This doesn't mean they didn't try! In any case, I am fascinated that the data encoded this overrepresentation, and that visualization helped reveal it.
Though I did not make use of LLMs in writing prose and designing visualizations, I used AI (mostly Codex) in the following capacities:
- Added Vega-Lite support to my Next.js SSG blog - nice, fluent
vl.authoring, (almost) inline fluent API support (like Observable notebooks) in MDX, including a custom TypeScript parser/language server extension for stress-free editing. - Commissioned a Python script to clean up the raw data I'd previously exported from Supabase before shutting the game DB down. The script's primary objective was to improve data quality (namely, deduping events from my terrible, vibe-coded analytics backend).
- Asking for hints and feedback as I wrote Vega-Lite code (mostly) by hand. I did not want to license any LLM to do this on my behalf, as I wanted Codex to be a tutor, not a complete stand-in for me. This was particularly useful, since most examples online covered the JSON format rather than the fluent-chain API we saw in the assigned notebooks.
For transparency, here is the full prompt I used to obtain my wrangling script:
Your task is to write a simple Python script (`wrangle.py`) whose task is:
- Read the `kiosk_sessions.csv` and `kiosk_events.csv` files in `supabase-2026-09-09/csv/`
- Build new CSV representations (you may use `pd.DataFrame` or similar) that records all sessions and events as follows, **written to new CSVs** in `wrangle_output/`:
- `sessions.csv` - ALL sessions, including ones that were never recorded as finished. Use the session start/end fields from `kiosk_events.csv` (as described below) where available; when not available, make a best inference as to the en of the session, based on the activity that followed. This will naturally have some uncertainty, so please add an `inferred` column (boolean) for us to know which session endings were merely guessed.
- `events.csv` - ALL events MINUS `session_start` and `session_end`, which should only be used in `sessions.csv` as described above. But do keep `session_id`, as this will function well as a sort of foreign key for `sessions.csv`.
- `scores.csv` - Scores as encoded by `kiosk_events.csv` and `leaderboard_entries.csv`. Try to line up a score event with a leaderboard event (by approx. timestamp) if there's a good candidate - use e.g. closest match, max 2 mins delta, otherwise do not associate that leaderboard entry with the score event. **Do NOT include emails as a column in `scores.csv`** - only show `player_name` to keep things pseudonymous. Note the deduping directive below.
While the tables should NOT store email or any unambiguous PII (save for `player_name`, which is not independently revealing), we should generate a deterministic but anonymous user ID for each email provided. This is important to track usage by the _same_ users across sessions. Include this in any table that originally held (or was, by foreign key, associated with) email. It doesn't have to be fully cryptographically secure - you could even do an MD5 hash of email and call it good enough.
**NOTE:** As you'll probably see, the data quality you are getting as input is pretty poor (sorry). As such, not only will you have to infer start/end dates, but you might have to dedupe events, e.g. score events (I believe I saw five adjacent rows somewhere with the exact same score, for pretty much the exact same time). Use your best judgment to, through the script you write, dedupe, filter, and coalesce redundant/spurious events.
If you need to install any dependencies (keep it minimal please), feel free to `uv venv` in this directory. In fact, I would prefer uv over plain pip + requirements.txt.
Upon completing the script, please run it once and read the data back to ensure it is as you expect. Iterate as needed to achieve the intended result.