How the Algorithm Works

Every recommendation on GamesGraph comes out of one pipeline, and this page explains it honestly, with nothing held back. Pick the depth you're in the mood for: the same system, told three ways.

You know how a friend who really gets you can say "oh, you'd love this one" and be right? That's what this site is trying to be, just with more patience for keeping lists.

Here's the whole trick. You tell us which games you loved and which ones you didn't, using a simple score. Everyone else does the same. Then the system looks for people whose loves and hates line up with yours. Not your neighbors, not people your age, but people who rated things the way you do. If a few hundred people share your taste, and most of them adored something you've never tried, that's a very good sign you'll adore it too. That's really the heart of it: your future favorites are hiding in the lists of people who agree with your past ones.

The system is also honest about how sure it is. If you've only rated a handful of things, it doesn't pretend to know you. It leans on what people in general enjoy, and gets more personal as you rate more. Same on the other side: if a game is brand new or obscure and almost nobody has rated it, the system doesn't guess wildly. It waits for evidence, the way you'd wait to hear from a few friends before committing an evening to something.

One thing it never does is guess why you liked something. It doesn't know that you love cozy farming games because of the music. It only knows that people who loved the same things you loved went on to love certain other things. Strangely enough, that works better than trying to be clever about reasons.

And when it's wrong, and it will be sometimes, your next rating teaches it. Every score you give sharpens the picture a little. Rate a dozen things and it starts to feel like that friend who just knows. That's not magic, and it's not mind-reading. It's a lot of people quietly agreeing with each other, and some math to find the ones agreeing with you.

What the model actually learns

Every rating on the site is a row: this user gave this game this score, on a 0-9 scale. Stack all those rows into a giant grid, users down the side and games across the top, and almost every cell is empty, because nobody has rated more than a sliver of the catalog. Recommendation is the art of filling in the blanks.

We use a technique called matrix factorization. Instead of memorizing the grid, the model invents a couple dozen hidden dimensions and gives every user and every game a position along each of them. Nobody tells it what the dimensions mean; it discovers whatever axes best explain the ratings it has seen. One might loosely track "systems-heavy vs. narrative", another something no human would bother naming. Your predicted rating for a game is just how well your position lines up with its position. Training is a long game of nudging: predict a rating we already know, compare, nudge both positions to make the error smaller, and repeat across every known rating until the nudges stop helping.

The result is compact and fast, predicting your score for the whole catalog takes milliseconds, and it captures taste without ever being told what genres are.

The model retrains, you don't wait

The full model retrains every few hours. But when you rate something, you shouldn't wait hours to see your recommendations react. So between retrains, the game positions stay frozen and only your position is recomputed, immediately, by replaying the same learning procedure on just your ratings. Your queue reflects a new rating within seconds; the rest of the world picks it up at the next scheduled retrain.

Knowing when not to trust yourself

A raw prediction is only as good as the evidence under it, and the system is explicit about that in two places.

First, the game side. For anything rated by only a handful of people, the model's position for it is basically folklore. So every prediction is a blend: partly the personalized model score, partly the game's crowd average, and the mix slides with evidence. The weight on the personal score is m / (m + K), where m is how many ratings actually trained that game's position and K is a fixed "burden of proof". Lots of evidence: you get nearly pure personalization. Two ratings: you mostly get the crowd, which is the right kind of humble.

Second, the user side. If you've rated fewer than 8 things, you don't have a position at all yet, and you get crowd favorites until you do. That's also why new accounts are asked to rate a few things they already know: it's not a quiz, it's coordinates.

A fair crowd average is harder than it sounds

That "crowd average" does a lot of work, so it has to be built carefully. Three problems and their fixes:

Small samples lie. An obscure game with three perfect scores would beat a beloved classic on raw averages. So every average is pulled toward a global baseline by a fixed number of phantom "pseudo-votes"; real votes have to outweigh the phantoms before a game can climb. The more ratings something has, the less the pull matters.

Outside ratings speak a different language. Our own users' ratings are the ground truth scale, but they're sparse, so we also fold in the vote counts and averages from IGDB's community. That community is systematically more generous than our raters, so its scores pass through a calibration curve fitted to land them on our scale before they're allowed into the pool. Their votes count; their inflation doesn't.

The baseline itself is biased. The naive global average is dominated by popular, well-liked titles. It describes the typical vote, not the typical game. An unknown game should be presumed typical, which is lower. A measured stretch factor corrects for exactly that gap.

