
steamAF
Know when to buy
I recently wanted to add a simple but highly requested feature to my Steam storefront app, steamAF: showing HowLongToBeat (HLTB) estimates right on the game detail cards.
The goal was simple: show Main Story, Main + Extras, and Completionist times so users can decide if a game fits their backlog schedule.
The problem? HLTB does not have a public API, and they actively try to break scrapers.
Here is a full breakdown of how I built a robust, scalable, and cheap integration from the client click down to the database cache, ensuring my app doesn't break even if HLTB changes their internal endpoints.
1. Bypassing HLTB’s Anti-Scraping Defenses
This was the hardest part. HLTB’s internal search endpoint is deliberately designed to frustrate scraping.
Rotating Path Segments: The endpoint path changes periodically (search → seek → ouch → s → bleed).
Per-Visit Tokens & Honeypots: You can't just POST a search query. You first have to hit an /api/<segment>/init endpoint to fetch a token and a honeypot key/value pair (hpKey / hpVal).
The Solution: I built a two-step fetcher. First, GET /api/bleed/init to grab the token and honeypots. Then, POST /api/bleed passing the token in the headers (x-auth-token, x-hp-key, x-hp-val) and injecting the honeypot into the JSON body.
Note on maintenance: I hardcoded the current segment (bleed) as a constant. The ceiling here is that when HLTB rotates the segment again, my fetch will 404. But because I built the UI to degrade gracefully, the playtime row simply won't render. No errors, no broken UI. To fix it, I just grep their _next JS chunk for the new segment and update one line of code.
2. Fuzzy Matching the Right Game
HLTB returns an array of candidate rows. Picking the right one isn't straightforward because Steam titles and HLTB titles don't always match perfectly, and HLTB’s search chokes on weird punctuation (searching "Assassin's Creed" with a curly apostrophe returns 0 results).
The Solution: I sanitize the search query by stripping weird punctuation and sending only alphanumeric word tokens. Then, I run a 3-tier matching strategy (inspired by the open-source hltb-for-deck plugin):
Exact Steam AppID match: Checking profile_steam (often null these days, but great when it hits).
Exact Normalized Name match: Stripping all symbols and comparing lowercase strings.
Fuzzy Match (Levenshtein distance): If exact matches fail, I calculate string similarity. If multiple games have the same distance, I tie-break using popularity (comp_all_count).
I added a minimum similarity threshold of 0.5 to prevent false positives (e.g., searching "Hades" and accidentally showing the playtime for "Saint Seiya: The Hades"). If it doesn't meet the threshold, I treat it as "no entry exists."
3. Scaling Cheaply: Caching & Rate Limiting
If 10 users click on Cyberpunk 2077 at the same time, I cannot send 10 requests to HLTB. I would get rate-limited instantly, and it's terrible for performance.
To make this practically free and instantly fast, I put everything behind a Supabase Read-Through Cache acting as a proxy layer.
Massive TTL: Game completion times rarely change. I set the cache TTL to 30 days.
Single-Flight Lease (RPC): When a cache is stale or missing, the first request claims a "lease" in the database. Any concurrent requests for the same game will just see the stale data or get a "pending" response. This completely eliminates cache stampedes.
Global Token Bucket: All outbound requests to HLTB pass through a global token bucket, ensuring the entire app stays under a strict request limit per minute.
Caching "Null": If a game genuinely doesn't exist on HLTB, I cache that null result too. This prevents the server from repeatedly querying HLTB for an obscure indie game that will never yield a result.
4. The Client Side (React)
The API route itself is a thin proxy. On the frontend (Next.js), when a game detail card mounts, a useEffect fires the fetch.
The state is tri-state:
undefined: Loading (shows a skeleton UI).
null: No HLTB entry exists (the row hides itself completely).
object: Renders the playtime visually.
To save even more client-side requests, I store the result in a module-level Map in memory. If a user opens a game, closes it, and reopens it in the same tab session, it reads directly from RAM.
Takeaway
By combining a smart 2-step token fetcher, aggressive Levenshtein string matching, and a rock-solid single-flight Supabase cache, you can build reliable features on top of undocumented, hostile endpoints without sacrificing UX or burning money on server costs.
Would love to hear how other indie hackers handle undocumented APIs or deal with cache stampedes!
Hey everyone,
Quick update on my project, steamAF. A major pain point I identified was the friction of "tab-switching." Users hate leaving the Steam storefront just to check a price-tracking site and figure out if a current discount is actually a good deal.
To solve this, I just rolled out a beta Chrome Extension for steamAF.
Now, instead of forcing you to open a new tab, the extension injects a clean, unobtrusive widget directly into the Steam game page, right above the purchase area. It immediately gives you a clear verdict (e.g., "Worth It", "Wait", "No Rush"), along with the 2-year low and the usual sale price, so you can make a quick decision without ever leaving the store.
Technical Highlights for fellow Makers:
Shadow DOM: Steam's storefront has a lot of legacy CSS quirks. To keep our UI from breaking or bleeding into Steam’s native styles, the widget is injected using Shadow DOM, providing complete style isolation.
Privacy-First Approach: We take user trust very seriously. The extension does not track your SteamID, browsed HTML pages, or your browsing history. It simply sends the numeric App ID to calculate the verdict and warm the cache. For metrics, it only uses a random daily token and hashed IP to count active installations without tracking individuals across days.
Performance & Deduplication: To ensure the Steam store remains snappy, we deduplicate requests via a background service worker. Changing your regional currency on the widget is fast and automatically updates the verdict on your active tabs without needing a page reload.
Currently, the extension is in v0.1.0 beta and is distributed as a manual installation (downloading a ZIP archive and using Chrome's "Load unpacked" feature).
I'd love for the Indie Hackers community to help me test this out. If you're a Steam gamer and want to protect your wallet from the backlog fatigue, please give it a try. I'm especially interested in feedback on the UI injection speed and the manual installation flow!
Best,
Ryan
1 Like
Comment
The Problem:
I have a bad habit: I buy Steam games on sale, only to realize the price drops lower a week later, or that the same "sale" happens every single month. I was getting tired of the FOMO.
The Solution:
I built steamAF (a PWA). Instead of just showing "current sale" like every other store, it checks the current price against ~2 years of historical data to give a verdict: Worth it, No rush, or Wait.
The "Aha!" Moment (Technical Build):
When I first started, I was naive. I set up a script that polled the Steam API for thousands of games every 5 minutes. It was inefficient, cost-heavy, and frankly, a recipe for getting rate-limited.
I had to rethink the architecture. I switched to a Smart Dynamic TTL approach using Supabase:
- If a game is on sale, I cache the data until endsAt + 30 mins.
- If it's at full price, the cache stays fresh for 12 hours.
- No cron jobs, no wasted API calls, and I'm saving 90% of my upstream traffic.
Tech Stack:
I'm running this on Next.js 15, React 19, and Supabase (Postgres). Moving away from "dumb" polling to a dynamic caching strategy was the most satisfying part of this build.
What I'm looking for:
I'm currently at the stage where I’m focused on polishing the "Worth it" verdict accuracy. If anyone here is working on price-tracking tools or e-commerce scrapers, I'd love to hear how you handle upstream API limitations!
Also, if you're a Steam gamer and feel the same "sale FOMO," I'd love for you to give it a spin and let me know if the "Wait" verdict makes sense to you.
3 Likes
9 Comments
9 Comments
-
2
A steam price tracker that's a nice app.
I also buy steam games but i rely on what price they show, as a player i don't know, when a new offer of my favourite games appear on steam.
-
1
Thanks Leonardo! Yeah, relying just on the Steam store page is tough because you never know if that 20% off is actually a good deal, or if it was way cheaper last month. That's exactly why the app focuses on the 'Verdict' instead of just raw numbers.
And for your second point about knowing when offers appear, I actually just finished and pushed a Web Push Alert feature! You can just track a game, and the app will notify you right on your device exactly when it hits that 'Worth It' tier so you don't have to keep checking manually. Feel free to give it a try!
-
-
2
Confidence in your buying decisions is important this kind of scenario happens far too often.
-
1
Exactly! That "should I buy this now or wait for a better deal?" feeling is exactly what I'm trying to eliminate. Glad to know I'm not the only one who feels this way!
-
-
2
I think the strongest part of the product isn't the price history—it's the verdict.
Most trackers answer, "What's the price?" Yours is trying to answer, "Should I act?" If people start trusting that judgment over time, the product becomes less about tracking discounts and more about helping users make better buying decisions.
-
1
Totally agree. Numbers are objective, but decisions are subjective and personal. Glad you see the potential in the verdict approach! That's exactly where I'm trying to steer the development. helping users feel confident in their buying decisions. Thank you, Aryan!
-
2
I'm glad it resonated.
Your reply raised one question for me about how you're thinking about that verdict over time. I'd rather explain why I'm asking in the context of your product than reduce it to a few comments.
What's the best email to reach you on?
-
-
About
I built this to help me decide which games are actually worth buying on promo. Sometimes the price looks really good, especially during a sale, but that same price comes back often. Then I just end up buying out of FOMO.




Comment