<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[WillingGames]]></title><description><![CDATA[Hand-picked free browser games you can play in a browser tab — no downloads, no installs.]]></description><link>https://willinggames.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ae2a629600b07ac7ae9d145/8ea0e35c-8c73-4e9b-bf76-39e556f09a37.webp</url><title>WillingGames</title><link>https://willinggames.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 15:09:04 GMT</lastBuildDate><atom:link href="https://willinggames.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Your Uptime Checker Says 200. Your Players See a Blank Frame]]></title><description><![CDATA[A field guide to monitoring 1,200 third-party game embeds that lie about their status codes.]]></description><link>https://willinggames.hashnode.dev/your-uptime-checker-says-200-your-players-see-a-blank-frame</link><guid isPermaLink="true">https://willinggames.hashnode.dev/your-uptime-checker-says-200-your-players-see-a-blank-frame</guid><category><![CDATA[webdev]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[webperformance]]></category><category><![CDATA[performance]]></category><category><![CDATA[node]]></category><dc:creator><![CDATA[WillingGames]]></dc:creator><pubDate>Fri, 30 Oct 2026 13:03:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae2a629600b07ac7ae9d145/6cfe7590-33cb-41ca-b0b8-2b31dd859f31.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A game catalog has an unusual property: almost none of its actual content is yours.</p>
<p>My catalog serves about 1,200 playable titles across nine genres, and every one of them is an <code>&lt;iframe&gt;</code> pointing at somebody else's HTML5 build — GameDistribution, CrazyGames, Famobi, PacoGames, a handful of studio-hosted builds, even a few Scratch projects. The page you own is a thumbnail, a description, and a URL to a runtime you have no control over and no visibility into.</p>
<p>That's fine until a provider removes a game. Then you get the worst kind of failure: <strong>the one that looks like success from every angle you're measuring.</strong></p>
<p>This post is about that failure, why your standard monitoring can't see it, and a checker that can.</p>
<h2>Two layers of blindness</h2>
<p>The first layer is architectural, and it's a side effect of doing the right thing for performance.</p>
<p>My game pages don't embed the iframe on load. It lives in a <code>&lt;template&gt;</code>, and it's only mounted after the user clicks play:</p>
<pre><code class="language-html">&lt;template data-game-embed&gt;
  &lt;iframe src="https://html5.gamedistribution.com/…/" width="960" height="660" scrolling="no"&gt;&lt;/iframe&gt;
&lt;/template&gt;
</code></pre>
<pre><code class="language-js">launchButton.addEventListener('click', () =&gt; {
  const frag = document.createRange().createContextualFragment(templateEl.innerHTML);
  gameHost.replaceChildren(frag);
  gameMounted = true;
});
</code></pre>
<p>This is the right call for LCP — the browser never fetches a third-party engine for a visitor who bounces in four seconds. But it means <strong>your page's health and the game's health are completely decoupled</strong>. The page returns 200, renders in 800ms, passes every check you have, and the thing the user actually came for may have been deleted six weeks ago. They click Play, and they get an empty frame with a provider's error page in it — or nothing at all.</p>
<p>So page-level monitoring is structurally unable to see the problem. You have to check the embeds themselves.</p>
<h2>And when you check them, they lie</h2>
<p>Here's the part that cost me an afternoon.</p>
<p>I sampled 14 game pages and pulled the embed URL out of each. Thirteen yielded one; seven distinct origins came back:</p>
<pre><code>5  html5.gamedistribution.com
2  www.pacogames.com
2  play.famobi.com
1  www.crazygames.com
1  scratch.mit.edu
1  infernals.goodgamestudios.com
</code></pre>
<p>Note the shape of those URLs — they carry a partner code:</p>
<pre><code>https://play.famobi.com/solitaire-master/A-WILLINGGAMES
</code></pre>
<p>That <code>A-</code> prefix is me. It's how the provider attributes traffic, and it's a useful reminder that these aren't neutral URLs — they're a commercial relationship with an API-shaped surface.</p>
<p>Now the interesting part. I took a GameDistribution URL and replaced the game ID with a string of zeros:</p>
<pre><code class="language-bash">$ curl -s -o /dev/null -w "%{http_code} %{size_download}\n" \
    https://html5.gamedistribution.com/00000000000000000000000000000000/