From a score to your queue

A prediction is not yet a recommendation. When the discovery queue looks for what to show you, every game in the catalog runs a gauntlet first: things you've already rated, shelved, or dismissed are out; so are things that fail your explicit filters, like the release-year window, the popularity floor, platform compatibility, early access, and content preferences. The sliders you see on the discovery page are mostly hard filters, not gentle weights: if you say nothing older than fifteen years, an old masterpiece scores zero, it doesn't just score lower.

What survives is ranked essentially by the blended prediction, with one deliberate correction: the model above has no idea what a genre is, so a separate, much simpler layer tracks whether you consistently rate certain tags above or below your own average and nudges predictions accordingly. The nudge is shrunk by evidence too, since two lucky ratings on a rare tag prove nothing, and capped, so no single genre obsession can hijack the queue.

Titles too new or obscure to have a model position at all get the crowd average, deliberately faded toward the global baseline. Otherwise every hyped new release would outrank your actual predictions, and everyone's queue would open with the same three newcomers.

How we know any of this works

There's a standing measurement: hide a fixed slice of real ratings from the serving path, predict them, and compare. The mean miss is currently a little over one point on the 0-9 scale, and every proposed change to the pipeline's constants has to beat that number on held-out data before it ships. With one caveat we take seriously: the metric only grades ratings people actually gave, so it can't see some failures at all. It will happily approve a change that floods everyone's queue with the same obscure games, because nobody has rated those. So changes pass two judges, the held-out error and a human looking at the queues and lists the change produces. When they disagree, the human wins, and the reason gets written down next to the constant it protects.

Every constant below is rendered live from the running configuration, so this page cannot drift out of date when the pipeline is recalibrated. Both modes' values are shown where they differ, because the cross-mode comparison is half the story: GamesGraph and ReelsGraph run the same code on datasets three orders of magnitude apart.

1. Signal and training data

Explicit feedback only: integer ratings \( r \in \{0,\dots,9\} \), one per (user, item) pair. No implicit signals enter the factorization: no playtime, watchlists, or clicks. Before training, users with fewer than 8 ratings and items with fewer than 2 are dropped.

The movie model would starve on native ratings alone, so it is bootstrapped on the MovieLens 32M dataset. The bootstrap rows are not used at face value: each MovieLens rating is re-anchored so the dataset's per-item baselines agree with TMDB's community levels mapped onto our internal axis (see the calibration curves in §4; those TMDB exponents are training labels there, which is why changing them forces a retrain, not just a restart). MovieLens is a temporary anchor: the plan of record is for Trakt-sourced ratings to replace it as the external training source, at which point several constants below get refitted.

2. Matrix factorization

Standard bias-free MF via ML.NET's wrapper over LIBMF. Each user \( u \) and item \( i \) gets a latent vector in \( \mathbb{R}^{24} \), and the predicted rating is the raw dot product:

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

trained 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) $$

Per-mode hyperparameters, each swept one knob at a time against a seeded 90/10 held-out split (5 seeds in Games, where a probe costs about a second; the knobs move test RMSE by ~1%, which is exactly the noise band of a single split, so single-split conclusions are treated as unproven):

RankIterations\( \lambda \)Training rows
Games24200.2~140k native ratings
Movies24300.05~32M (MovieLens bootstrap + native)

Two things worth noting. There is no bias term: no global mean, no user or item offsets, so a user's rating level lives inside their vector. That single modeling choice ripples through everything downstream. It's why the fold-in can't be a ridge solve (§3), and why the crowd prior needs a per-user shift (§5). There is deliberately no rating decay. A nostalgia discount (old ratings blended toward the mean) shipped early and was removed once probing showed it was functionally an age-shaped regularizer: raising \( \lambda \) replaced it with a better test score in both modes, and per-item decay taxed genuine retro enthusiasts while erasing the old-item rating spread that identifies them.

Retraining runs every 3 hours. After each train, the serving snapshot swaps atomically, every user is re-folded, and the user-similarity snapshot reloads.

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 frozen \( Q \), with the same learning rate (0.1), epochs, and \( \lambda \) as the trainer, and AdaGrad per-coordinate step scaling:

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

This runs synchronously on every rating, so your own queue reacts in seconds; the 3-hour retrain is what propagates your ratings into \( Q \) and to other users. It is also the only serving path. Trained and folded vectors agree to within a few tenths, so folded vectors are served for everyone rather than maintaining two code paths.

