How the Algorithm Works

Note: the technical deep dive was written with help from Claude, working directly from the source code.
The recommender itself was designed and refined by yours truly.

In short: the site learns from everyone's ratings and guesses how you would rate each game. Then it removes what your filters exclude and shows you the best matches first.

Your ratings are the starting point

Everyone's ratings go into one shared model. The model looks for games that the same people like. For example, many people who loved Hollow Knight and Celeste also give Dead Cells a high rating. So if you loved those two, Dead Cells moves up in your list.

Only ratings count. The site does not use clicks, playtime or wishlists.

Your taste profile changes every time you rate

Your ratings, from Hated to Loved, show the model how your taste compares with everyone else's. This is your taste profile. It starts working after 8 ratings. After that, it updates every time you rate something, so your queue changes right away, and leans a little more on it with every rating. The shared model is retrained every three hours. From then on, your ratings also help other people's predictions.

Your prediction mixes your taste with the average

Every game has a sitewide average. It comes from ratings on this site and votes on IGDB, the public game database. The model also makes a personal guess from your taste profile. Your prediction mixes the two. If the model has seen many ratings for a game, the personal guess counts more. If it has seen only a few, the average counts more.

The average is careful with small numbers. A few fans cannot push a new game above a classic that many people love.

Sitewide average:
With only Loved ratings, a game needs about 74 of them to reach Amazing. Real games also have IGDB votes, so a popular, well-liked one reaches it much sooner.

Then two small personal changes are added. The first is your level. If you usually rate higher or lower than other people, the average moves to match you. The second is the genre adjustment. If you usually rate a genre higher or lower than your other ratings, games in that genre move up or down a little. Until you have 8 ratings, every score you see is the sitewide average. Discovery shows popular games that you probably know, so you have something to rate.

Your filters decide what can appear

Discovery does not show games that you have already rated, saved or dismissed. It also removes anything outside your settings: age, popularity, platform, Linux compatibility and content preferences. These are strict limits. If a filter removes a game, it stays out, even with a very high score.

Your queue shows the best matches first

The remaining games are sorted by prediction, highest first. You can change the results in a few ways:

Rate what you didn't like, too

A low rating teaches the model as much as a high one.

Use the whole scale

You can rate generously or strictly. But if almost all your ratings are 8, the model learns very little.

Not Interested and Not Sure only hide games

They do not change your taste profile. You can undo them on the skipped page.

Wrong kind of game? Use a filter

Too old, not popular enough, wrong platform? Change a filter and it is fixed at once. Rating more games will not fix it.

Imported games count only after you rate them

A Steam import adds your games. It does not tell the model what you think of them.

Reading the scores

On a game's page you can see three numbers:

  • Averages is the sitewide average, as the rankings use it. It is a little stricter with games that have few ratings, so it can be slightly different from the average your prediction starts from.
  • ML score is your full prediction, from 0 to 100. 67 is about Good, 78 is Excellent and 89 is Amazing.
  • Rated is the model's personal guess alone, before it is mixed with the average. It is not your own rating.

For a game with few ratings, the ML score and Rated can be very different. This is because the average still counts for most of the ML score.

The prediction slider: your taste or the crowd

Power Users can change how the personal guess and the sitewide average are mixed. They use the slider labelled using K=... in the Discovery and playlist sidebars. K is about how many ratings a game needs before the personal guess and the average count the same.

  • Left, smaller K: the site trusts your taste more, even for games with few ratings. You will probably find more surprising favourites, but also more bad picks. Your favourite genres also count more.
  • Middle: the default. It was tested against real ratings. If you are not sure, keep this setting.
  • Right, larger K: the site trusts the crowd more and your taste less. games with good reviews go up more easily, but the list is less personal.

So if you move the slider left, a game with few ratings that looks perfect for you goes up. If you move it right, games that many people love tend to win. The slider only changes the order of Discovery and your playlist, and only after your taste profile is on. It does not change your ratings or your filters.

The technical explanation shows exactly what each slider position changes.

How we check it

We hide a fixed set of real ratings from the model, and the model has to predict them. As of September 2026, its predictions are wrong by about one point on the 0 to 9 scale. That is 11% better than the sitewide average alone. We also look at the queues themselves after every change. A good error score can still hide a bad queue, for example one that shows the same unknown game to everyone.

