I play on several platforms, but every store only recommends games based on what I did there. Steam had no idea what I'd finished elsewhere, so its suggestions kept missing. I wanted one place that knows my whole library, so I built it: NXTO, a backlog tracker with its own recommendations.
The stack is boring on purpose: Vue 3, Express, SQLite via better-sqlite3. Game metadata and tags come from RAWG. The interesting part was making the recommendations not suck.
First attempt: Jaccard on tags
The obvious approach: each game has tags, and two games are similar if their tag sets overlap. Jaccard similarity does exactly that.
It looked fine until I read the results. Games matched on filler tags like 2d, atmospheric and rpg, which are shared by thousands of titles. The tags that actually define a game, like farming or souls-like, were drowned out.
Fix 1: IDF weighting
Borrowing from search: a rare tag should count more than a common one. I weight every tag by inverse document frequency and compare games as vectors with cosine similarity. Two games sharing souls-like now look far more alike than two games sharing 2d.
Fix 2: the long tail of junk tags
IDF created a new problem. The catalog has about 48k distinct tags, and 90% of them occur fewer than 10 times: typos, one-off labels, joke tags. Under plain IDF those rare tags score higher than any real tag. One tag with a document frequency of 1 got a weight of 8.5 and hijacked the cosine.
The fix is a minimum document frequency (MIN_DF = 5). Anything below the floor is dropped from the vocabulary entirely.
Fix 3: ratings are signal, not just "finished"
Finishing a game doesn't mean you liked it. I turn the user's rating into a taste weight:
| Rating | Weight |
|---|---|
| 5★ | +2.0 |
| 4★ | +1.5 |
| 3★ | +0.6 |
| 2★ | -1.0 |
| 1★ | -2.0 |
| unrated | +0.8 |
3★ means "fine, not a signal", and 1-2★ actively push the taste vector away from similar games. A user's profile is the weighted sum of their games' tag vectors.
I also only rank games with at least 10 ratings. Below that, the catalog is full of unrated jam entries that no ranking can order honestly.
Caching without serving stale results
Scoring a whole catalog per request is expensive, so I cache the sorted pool per user. The key includes MAX(updated_at) and the row count for the user's games. Both are needed. updated_at has one-second resolution, and deleting a game doesn't move the maximum at all, so a timestamp-only key would keep serving a pool that still contains the game you just removed.
I also bump an ALGO_VERSION in the key whenever scoring changes, so a deploy never serves pools built by the old algorithm.
What I'd do differently
- Look at the output early. The tag problem was invisible in the code and obvious in the results.
- Treat the vocabulary as data to clean, not just math to tune.
If you track a backlog, I'd love to know what it recommends for you. And if you've built a recommender, what did you do about noisy tags?
Try it: nxto.app
Top comments (0)