The AdaGrad term is load-bearing, not a nicety. At a constant learning rate the per-item steps are near-critical ( \( \eta \lVert q_i \rVert^2 \approx 0.5 \) ), so the folded vector mostly fits whichever ratings the loop visits last, and the rating list comes from an unordered database scan whose order changes between reloads. Measured on a 341-rating account, that made displayed scores swing ±10 points on every retrain, with raw SD around 1.0 across shuffles; with AdaGrad the same shuffle test gives SD around 0.05.

The tempting "correct" alternative, the closed-form ridge solve \( p_u = (Q_u^\top Q_u + \lambda I)^{-1} Q_u^\top r_u \), was tried and is explicitly banned in the source: with no bias term the exact least-squares optimum latches onto the dominant popularity axis and pins every popular title at the 9.0 ceiling.

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 carries a Bayesian average built from two rating populations: our own users, and the metadata provider's community (IGDB's rating count in Games, TMDB's vote count in Movies). The internal 0-9 axis is our users' scale; provider scores are translated onto it by fitted power curves, and our own ratings are never transformed:

$$ \text{IGDB:}\quad 9 \cdot (r/100)^{2.17} \qquad \text{TMDB:}\quad 9 \cdot (v/10)^{1.17} \;\; (1.419 \text{ for TV}) $$
Where a provider's community score lands on our 0-9 axis. The dashed line is "no correction".

The anchor for every fit is where a human puts the average title: measured independently on MovieLens (5.43) and our own users (5.22), agreeing at ~5.2-5.4 on the 0-9 axis. Providers sit above it; the curve exists to bring their level down without touching their ordering or their vote counts. The TV exponent is steeper because the movie-fitted curve over-credited series, established by a within-user fixed-effects contrast (CI [1.20, 1.63]) after every naive estimate of the gap turned out to be noise. Averaging per-user means when rating counts vary 300x produced, in sequence, a CI spanning zero, a confident and entirely spurious "82% is curve inflation", and a wrong-signed dose-response with CI [-10.4, +10.8].

The pooled average then shrinks toward a global base with strength \( 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} $$

with \( S = 24 \) (Games) / \( 50 \) (Movies), and the base \( b \) derived from the vote-weighted global mean \( \mu \) by a stretch:

$$ b = 9 - (9 - \mu) \cdot \gamma, \qquad \gamma = 1.14 \text{ (Games)}, \; 1.127 \text{ (Movies)} $$

The stretch corrects a weighted-vs-unweighted mismatch, not selection bias: \( \mu \) is vote-weighted, so it describes the typical vote, cast on a popular, liked title (Games measured 5.28 weighted vs 4.28 unweighted over 28k items), while the prior is for an item with no votes, which belongs at the typical item.

Three deliberate elaborations:

  • Leaderboards use a stricter \( S \). Ranked pages recompute the prior with \( S = 60 \) (Games) / \( 250 \) (Movies), because provider averages run high exactly where vote counts are small: a 27-vote fangame at 99/100 was outranking Morrowind on 847 votes. The held-out error metric cannot see this failure, since it grades ratings people actually gave, and nobody rates those titles. So this constant is fitted by inspecting ranked pages, accepting a known cost (thin-but-genuine classics drop some places) as the price of not being able to distinguish them from brigaded titles on the evidence. Most Hated deliberately keeps the smaller \( S \): shrinking toward the mean means shrinking toward less hated, which rescues exactly the titles that list exists to name.
  • Release maturity ramp (Movies). Measured against critic scores, which are fixed at release and so cannot drift, TMDB's vote average runs about +1.2 points hot on release day, decaying with a ~4.5-month time constant. A fresh title's provider vote count is therefore weighted from 0.4 at release, ramping to 1.0 over 9 months. The floor is not 0 because the weight multiplies the count: a 200-vote newcomer would otherwise collapse onto the global prior and vanish from discovery entirely. Games measured a half-size excess and declined the mechanism: IGDB counts accrue so slowly that exactly one fresh game sat in the catalog top 100 (against fourteen fresh movies), so it would trade visible score removals for a correction on titles nobody is shown.
  • No critic fold-in. Adding the critic score as one extra pooled vote was removed once provider vote counts joined the pool: one vote among thousands moved an average by 0.02.

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