Try it on your own taste
Rate 8 games you know, from the Discovery queue or the top 200 rankings. Then personal predictions start.

The formulas the live system runs on, and why each one was chosen.

1. Signal and training data

The model is trained on explicit feedback only: integer ratings \( r \in \{0,\dots,9\} \), one per (user, item) pair. Implicit signals such as playtime, wishlists or clicks are not used. Before training, users with fewer than 8 ratings and items with fewer than 2 ratings are dropped.

2. Matrix factorization

The model is plain matrix factorization with no bias terms, trained through ML.NET's wrapper around LIBMF. Each user \( u \) and item \( i \) gets a latent vector in \( \mathbb{R}^{24} \), and the predicted rating is their dot product:

$$ \hat r_{ui} = p_u^\top q_i $$

Your predicted rating is how well your taste vector lines up with the game's vector.

The dimensions are learned from rating patterns and carry no labels such as genre. One might loosely correspond to systems-heavy versus narrative, and another to nothing you could put a name on. The vectors are fitted by SGD on squared loss with L2 regularization:

$$ \min_{P,Q} \sum_{(u,i) \in \mathcal{K}} \left( r_{ui} - p_u^\top q_i \right)^2 + \lambda \left( \lVert p_u \rVert^2 + \lVert q_i \rVert^2 \right) $$

Find the vectors that reproduce everyone's real ratings as closely as possible, while keeping them small so that a few ratings cannot push anything to an extreme.

The training settings, chosen against a held-out split of real ratings:

RankIterations\( \lambda \)Training rows
24 20 0.2 every native rating

There is no bias term: no global mean and no user or item offsets, so how high a user rates in general is encoded in their vector. Two later decisions follow from that: the fold-in can't be a closed-form solve (§3), and the crowd prior needs a per-user shift (§5). Ratings are not weighted by age either, so an old rating counts as much as a new one.

The model retrains every 3 hours. After each run the serving snapshot is swapped atomically, every user is re-folded, and the user-similarity snapshot is reloaded.

3. Serving: AdaGrad fold-in

Between retrains the item factors \( Q \) are frozen and every user vector is computed by fold-in: initialize \( p_u = 0 \) and replay LIBMF's own SGD update over the user's rated items against the frozen \( Q \), using the trainer's learning rate (\( \eta = 0.1 \)), iteration count and \( \lambda \), plus AdaGrad's per-coordinate step scaling:

$$ p_{u,f} \mathrel{+}= \frac{\eta}{\sqrt{\varepsilon + \sum_t g_{t,f}^2}} \, g_f $$

Replay your ratings to find your vector, taking smaller steps in any direction that has already moved a lot. Here \( g_f \) is the current gradient for coordinate \( f \), \( g_{t,f} \) the same gradient at an earlier step \( t \), and \( \varepsilon = 10^{-8} \) only keeps the division finite.

The fold-in runs synchronously on every rating, so your own queue reacts within seconds. The 3-hour retrain is what carries your ratings into \( Q \), and through it to other users. Fold-in is also the only serving path: trained and folded vectors agree to within a few tenths, so everyone is served a folded vector and there is one code path to maintain instead of two.

The AdaGrad term is what makes the result independent of the order in which the ratings are replayed. At a constant learning rate the per-item steps are near-critical ( \( \eta \lVert q_i \rVert^2 \approx 0.5 \) ), so the vector ends up mostly fitting whichever ratings the loop visited last.

Below 8 ratings no vector is folded and predictions fall back to the crowd prior.

4. The crowd prior: calibrated pooled Bayesian average

Every item has a Bayesian average built from two rating populations: our own users and the IGDB community. The internal 0 to 9 axis is our users' scale. Provider scores \( v \) are mapped onto it with a power curve, and our own ratings are left as they are:

$$ 9 \cdot (v/100)^{2.17} $$

IGDB scores are bent down to match how people rate here: a 90 on IGDB counts as about 7.2 on our 0 to 9 scale, and an 80 as about 5.5.

Where a provider's community score lands on our 0-9 axis. The dashed line is "no correction".

IGDB's community rates higher on its scale than our users do on ours. The exponent compresses that gap without changing the order of games or throwing away IGDB's vote counts. The current value is still provisional and has not been properly fitted yet.

The pooled average is then shrunk toward a global base \( b \), which counts as \( S \) pseudo-votes:

$$ \bar r_i = \frac{\sum_{\text{ours}} r + \text{curve}(\text{provider}) \cdot n_{\text{prov}} + S \cdot b}{n_{\text{ours}} + n_{\text{prov}} + S} $$

Average every rating here and every translated provider vote, plus \( S \) imaginary votes at the base, so a title needs real votes to move far from it.

Here \( S = 24 \), and the base \( b \) is derived from the vote-weighted global mean \( \mu \) by stretching its distance from the top of the scale:

$$ b = 9 - (9 - \mu) \cdot \gamma, \qquad \gamma = 1.14 $$

The base sits a little below the average vote, near the typical title rather than the typical popular one.

The stretch is needed because \( \mu \) is vote-weighted: it describes the typical vote, and most votes are cast on popular, well-liked titles. That is a mismatch between a weighted and an unweighted mean, not a selection-bias correction. The measured weighted and unweighted averages were 5.28 and 4.28 over about 28,000 games. A prior for an item with no votes should sit near the typical item, so the base is pulled toward the lower figure. For games it only goes part of the way: the raw measurement (\( \gamma \approx 1.27 \)) would put the base at 4.28, but the held-out check preferred a slightly higher one, so the live value is \( \gamma = 1.14 \), for a base of about 4.96.

One more safeguard:

  • Leaderboards use a stricter \( S \). Ranked pages recompute the prior as \( \bar r_i^{\text{rank}} \) with \( S = 60 \), because provider averages tend to be highest where vote counts are lowest. Before the change, a fangame with 27 votes at 99/100 outranked Morrowind with 847. The cost is that a few real classics with thin vote counts drop some places. Most Hated keeps the smaller \( S \) on purpose: shrinking toward the mean makes a title look less hated, which would pull out the very titles that list is for.

5. Prediction: evidence-gated shrinkage plus a user-scale shift

The served prediction is a blend of the MF score and the prior, weighted by how much training evidence the item has:

$$ \hat r = \alpha \, \hat r^{MF} + (1-\alpha)\left( \bar r_i + \delta_u \right), \qquad \alpha = \frac{m}{m+K} $$

The more ratings a title was trained on, the more the model's guess counts. The rest comes from the crowd score, moved to your level.

  • \( m \) is the number of ratings the item's position was actually trained on: native ratings only. IGDB votes are left out on purpose, because the factorization never sees them. A title can have thousands of provider votes and still be shrunk hard if its trained position rests on a handful of ratings.
  • The default is \( K = 10 \), chosen so that a typical game still leans on the crowd prior and a well-rated one leans on the model.
How much of the prediction is personal (\( \alpha \)) as training evidence grows.

\( K \) is also one of the three settings Power Users can change from Discovery's sidebar. See the prediction slider in §7.

\( \delta_u \) is the user shift, and it exists because of the missing bias term. MF scores are on the user's personal scale (someone who rates everything 9 gets MF scores near 9), but the prior is on the crowd's scale. Without a correction, items with little evidence sank for generous raters and rose for harsh ones. The shift is applied to the prior only:

$$ \delta_u = w \cdot \frac{n_u}{n_u + K_g} \cdot (\text{offset}_u - \overline{\text{offset}}) $$

How much more generous or harsh you are than the typical rater, trusted more as your rating count grows.

Here \( \text{offset}_u \) is the user's mean residual against the Bayesian averages of the titles they rated, taken relative to the typical rater's. \( K_g = 5 \), the same constant that shrinks the genre tags in §6, stops a short rating history from producing an extreme shift. \( w = 1.25 \) sets how much of the measured difference is carried into predictions.

Since the shift is measured relative to the typical rater, most users move by less than a point and only the extremes move noticeably. On the held-out check it is worth more than twice the whole genre layer of §6. \( w \) is not set higher because some of what looks like generosity may just be a user picking games well, and that doesn't carry over to games they didn't pick.

The no-position path. Fresh or obscure games may have no trained position at all. Serving their raw Bayesian average would flood every queue with them, because popular newcomers have generous priors, so the average is faded toward the global base:

$$ \hat r_{\text{cold}} = b + 0.5 \cdot (\bar r_i - b) + \delta_u + \text{genre correction} $$

With no trained position, start from the base, go only part of the way toward the crowd score, then add the personal terms in full.