200 49
</code></pre>
<p><strong>HTTP 200. Forty-nine bytes.</strong> Here's the entire response body:</p>
<pre><code class="language-html">&lt;html&gt;&lt;body&gt;Not found at origin!...&lt;/body&gt;&lt;/html&gt;
</code></pre>
<p>That is a soft 404 served as a success. And it's not an edge case or a misconfiguration — it's a reasonable engineering choice on the provider's side: their player shell needs to load before it can tell you the game is missing, so the shell always loads, and the "missing" state is a string inside it.</p>
<p>The consequence for you: <strong>any monitor that checks <code>status_code == 200</code> will report 100% health on this catalog forever, including the day every game is gone.</strong></p>
<h2>Check content, not status codes</h2>
<p>The fix is to stop trusting the status line and look at what came back. Five signals, roughly in order of how often they fire:</p>
<p><strong>1. Known error strings in the body.</strong> Cheap, fast, and catches the most common case. Keep the list short and specific — generic words like "error" appear in working games too.</p>
<p><strong>2. Absurdly small responses.</strong> A real HTML5 game shell is tens of kilobytes at minimum. Forty-nine bytes is not a game. A threshold around 500 bytes catches the degenerate cases without flagging minified loaders.</p>
<p><strong>3. Redirects to a provider homepage.</strong> Some providers 302 a dead game to their front page. Following redirects and getting <code>https://provider.com/</code> back is a failure, not a success.</p>
<p><strong>4. <code>frame-ancestors</code> / <code>X-Frame-Options</code>.</strong> This is the sneaky one: the game is alive, but it has stopped allowing <em>you</em> to embed it. From the user's perspective that's identical to death — a blank box. Your checker has to know your own origin and compare.</p>
<p><strong>5. HEAD is not enough.</strong> Several CDNs answer HEAD with a clean 200 and answer GET with something else entirely. Use GET, read a bounded prefix, and close the connection.</p>
<p>Here's the checker, ~70 lines, no headless browser:</p>
<pre><code class="language-python">import json
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests

UA = "Mozilla/5.0 (compatible; embedcheck/1.0; +https://www.willinggames.com/)"
OUR_ORIGIN = "https://www.willinggames.com"

ERROR_MARKERS = (
    "not found at origin",
    "game not found",
    "no longer available",
    "has been removed",
    "this game is not available",
)

def check(url, timeout=15):
    try:
        r = requests.get(url, timeout=timeout, allow_redirects=True,
                         headers={"User-Agent": UA}, stream=True)
    except requests.RequestException as exc:
        return {"url": url, "verdict": "unreachable", "detail": type(exc).__name__}

    try:
        raw = r.raw.read(16384, decode_content=True) or b""
        headers = r.headers
        final_url = r.url
        status = r.status_code
    finally:
        r.close()

    # 4. embeddability, before anything else — a live game you can't frame is still dead to you
    csp = headers.get("content-security-policy", "").lower()
    if "frame-ancestors" in csp:
        allowed = csp.split("frame-ancestors", 1)[1].split(";")[0].strip()
        if allowed and "*" not in allowed and OUR_ORIGIN not in allowed:
            return {"url": url, "verdict": "not_embeddable", "detail": allowed}

    if status &gt;= 400:
        return {"url": url, "verdict": "dead", "detail": f"HTTP {status}"}

    body = raw.decode("utf-8", "ignore").lower()

    # 1. the 49-byte lie
    for marker in ERROR_MARKERS:
        if marker in body:
            return {"url": url, "verdict": "soft_404", "detail": marker}

    # 3. redirected to a provider homepage
    if final_url.rstrip("/") != url.rstrip("/") and final_url.rstrip("/").count("/") &lt;= 3:
        return {"url": url, "verdict": "redirected_home", "detail": final_url}

    # 2. too small to be a game
    if len(raw) &lt; 512:
        return {"url": url, "verdict": "suspicious", "detail": f"{len(raw)}B"}

    return {"url": url, "verdict": "ok", "detail": f"{status} {len(raw)}B"}

def main(urls):
    report = []
    with ThreadPoolExecutor(max_workers=8) as pool:
        futures = {pool.submit(check, u): u for u in urls}
        for fut in as_completed(futures):
            report.append(fut.result())

    bad = [r for r in report if r["verdict"] != "ok"]
    print(f"{len(report)} checked, {len(bad)} not ok")
    for r in sorted(bad, key=lambda x: x["verdict"]):
        print(f'  {r["verdict"]:16} {r["detail"][:40]:40} {r["url"]}')

    with open("embed-report.json", "w") as fh:
        json.dump(report, fh, indent=2)