The served prediction blends the MF score against the prior by training evidence:

$$ \hat r = \alpha \, \hat r^{MF} + (1-\alpha)\left( \bar r_i + \delta_u \right), \qquad \alpha = \frac{m}{m+K} $$
  • \( m \) counts rows that actually trained the item's embedding, native ratings plus MovieLens rows in Movies, and deliberately not provider votes, which the MF never sees. A film with 50,000 TMDB votes and three training ratings shrinks hard.
  • \( K = 10 \) (Games) / \( 25 \) (Movies). Per-mode because \( m \) is a different unit on each side; Movies items carry MovieLens evidence (measured medians: Movies p50 = 9, p75 = 51; Games p50 = 5, p75 = 13). The two values were chosen to give both catalogs the same alpha profile: mean \( \alpha \approx 0.40 \), about a third of items majority-personalized. Known open thread: depth probes suggest the pipeline under-trusts the model on well-evidenced items (optimal weight ~1.07 vs ~0.91 served), so \( K \) was fitted to a distribution shape, not to error, and may move.
How much of the prediction is personal (\( \alpha \)) as training evidence grows.

\( \delta_u \) is the user shift, a consequence of the missing bias term: MF scores live on the user's personal scale (a rate-everything-9 user gets MF scores near 9) while the prior lives on the crowd's, so thin-evidence items systematically sank for generous raters and rose for harsh ones. The shift moves the prior only:

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

where \( \text{offset}_u \) is the user's mean residual against the Bayesian averages of what they rated, centred on the typical rater (measured -0.07, so the middle ~80% of users move by under a point and only the tails shift). \( w = 1.25 \), fitted on held-out MAE, and notable because the 0 to 1 step alone was worth more than twice the entire genre-correction layer. It is deliberately not pushed past ~1.25: the offset is computed over ratings the user chose to give, so part of "generous" is "picks well" and doesn't transfer to titles they didn't pick.

The no-embedding path. Items the trainer never saw (fresh releases, and in Movies, series before they accrue ratings) get no MF score at all. Serving the raw Bayesian average would flood queues, since popular newcomers carry generous priors, so it is faded toward the global base:

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

The 0.5 has survived a calibration sweep that wanted 1.0 (no fade, worth ~0.7% of MAE) and was overruled: at 0.75 the same three cold titles topped every mid-depth account's queue. The metric grades each title's level and is right that the fade serves them low; the fade exists to prevent exactly that sameness, which the metric cannot see.

6. The genre-affinity correction

MF absorbs genre taste only where it has evidence, so a separate layer measures per-tag residual preference and nudges predictions. For each eligible tag, the user's mean residual against item Bayesian averages is shrunk by \( n/(n+5) \); the shrinkage guard exists because one bad rating of a rare tag otherwise becomes that tag's entire estimate and lands on every item carrying it. Per-item, eligible-tag deltas average into \( \Delta_{ui} \), capped at ±1 rating points, then applied with an evidence-dependent weight:

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

The fitted values tell a story about model capacity:

\( w_{\text{floor}} \)\( w_{\text{cold}} \)\( w_{\text{no-embed}} \)
Games1.5500.75
Movies01.251.25