A fade of 0.5 halves how far such a game can stand out from the typical one, which stops the same popular newcomer from topping every account's queue. The personal terms are added after the fade, so they are not halved. The fade is a step and not a ramp: it applies only to titles with no trained position at all, and a title just past the training floor is blended by \( \alpha \) against the unfaded prior.

6. The genre-affinity correction

MF picks up genre taste only where it has evidence, so a separate layer measures the user's residual preference per tag and nudges predictions with it. For each eligible tag, the user's mean residual against the items' Bayesian averages is shrunk by \( n/(n+K_g) \), with the same \( K_g = 5 \) as the user shift in §5. Without the shrinkage, one bad rating of a rare tag would become that tag's whole estimate and be applied to every item carrying it. For each item, the deltas of its eligible tags are averaged into \( \Delta_{ui} \), capped at ±1, and then applied with a weight that depends on evidence:

$$ \hat r \mathrel{+}= \left( w_{\text{floor}} + w_{\text{cold}} \cdot (1-\alpha) \right) \Delta_{ui} $$

Add your average leaning across the title's tags, capped, then weighted the same on every trained title (the cold term is zero here).

The weight can exceed 1, so the cap limits \( \Delta_{ui} \) and not the final nudge. With the values below, a capped delta moves a prediction by up to ±1.55 points on a trained title and ±0.75 on one with no position. The cap itself has not been fitted yet.

The fitted weights show how much genre signal the factor model leaves behind:

\( w_{\text{floor}} \)\( w_{\text{cold}} \)\( w_{\text{no-position}} \)
1.5500.75

The rank-24 model leaves about the same amount of tag signal behind whether an item has few ratings or many. So the floor does all the work across the trained catalog, and the cold term is zero.

The tag set is curated by hand according to what each tag means, even where the metric disagreed. Most IGDB game-mode tags were removed because they describe what a game supports, not what someone likes. A bad rating for a single-player RPG with an optional co-op mode shouldn't lower every game that happens to support co-op.

7. Discovery: filters, then rank

The Discovery queue is built in a single pass over the in-memory catalog. About a dozen eligibility checks run first: library, wishlist and dismissed exclusions, platform and content preferences, plus the year, popularity and Linux compatibility sliders. Those sliders are hard cutoffs and not weights. Each notch maps to a threshold (the top year notch means released within the last 2 years, the middle one within 15), so anything a slider already filters on is not scored a second time. Whatever passes is ranked by the prediction \( \hat r \) from §5 and §6, using a streaming top-k selection instead of a full sort.

A few queues work differently:

  • Accounts with fewer than 8 ratings are ranked on popularity alone. Predictions would just be the same crowd score for everyone, and what matters for the first few cards is that the user can actually rate them: a famous disappointment is more useful there than an acclaimed title nobody has heard of. After that, popularity fades out gradually instead of all at once. The queue ranks on \( \hat r^{\,1-b} \cdot p^{\,b} \), where \( p \) is the popularity fraction and \( b \) starts at 0.35 and halves with every 20 ratings after that, so a long-time user's queue belongs to the model. A handful of ratings is not enough for the model to stand on its own, and in replays of past accounts a little popularity early on found more of the games people went on to love. Later, it mostly costs the lesser-known ones, which are what the model is for. The scores you see are not affected, only the order.
  • Upcoming games have no ratings at all, so they are ranked on hype, meaning the provider's anticipation signal (IGDB's want-to-play count), multiplied by \( 1 + 0.35 \, \Delta_{ui} \) to tilt it toward the viewer's genres.
  • The DLC queue only shows add-ons for games you've rated or are playing, and weights each add-on's prediction by how many players are interested in it and by your rating of its base game.

The prediction slider. Power Users get one more slider at the bottom of the Discovery sidebar. It decides who wins when your taste and the crowd disagree about a game: moving it left favours your personal signals, moving it right favours the crowd. It also orders your playlist, and in Party Mode each member's own setting applies to their share. Each step changes four parameters together, so that they stay consistent with each other:

  • Shrinkage \( K \) from §5. A lower \( K \) lets a prediction built on a handful of ratings stand on its own. That is where the surprising picks come from, and the misfires too.
  • The no-position fade from §5. A lower fade flattens the crowd score of games the model can't place, so they end up ordered by genre fit alone. A higher one lets well-reviewed unknowns rise, at which point every account's queue starts with the same few.
  • The genre weights from §6, all scaled by a single multiplier so the calibrated ratio between them is kept. A lower multiplier means your genre leanings count for less.
  • The popularity blend \( b \) from the queue's ranking above, scaled by a multiplier. The leftmost step turns it off, so even a new account's queue is the model's alone.
