Skip to main content
Layer 1 is a pure, synchronous filter applied to every deal returned by CheapShark before any AI call is made. It enforces hard thresholds for discount depth, price ceiling, and review quality, then removes games already broadcast within the deduplication window.

Configuration

All thresholds are configurable via environment variables. The defaults are:
VariableDefaultDescription
MIN_DISCOUNT_PERCENT50Minimum savings percentage
MIN_METACRITIC_SCORE70Minimum Metacritic score (0–100)
MIN_STEAM_RATING_PERCENT70Minimum Steam rating percentage
MAX_PRICE_USD60Maximum sale price in USD
DEALS_PAGE_SIZE60Number of deals to fetch from CheapShark
DEDUP_DAYS7Days before a notified game can appear again

Filter logic

Each deal is evaluated against two categories of criteria.

Rejection conditions (all must pass)

A deal is rejected immediately if any of these is true:
  1. savings < MIN_DISCOUNT_PERCENT — discount is too shallow.
  2. salePrice > MAX_PRICE_USD — absolute price is too high.
  3. steamAppID is in the recent deduplication set — game was already notified within DEDUP_DAYS days.

Quality condition (OR logic)

A deal that survives the rejection conditions must also meet at least one quality signal:
  • metacriticScore >= MIN_METACRITIC_SCORE (only evaluated if a Metacritic score exists)
  • steamRatingPercent >= MIN_STEAM_RATING_PERCENT
The OR logic means a game with no Metacritic score can still pass if its Steam community rating is strong enough — and vice versa.

Why Metacritic is not filtered upstream

CheapShark supports a metacritic query parameter to request only deals above a score threshold. This filter is intentionally not used. Because the quality check is an OR between Metacritic and Steam Rating, filtering by Metacritic upstream would silently discard all games that have no Metacritic score but have excellent Steam reviews. The full candidate universe must be fetched and evaluated locally.
Do not add a metacritic parameter to the CheapShark request. Doing so would break the OR logic and drop valid high-rated indie games.

Source code

rulesFilter.ts
export function applyHardFilters(
  deals: Deal[],
  options: RulesFilterOptions,
  notifiedIds: Set<string>,
): Deal[] {
  return deals.filter((deal) => {
    const savings = parseFloat(deal.savings);
    const salePrice = parseFloat(deal.salePrice);
    const metacritic = parseInt(deal.metacriticScore) || 0;
    const steamRating = parseInt(deal.steamRatingPercent) || 0;

    if (savings < options.minDiscountPercent) return false;
    if (salePrice > options.maxPriceUSD) return false;
    if (notifiedIds.has(deal.steamAppID)) return false;

    const hasGoodMetacritic = metacritic > 0 && metacritic >= options.minMetacriticScore;
    const hasGoodSteamRating = steamRating >= options.minSteamRatingPercent;

    return hasGoodMetacritic || hasGoodSteamRating;
  });
}
applyHardFilters has no I/O. The notifiedIds set is built by dealsService.ts before calling this function, which keeps rulesFilter.ts pure and trivially testable.

Worked example

Assume default thresholds: MIN_DISCOUNT_PERCENT=50, MIN_METACRITIC_SCORE=70, MIN_STEAM_RATING_PERCENT=70, MAX_PRICE_USD=60.
GameSavingsSale priceMetacriticSteam ratingVerdict
Game A75%$9.998288%Pass — discount ✓, price ✓, Metacritic ✓
Game B60%$14.990 (none)91%Pass — discount ✓, price ✓, Steam rating ✓
Game C80%$4.996568%Reject — neither quality signal passes
Game D40%$7.998590%Reject — discount below minimum
Game E70%$74.999095%Reject — price exceeds maximum
Game F65%$12.997580%Reject — already notified this week
Games A and B become candidates passed to Layer 2 (AI curation).

What comes next

Candidates from Layer 1 are hashed and compared against the stored snapshot. If the hash matches, the previous AI selection is reused. If not, they are sent to GPT-4o-mini for curation.

AI curation

How GPT-4o-mini curates the candidate set and what the model actually contributes.

Caching and deduplication

How the candidate hash prevents unnecessary GPT calls.

Build docs developers (and LLMs) love