Games: rank-24 MF on ~140k ratings absorbs essentially none of the tag signal at any depth it reaches (the residual slope against tags is flat in evidence), so the correction applies fully everywhere. Movies: at MovieLens scale, with mainstream items near \( m \approx 21{,}000 \), the MF absorbs genre taste completely (the deep bucket's residual slope is -0.19 at \( r = -0.03 \)), so the warm catalog is left alone and only the thin end gets corrected. Same formula, opposite regimes, and the fitted constants found both.

The tag basis itself is curated by mechanism, sometimes against the metric: IGDB's game-modes tags were removed even though the held-out meter liked them, because they are capability lists. One bad rating of a single-player RPG that happens to have a co-op option docked every co-op-capable game by over a point for that user. The holdout is pair-weighted (a 500-rating user counts 50x a 10-rating one), so it structurally under-weights exactly the users such noise harms.

Pre-release items have no residuals to regress, so hype ranking takes a multiplicative \( 1 + 0.35 \, \Delta \) instead, sized by queue inspection, since nothing measurable exists for titles nobody has rated.

7. Discovery: filters, then rank

The discovery queue is a single pass over the in-memory catalog. Architecturally the score is a product of preference-weighted factors, each shaped \( (1-w) + w f \) so that \( w = 0 \) disarms a factor and \( w = 1 \) makes it a pure multiplier. In the current configuration that machinery is mostly disarmed on purpose: the year, popularity, and Linux compatibility sliders were promoted from soft multipliers to hard queue filters (each slider notch maps to a cutoff, e.g. top notch = released within 2 years, middle notch = within 15 years), because scoring on a dimension that already filters would double-count it. What remains multiplying is the blended prediction itself, interest and parent-game rating on the DLC branch, and discount times price in deals mode. About a dozen eligibility gates run before scoring (library, wishlist and dismissed exclusions, platform, language, content preferences), then a streaming top-k selection with no global sort.

8. The similarity layers

User-user (taste compatibility, social features): mean-centered cosine over co-rated items, mapped to [0,1], then shrunk by overlap as \( \text{sim} \cdot \frac{o}{o+5} \), null under 5 co-ratings. Brute force \( O(n^2) \), fine at current scale.

Item-item ("people who loved X also loved"): a three-signal blend of latent-embedding cosine, IDF-weighted tag cosine, and a co-love lift (confidence-weighted log-odds that the seed's fans, raters at 8 or above, also love the candidate). The cosine is trusted by \( m_{\min}/(m_{\min}+10) \) on the pair's thinner side, with the remainder of the weight going to tags, plus a constant tag-weight floor that is the pipeline's one deliberately metric-negative constant: it costs measured lift and is paid entirely in list coherence. Without it, The Witcher 2 (tag cosine 0.81 with The Witcher 3) ranked below five embedding-cosine strangers, and The Godfather's list seated Full Metal Jacket at #5. Candidates below a minimum evidence floor are excluded entirely, which knowingly costs discovery gold (a 7-rating farming gem exactly right for Stardew fans) to stop 5-rating tag-twins from topping lists. The whole blend is graded on held-out loved-title hit rate against a same-sized popularity baseline; current lift is about 1.18 in Games and 1.36 in Movies, where each individual signal alone scores at or below parity in Games. The blend is the entire product.

9. Calibration: the process is the product

Roughly forty fitted constants steer this pipeline, and the codebase treats their calibration as a first-class engineering artifact. Each constant carries, in source, the rule that fixes its value, the trigger that would invalidate it, the script or endpoint that re-derives it, and the date it was last fitted.

The structure that makes refitting tractable: the pipeline is a DAG, not a feedback loop. Nothing downstream changes an input upstream, so one fitting pass in dependency order is valid and there is no fixed point to iterate toward:

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

with the trainer's hyperparameters fitted independently (they consume raw ratings, not priors), and genre weights always last, because they are fitted on the finished pipeline's own residual error and absorb any slope that moved above them. Coupled pairs are never fitted apart: prior strength and base stretch jointly define the prior; the no-embed genre weight and the cold fade act on the same path and trade against each other (measured: 0.75 with the fade at 0.5, 1.25 with it at 1.0).

The shared instrument is a held-out meter: a fixed slice of real ratings, selected by per-rating hash so month-to-month runs grade the identical pairs, excluded from serving, predicted, and scored by mean absolute error. The current baseline sits around 1.09 to 1.13 rating points depending on mode and model kind, against roughly 1.25 to 1.36 for the crowd prior alone. On top of it, a joint search sweeps the nine chained constants against holdout MAE with no restart in the loop, fitting on one hashed slice and confirming on another. Its per-constant verdicts are suggestions, not rulings, because the meter has known blind spots, and they are documented as carefully as the fits:

  • It only grades ratings people gave. Queue degeneracy, every account shown the same obscure titles, is invisible to it, because nobody rates those. This is why the sweep's requests to weaken the prior strength and remove the cold fade were both measured, tried, and reverted on queue inspection.
  • It is pair-weighted, so heavy raters dominate and harms concentrated on thin accounts are underweighted (the game-modes removal above).
  • It has a leak: the trainer sees every rating, so a held-out rating is unseen by the user's vector but was seen by the item's factors. Tracked per run; exam-model runs that genuinely exclude the slice read about 0.08 MAE higher and are never compared against full-model runs.

The operating rule that falls out of all this: every change faces two judges, the meter and a human reading the pages it produces, and when they disagree, mechanism beats meter. The disagreements are the most valuable entries in the file, because each one records a failure mode no offline metric was going to catch.