← All posts

The beginnings of Unravel

  • Tech

As a TypeScript and React developer, somehow, Rust is the language you hear about almost every day. More and more of your tooling gets rewritten in it, and yet I had never touched it. With some minimal leftover C++ skills from college, it was about time to start a small project to see what's up.

After stumbling on Boxcars on GitHub, the subject picked itself. I play Rocket League on and off and have been doing that for about eight years now. Ranking up is hard, and losing tells you almost nothing: the scoreboard gives you goals, assists, saves and shots, and that is it. Meanwhile the replay file sitting on your disk holds every frame of what actually happened. Numbers to improve on were right there, just not in a form anyone could read.

Boxcars and Tauri

Boxcars is a Rust crate that decodes the replay format and hands you the data. What to do with it was still open, and a terminal dump of structs is not something you show a player.

Then I found Tauri, which settled the architecture. Rust does the parsing in the backend, a normal React frontend renders it, and the two talk over a single invoke call. Rust where I wanted to learn, TypeScript where I already live, and no browser runtime shipped alongside it, so the app is a few megabytes instead of a few hundred.

A replay has two halves. The header holds the match summary and decodes in milliseconds. The network stream holds every actor update of every frame and takes seconds. The first prototype read the header only, and within two days it could show map, score, playlist and a player table.

Unravel is built with Claude Code, mostly running Opus 5, on both the Rust and the TypeScript side. Rust is not my home turf, and having something write a first draft of a parser while I think about what the product should be is a big win. It does not lower the standard. I inspect every line before it lands, plenty gets rewritten, and actually, most of it gets thrown out entirely. A model will happily produce something that compiles, passes its own tests, and misses the point completely. Producing the correct thing and keeping it maintainable is still the job.

The first thing thrown out was kickoff detection. Everything interesting lives in the network data, so it went in by hand: walk the frames, work out who won each kickoff. Claude wrote the first draft and it worked, on the replays I pointed it at. Getting it to work on the rest was where the evening went, and every fix meant deciding the game rules myself: which actor is the ball, which car belongs to whom after a rejoin, when a kickoff actually starts. None of that gets easier with the second metric, and I could already see myself maintaining a pile of hand-tuned frame heuristics for every stat in a language that was new to me. Hope drained fast, and that version lasted three days.

Enter subtr-actor

subtr-actor already did all of it. It turns raw actor updates into a frame-by-frame view and ships around thirty-five stat collectors on top, kickoffs included. Adding a metric went from days of frame archaeology to adding a module name to a list and writing a small adapter for the frontend.

Results on my own replays came quickly after that, and drawing them was suddenly cheap. A screen of donut charts (kickoffs won, boost usage, time spent in each third) gave me more insight into my own matches in an evening than years of watching the scoreboard had.

And then it stalled. By version 0.1.4 the app could tell you that you won 4 out of 7 kickoffs, but not whether that is good.

Building a baseline

This part finally involved writing Rust rather than calling other people's. Nothing frightening, but a resumable batch pipeline over tens of thousands of files is a different exercise than a single parse call, and it is where most of the language actually entered my brain.

The raw material came from Kaggle: around 120,000 replays across 1v1, 2v2 and 3v3, from high-level play, SSL and GC3 territory. Way too much disk and far too many hours of parsing to be practical, so it had to be cut down.

Two things decided where to cut. Recency beats volume, because high-level Rocket League moves and a match from three seasons ago is not the standard anyone is held to today. And you want enough samples for percentiles to stop wobbling, not one more. Balancing the two, each mode keeps its 3,000 most recent replays, 9,000 in total. That is between 6,000 and 18,000 individual player samples depending on team size, with the cutoff date as recent as that sample size allows.

Building it runs in two passes, because the slow part and the part you keep tweaking are not the same. The collect pass parses every replay in full and writes one line of metrics per replay to a cache, about 8 hours of work. It is resumable, a feature you add right after a run dies at replay 2,400. The reduce pass reads that cache and collapses it into one small JSON file per mode in seconds, so the reduction stays cheap to iterate on.

The headline figure is the median rather than the mean, so one absurd match cannot drag a stat upward, with the 25th and 75th percentiles around it to show the shape. A 3-minute stomp and an 8-minute overtime game cannot contribute raw totals to the same average, or match length becomes the strongest signal in every stat. So every value is normalized by match length before it is reduced, and the frontend applies the exact same rules to your match before comparing.

Where are we now?

The first public beta shipped on 21 August with local parsing, a match summary and a player table. The two weeks after that turned it into a tool: a sortable replay table, a folder watcher that picks up new replays while you play, in-app auto update, custom dashboards, and the baseline behind every number on screen. By 0.1.12 there was a stat explorer, impact tagging to surface the stats that swung a match, and opt-out crash reporting.

Parsing happens on your machine, replays are never uploaded, and the baseline is a file bundled into the build instead of a service you query. That will not change.

Next up is turning all those numbers into a single performance indicator, one you can read at a glance, which is what the rating work in 0.1.17 is about. More on that another time.

Find out which part of your game keeps you hardstuck.

Find out what's holding you back

Free · 100% local · No account

Windows & Linux · currently in beta