if __name__ == "__main__":
    main(json.load(open("embed-urls.json")))
</code></pre>
<p>The input file is just a list of URLs, which you generate from your own database:</p>
<pre><code class="language-sql">SELECT post_id, embed_url FROM game_embeds WHERE status = 'live';
</code></pre>
<h2>Politeness, because you're the guest</h2>
<p>Twelve hundred GETs against other people's infrastructure is not free, and none of these providers owe you an API.</p>
<ul>
<li><strong>Eight workers, not eighty.</strong> Concurrency above that starts looking like an attack and will get you rate-limited or IP-blocked.</li>
<li><strong>One pass a night.</strong> Games don't die by the hour. A weekly pass is honestly enough for a catalog this size.</li>
<li><strong>Skip what you just verified.</strong> Store <code>last_checked</code> and <code>last_ok</code> per row; only re-check rows older than seven days, plus anything that failed last time.</li>
<li><strong>Send a real User-Agent with a contact URL.</strong> It costs nothing and it's the difference between "a crawler" and "a scraper" if someone looks at their logs.</li>
</ul>
<h2>The part nobody writes about: what to do with the answer</h2>
<p>Finding dead games is the easy half. Deciding what to do with a page that's been indexed for two years and now embeds nothing is the actual decision.</p>
<p>Three options, and they're not equivalent:</p>
<p><strong>Swap in a replacement.</strong> If the provider has the same game under a new ID, or a close sequel, do this. Keep the URL, keep the ranking, change one field. This is almost always the right answer and it's why the checker should record <em>what</em> it found, not just pass/fail.</p>
<p><strong>301 to the category page.</strong> Correct when there's no replacement. A page whose only content is "this game is unavailable" is a soft 404 in Google's eyes — it has no value to a searcher, and serving it with a 200 teaches the crawler that your site has thin pages. A permanent redirect to the genre hub preserves some of the link equity and gives the visitor something to do.</p>
<p><strong>Leave it up with a notice.</strong> Only defensible if the page has substantial original content beyond the embed — a real review, a walkthrough, something worth reading without the game. Most of mine don't have that, which is why on <a href="https://www.willinggames.com/">Free Cool Games</a> a dead game gets replaced or redirected, never left up with a notice.</p>
<p>What I don't do is delete the post. Deleting turns an indexed URL into a 404, which is worse than a redirect and throws away whatever the page earned.</p>
<h2>What the checker still can't see</h2>
<p>Honesty about the limits, because this is where a monitoring setup usually rots:</p>
<p>The marker approach only catches errors that are <strong>server-rendered into the HTML</strong>. Some providers render the "game unavailable" state client-side, after the shell loads. For those, a 16KB prefix of HTML looks perfectly healthy and the user still gets a blank frame. Catching that requires a headless browser per URL, which is roughly a hundred times the cost per check.</p>
<p>My compromise: run the cheap checker on everything nightly, and run a real browser against a rotating 5% sample — the same URLs every day until they've all been visited, then start over. It won't catch every client-side failure on day one, but it catches the trend, and a provider that starts client-side-failing tends to do it to all their games at once.</p>
<p>The other limit is the one that matters more: <strong>the checker tells you a game is gone, and only you can decide whether the page is worth saving.</strong> That's not a technical problem. It's the actual work.</p>
<hr />
<p><em>If you've solved the client-side soft-404 problem more cheaply than a headless browser, I'd genuinely like to hear how — comments are open.</em></p>
]]></content:encoded></item><item><title><![CDATA[Embedding 1,200 HTML5 Games Without Destroying Your LCP]]></title><description><![CDATA[A game catalog looks like a simple content site until you count what actually loads. Every listing page is a thumbnail grid. Every detail page is a…]]></description><link>https://willinggames.hashnode.dev/embedding-1-200-html5-games-without-destroying-your-lcp</link><guid isPermaLink="true">https://willinggames.hashnode.dev/embedding-1-200-html5-games-without-destroying-your-lcp</guid><category><![CDATA[webdev]]></category><category><![CDATA[WordPress]]></category><dc:creator><![CDATA[WillingGames]]></dc:creator><pubDate>Thu, 29 Oct 2026 02:22:37 GMT</pubDate><content:encoded><![CDATA[<p>A game catalog looks like a simple content site until you count what actually loads. Every listing page is a thumbnail grid. Every detail page is a thumbnail, a description, and — this is the part that hurts — an <code>&lt;iframe&gt;</code> pointing at a third-party HTML5 build you don't control, which then pulls in its own engine, its own fonts, and often its own analytics.</p>
<p>I maintain a free browser game catalog with a bit over 1,200 titles across nine genres. The first version of it scored badly on Core Web Vitals and I didn't understand why, because the pages <em>looked</em> light. They weren't. Here's what actually moved the numbers, in the order I'd do it again.</p>
<h2>Rule 1: Never load the iframe until someone asks for it</h2>
<p>This is the single biggest win and it's not close.</p>
<p>The default pattern is to embed the game on page load so it's "ready" when the user clicks play. That means every visitor pays for a third-party runtime whether or not they ever press the button. On a catalog where the bounce rate on detail pages is high, you're buying a lot of bytes for nothing.</p>
<p>The fix is a facade: render a poster image and a play button, and swap in the iframe on click.</p>
<pre><code class="language-html">&lt;div class="game-facade" data-src="https://games.example.com/neon-drift/index.html"&gt;
  &lt;img
    src="/thumbs/neon-drift-480.webp"
    alt="Neon Drift"
    width="800" height="600"
    loading="lazy" decoding="async"&gt;
  &lt;button class="play-btn" type="button"&gt;Play&lt;/button&gt;