Setting\( K \)FadeGenre weightsPopularity
Wild, no safety net 0 0.2 ×1.4 ×0
Bold, leans on your genres 2 0.3 ×1.25 ×0.5
Slightly bolder than default 5 0.4 ×1.1 ×0.75
Calibrated for everyone 10 0.5 ×1 ×1
Slightly safer than default 20 0.6 ×0.85 ×1.25
Safe, just follows the crowd 50 0.7 ×0.7 ×1.5
Boring, ML almost off 100 0.8 ×0.5 ×2

The middle row, in bold, is the calibrated default that everyone else gets, and it tracks the constants whenever they are refitted. The rest of the pipeline, user shift included, is identical at every step. The slider only reorders the released queue, and only for accounts past the 8-rating mark. Newer accounts, the upcoming and DLC queues, backlog sorting and the numbers on a game page are not affected. It works well together with the popularity slider: the Wild setting plus a low popularity floor is how you go looking for obscure gems.

8. The numbers on a game page

  • Averages is the leaderboard version of the crowd prior, \( \bar r_i^{\text{rank}} \) at \( S = 60 \), so it matches the rankings it appears in. It is not the \( \bar r_i \) the prediction starts from, which is why the two can differ slightly.
  • Rated is the raw factorization score \( p_u^\top q_i \), clamped to 0 to 9, with no shrinkage, shift or genre term. It is what people with your taste would give the game, regardless of how much evidence that estimate rests on.
  • ML score is \( 100 \cdot \hat r / 9 \), the full served prediction. For a game the model knows well, \( \alpha \to 1 \) and it tracks Rated. For one it doesn't, it stays close to the crowd prior, which is why the two can disagree.

9. Social taste compatibility

Compatibility between two users is the mean-centered cosine of their ratings on the titles they have both rated, mapped to [0,1] and then shrunk by overlap as \( \text{sim} \cdot \frac{o}{o+5} \). With fewer than 5 co-ratings there is no score. A user's closest matches are found on request with a single scan over everyone else's ratings, which are held in memory and reloaded after each training run (§2). That avoids maintaining a table of every pair, which would grow as \( O(n^2) \).

10. How the constants are chosen

Most of the numbers above were measured, not picked by hand. A few are still judgment calls that haven't been fitted yet: the IGDB curve's exponent, the cold fade, the genre cap, the leaderboard prior and the hype tilt. The rest are tuned against a held-out check: a fixed slice of real ratings is hidden from the model, predicted, and scored by mean absolute error. As of September 2026 the full pipeline is off by about 1.12 rating points on average, against 1.26 for the crowd prior alone.

The pipeline is a chain with no feedback loop, so the constants can be refitted in one pass, in order:

$$ \text{Provider curves} \to \text{Bayes prior} \to \text{Shrinkage } K \to \text{Cold fade} \to \text{Genre weights} $$

The trainer's settings are fitted separately, since they depend on ratings and not on priors. Genre weights always come last, because they are fitted on whatever error the rest of the pipeline leaves.

The check has one big blind spot: it can only grade ratings people chose to give. A change that shows every account the same obscure game can still score well, because a game almost nobody has rated barely appears in the hidden set. So every change is also checked by reading the queues it produces, and if the queues look wrong the change is dropped, whatever the number says.

What was tried and didn't work
  • Discounting old ratings. In practice it acted like extra regularization with an age bias. Simply raising \( \lambda \) scored better, and the decay penalized people who really do prefer older games.
  • A closed-form fold-in. On paper the exact ridge solve \( p_u = (Q_u^\top Q_u + \lambda I)^{-1} Q_u^\top r_u \) is more correct than SGD, but with no bias term it latches onto the popularity axis and predicts 9 for every popular title.
  • A fold-in without AdaGrad. Scores on a 341-rating account swung by up to 10 points between reloads, for no reason other than the database returning the ratings in a different order.
  • A weaker prior and no cold fade. The held-out check preferred both. Each of them put the same few untrained games at the top of almost every queue, so both were reverted.