&lt;/div&gt;
</code></pre>
<pre><code class="language-js">document.querySelectorAll('.game-facade').forEach((el) =&gt; {
  el.querySelector('.play-btn').addEventListener('click', () =&gt; {
    const frame = document.createElement('iframe');
    frame.src = el.dataset.src;
    frame.allow = 'autoplay; fullscreen; gamepad';
    frame.setAttribute('allowfullscreen', '');
    frame.className = 'game-iframe';
    el.replaceChildren(frame);
  }, { once: true });
});
</code></pre>
<p>Three details that matter:</p>
<ul>
<li><strong><code>{ once: true }</code></strong> — without it, a second click re-runs the handler and you get a flash of empty frame.</li>
<li><strong><code>allow="gamepad"</code></strong> — if you skip the permissions policy, controllers silently don't work inside the iframe and you'll get bug reports you can't reproduce.</li>
<li><strong>Keep the facade's dimensions identical to the iframe's.</strong> Any size difference turns a click into a layout shift, and now you've traded an LCP problem for a CLS problem.</li>
</ul>
<p>If your grid embeds previews inline (some catalogs do), don't use click-to-play — use <code>IntersectionObserver</code> with a generous <code>rootMargin</code> instead, so the swap happens slightly before the element enters the viewport:</p>
<pre><code class="language-js">const io = new IntersectionObserver((entries) =&gt; {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    const el = entry.target;
    const frame = document.createElement('iframe');
    frame.src = el.dataset.src;
    frame.loading = 'lazy';
    el.replaceChildren(frame);
    io.unobserve(el);
  }
}, { rootMargin: '300px 0px' });
</code></pre>
<h2>Rule 2: Your thumbnail pipeline is your LCP</h2>
<p>On a listing page, the largest element is almost always a thumbnail in the first row. That makes the image pipeline the thing you should be tuning, not the CSS.</p>
<p>Two mistakes I made:</p>
<p><strong>Upscaling source images.</strong> My source thumbnails are 240×180. On a wide grid they were being stretched to 480px wide, so the browser downloaded 240px and resampled it up — blurry <em>and</em> no faster. Serving a <code>srcset</code> wider than your source is pure waste. Either ship bigger sources or cap the display width at the source width.</p>
<p><strong>One size for every viewport.</strong> A single 800px-wide thumbnail on mobile costs three times what it needs to.</p>
<pre><code class="language-html">&lt;img
  src="/thumbs/car-sort-240.webp"
  srcset="/thumbs/car-sort-240.webp 240w,
          /thumbs/car-sort-480.webp 480w"
  sizes="(max-width: 600px) 45vw, 220px"
  width="240" height="210"
  loading="lazy" decoding="async"
  alt="Car Sort"&gt;
</code></pre>
<p>Then, on the first row only, override the lazy default:</p>
<pre><code class="language-html">&lt;img src="/thumbs/hero-480.webp" fetchpriority="high" decoding="async" ...&gt;
</code></pre>
<p><code>fetchpriority="high"</code> on everything is the same as on nothing — the browser just has more high-priority requests to order. Use it for the one image that's likely to be the LCP element and leave the rest alone.</p>
<p><strong>Serve WebP or AVIF.</strong> If your catalog is WordPress-based, this is a plugin setting, not a code change. Do it before you touch anything else.</p>
<h2>Rule 3: Preconnect deliberately, not enthusiastically</h2>
<p>A game catalog typically touches three or four origins: your own, the game CDN, an image CDN, and whatever ad or analytics script you've been talked into.</p>
<p><code>preconnect</code> opens a TCP + TLS connection early. It's cheap per origin and expensive in aggregate — each one competes for sockets and CPU on a mid-range phone.</p>
<pre><code class="language-html">&lt;link rel="preconnect" href="https://games-cdn.example.com" crossorigin&gt;
&lt;link rel="dns-prefetch" href="https://analytics.example.com"&gt;
</code></pre>
<p>My rule: <strong><code>preconnect</code> only for origins needed before first paint. <code>dns-prefetch</code> for everything else.</strong> With the facade pattern, the game CDN isn't needed until a click, so it arguably doesn't belong in the head at all — but the connection setup cost is real and a warm socket makes the click feel instant, so I keep one preconnect for it and nothing more.</p>
<p>If you're carrying six preconnects, you're probably slowing the page down while telling yourself you're optimizing it.</p>
<h2>Rule 4: Faceted URLs will eat your crawl budget</h2>
<p>Nine genres × pagination × sort orders × filter combinations is how a 1,200-game catalog turns into 40,000 URLs that are 90% identical.</p>
<p>Google doesn't punish you for having them, it punishes you for making it crawl them. What worked:</p>
<ul>
<li><strong>Self-referencing canonicals on paginated pages</strong>, not "everything points to page 1." Pointing page 5 at page 1 is a 2012-era recommendation that gets your deep pages dropped from the index entirely.</li>
<li><strong><code>noindex, follow</code> on sorted and filtered views</strong> (<code>?sort=popular</code>, <code>?tag=3d</code>). They're useful to humans, redundant to crawlers.</li>
<li><strong>Segment the sitemap.</strong> One sitemap per genre, plus one for posts. It makes diagnosing "why isn't this page indexed" a five-minute job instead of an afternoon.</li>
</ul>
<h2>Rule 5: Measure field data, not Lighthouse</h2>
<p>Lab scores are a smoke test. They don't know what your users' phones are doing.</p>
<pre><code class="language-js">import { onLCP, onINP, onCLS } from 'web-vitals';

const send = (metric) =&gt; {
  navigator.sendBeacon('/api/vitals', JSON.stringify({
    name: metric.name,
    value: metric.value,
    id: metric.id,
    rating: metric.rating,
  }));
};

onLCP(send);
onINP(send);
onCLS(send);
</code></pre>
<p>Watch the 75th percentile, not the average. A p75 LCP of 2.4s with a 6s tail on low-end Android is a worse product than a 2.8s average that's flat across devices — and Lighthouse will happily tell you both are fine.</p>
<h2>What I'd do differently</h2>
<p>I spent two weeks on image compression before realizing the iframe was the actual problem. The order I'd recommend:</p>
<ol>
<li>Click-to-play facade on every game page</li>
<li>Thumbnail <code>srcset</code> with sources you actually have</li>
<li>WebP/AVIF everywhere</li>
<li>Canonical + <code>noindex</code> strategy for faceted URLs</li>
<li>Field measurement, then optimize what the data says</li>
</ol>
<p>None of this is exotic. It's just that a catalog site has a hundred pages doing the same slightly-wrong thing, so the fix has to happen in the template, once, and then you have to resist the urge to re-litigate it per page.</p>
<hr />
<p><em>If you're running a similar catalog — games, wallpapers, recipes, anything with a big thumbnail grid and an embed — I'd like to hear what your p75 LCP looks like and what actually moved it. Comments are open.</em></p>
]]></content:encoded></item></channel></rss>