<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: doodoolove</title>
    <description>The latest articles on Forem by doodoolove (@doodoolove).</description>
    <link>https://forem.com/doodoolove</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3903443%2Fadecfe92-4be1-4b6d-a848-25e87727ac48.png</url>
      <title>Forem: doodoolove</title>
      <link>https://forem.com/doodoolove</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/doodoolove"/>
    <language>en</language>
    <item>
      <title>Why Cloudflare's Free-Plan Cache Rules Don't Cache Your HTML (And the Page Rules Fix That Does)</title>
      <dc:creator>doodoolove</dc:creator>
      <pubDate>Thu, 07 May 2026 09:01:41 +0000</pubDate>
      <link>https://forem.com/doodoolove/why-cloudflares-free-plan-cache-rules-dont-cache-your-html-and-the-page-rules-fix-that-does-24d4</link>
      <guid>https://forem.com/doodoolove/why-cloudflares-free-plan-cache-rules-dont-cache-your-html-and-the-page-rules-fix-that-does-24d4</guid>
      <description>&lt;p&gt;&lt;em&gt;A 90-minute debugging session, with curl outputs.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;We run a 6,800-page games portal behind Cloudflare on the Free plan. Last week's audit flagged that &lt;code&gt;cf-cache-status&lt;/code&gt; was returning &lt;code&gt;DYNAMIC&lt;/code&gt; for every HTML request, despite an origin &lt;code&gt;Cache-Control: s-maxage=31536000&lt;/code&gt;. We spent 90 minutes building Cache Rules with every "correct" config — Eligible for cache, Edge TTL, browser TTL, expression filters covering 8 path patterns — and Cloudflare still refused to cache HTML. The fix wasn't another tweak to Cache Rules; it was switching to the older &lt;strong&gt;Page Rules&lt;/strong&gt; with &lt;code&gt;Cache Everything&lt;/code&gt; + &lt;code&gt;Edge Cache TTL&lt;/code&gt;. Within 10 seconds of saving, our second-request TTFB dropped from 660ms to 210ms (a 68% reduction on keep-alive connections, much larger globally) and &lt;code&gt;age:&lt;/code&gt; headers started incrementing. This post is the actual headers we saw, the configs we tried, and why the docs don't tell you this directly.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;DooDoo.Love is a multilingual HTML5 games portal. ~6,800 game pages, plus categories, tags, blog, news. Origin is Vercel; Cloudflare sits in front for DNS + a few security headers + (we hoped) HTML caching. We're on the Free plan because it's enough for our traffic and we'd rather spend the $20/month elsewhere.&lt;/p&gt;

&lt;p&gt;The full SEO audit pegged our indexing rate at 18.6% — Google had crawled and indexed only 1,250 of our 6,725 sitemap URLs. One contributing factor (among several): Googlebot was paying full origin TTFB for every page in every region. From Tokyo, that meant 1,800ms just to start receiving HTML. With a flat crawl budget, that math eats most of the budget on protocol overhead instead of actual page content.&lt;/p&gt;

&lt;p&gt;Edge caching HTML at Cloudflare's POPs would mean Googlebot in Tokyo hits the Tokyo POP (round-trip ~30ms) instead of LAX (round-trip ~150ms). On 6,800 URLs, that's a meaningful budget unlock. So: configure CF, get &lt;code&gt;cf-cache-status: HIT&lt;/code&gt;, ship it.&lt;/p&gt;

&lt;p&gt;It did not go that way.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we tried first: Cache Rules
&lt;/h2&gt;

&lt;p&gt;Cloudflare's modern caching configuration UI is &lt;strong&gt;Cache Rules&lt;/strong&gt; (under Caching → Cache Rules). Page Rules are the legacy interface, and the docs nudge you toward Cache Rules. So we built one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rule name&lt;/strong&gt;: Cache HTML edge&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match expression&lt;/strong&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;host&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt; &lt;span class="nv"&gt;"doodoo.love"&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;method&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt; &lt;span class="nv"&gt;"GET"&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"/games/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"/blog/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"/news/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"/categories/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"/tags/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt; &lt;span class="nv"&gt;"/"&lt;/span&gt;
    &lt;span class="p"&gt;[...]&lt;/span&gt;
  &lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache eligibility&lt;/strong&gt;: Eligible for cache&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge TTL&lt;/strong&gt;: Use cache-control header if present, otherwise bypass&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browser TTL&lt;/strong&gt;: Override origin TTL → 4 hours&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Saved. Deployed. State: 活动 (Active). Order: 1.&lt;/p&gt;

&lt;p&gt;We then ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://doodoo.love/games/sudoku | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"cf-cache-status"&lt;/span&gt;
cf-cache-status: DYNAMIC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five attempts in a row, each with a 2-second sleep between. Every single one: &lt;code&gt;DYNAMIC&lt;/code&gt;. No &lt;code&gt;MISS → HIT&lt;/code&gt; progression. No &lt;code&gt;age:&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;The origin response was healthy — &lt;code&gt;cf-ray&lt;/code&gt; confirmed Cloudflare was on the path, &lt;code&gt;x-nextjs-cache: HIT&lt;/code&gt; confirmed Vercel was caching its end, and &lt;code&gt;cache-control: s-maxage=31536000, stale-while-revalidate&lt;/code&gt; was being sent. There was no &lt;code&gt;Set-Cookie&lt;/code&gt;. No &lt;code&gt;Vary: *&lt;/code&gt;. No &lt;code&gt;Pragma: no-cache&lt;/code&gt;. The response was, by every spec we knew, cacheable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Diagnostic detour: ruling out the obvious
&lt;/h2&gt;

&lt;p&gt;We worked through the standard checklist:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Was the rule actually deployed (not draft)? &lt;strong&gt;Yes&lt;/strong&gt;, status was 活动 (Active).&lt;/li&gt;
&lt;li&gt;Was there a higher-priority rule overriding? &lt;strong&gt;No&lt;/strong&gt;, this was the only Cache Rule.&lt;/li&gt;
&lt;li&gt;Was the expression matching? We tested a path explicitly &lt;em&gt;not&lt;/em&gt; in the expression (&lt;code&gt;/api/test-not-in-rule&lt;/code&gt;) and got &lt;code&gt;DYNAMIC&lt;/code&gt; too — but that's expected because it's not in the rule. The match question stayed inconclusive from headers alone.&lt;/li&gt;
&lt;li&gt;Was Cloudflare seeing the request? &lt;strong&gt;Yes&lt;/strong&gt;, every response had &lt;code&gt;cf-ray:&lt;/code&gt; and &lt;code&gt;server: cloudflare&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Was a static asset cacheable? &lt;code&gt;curl -sI .../logo.png&lt;/code&gt; returned &lt;code&gt;cf-cache-status: MISS&lt;/code&gt; (cacheable, just not yet warmed). So Cloudflare's cache pipeline was not broken globally — only HTML was failing.&lt;/li&gt;
&lt;li&gt;Did switching Edge TTL to "Ignore cache-control header, use this TTL" fix it? &lt;strong&gt;No&lt;/strong&gt;. Even forcing Cloudflare to use a manual 1-hour TTL, ignoring the origin entirely, the response stayed &lt;code&gt;DYNAMIC&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last data point was the one that broke our model. If "Ignore cache-control" + a hard TTL doesn't cache an HTML response, the rule isn't being honored at all. Something below the rule layer was vetoing the cache.&lt;/p&gt;




&lt;h2&gt;
  
  
  The actual answer
&lt;/h2&gt;

&lt;p&gt;Cloudflare Free Plan + Cache Rules + &lt;code&gt;text/html&lt;/code&gt; is an empirically unreliable combination. The Cache Rules feature is technically available on Free, but caching of HTML/dynamic content has documented quirks that don't apply to static assets.&lt;/p&gt;

&lt;p&gt;We confirmed plan tier in the dashboard (top right: "Free"), and we confirmed the rule was correctly configured. Cache Rules just doesn't reliably cache &lt;code&gt;text/html&lt;/code&gt; for Free-tier accounts in 2026, regardless of how perfectly you configure it.&lt;/p&gt;

&lt;p&gt;The interesting thing is the docs don't say this directly. They say Cache Rules are available on Free. They don't say "but for HTML on Free, use Page Rules instead." We figured this out by switching.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix: Page Rules with Cache Everything
&lt;/h2&gt;

&lt;p&gt;Page Rules predate Cache Rules and have a different code path inside Cloudflare. On the Free plan you get 3 rules. Here's what we set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Rule 1: *doodoo.love/games/*
  - Cache Level: Cache Everything
  - Edge Cache TTL: 2 hours

Rule 2: *doodoo.love/categories/*
  - Cache Level: Cache Everything
  - Edge Cache TTL: 2 hours

Rule 3: *doodoo.love/tags/*
  - Cache Level: Cache Everything
  - Edge Cache TTL: 2 hours
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cache Level: Cache Everything&lt;/strong&gt; is the magic incantation. Without it, Cloudflare's default Cache Level (Standard) is "cache only static file extensions" — which excludes HTML even though &lt;code&gt;text/html&lt;/code&gt; is the largest miss in the URL space. Cache Rules' "Eligible for cache" eligibility flag &lt;em&gt;should&lt;/em&gt; be the equivalent override, but on Free it isn't (or isn't fully). Page Rules' Cache Everything is.&lt;/p&gt;

&lt;p&gt;We saved, then purged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Caching → Configuration → Purge Cache → Purge Everything
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then verified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://doodoo.love/games/sudoku | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"cf-cache"&lt;/span&gt;
cf-cache-status: MISS

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;3
&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://doodoo.love/games/sudoku | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"cf-cache|^age"&lt;/span&gt;
cf-cache-status: HIT
age: 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;age: 3&lt;/code&gt; — the response had been at the edge for 3 seconds. Two minutes later, &lt;code&gt;age: 124&lt;/code&gt;. The HIT was real.&lt;/p&gt;

&lt;p&gt;We hit &lt;code&gt;/categories/puzzle&lt;/code&gt; and &lt;code&gt;/tags/puzzle-games&lt;/code&gt; and saw the same MISS → HIT pattern. The three Page Rules covered our highest-traffic surface: ~6,820 game pages plus 14 category pages plus 33 tag pages = ~6,867 URLs cached at edge.&lt;/p&gt;




&lt;h2&gt;
  
  
  What didn't speed up: TTFB on the same connection
&lt;/h2&gt;

&lt;p&gt;The first thing we noticed after the switch is that TTFB &lt;em&gt;didn't change&lt;/em&gt; on a fresh &lt;code&gt;curl&lt;/code&gt;. From our LA-area test machine to the Cloudflare LAX POP, TTFB stayed around 660ms whether the response was DYNAMIC or HIT. We almost reverted, thinking the Page Rule wasn't actually doing anything despite the headers.&lt;/p&gt;

&lt;p&gt;Then we ran three requests in a single keep-alive &lt;code&gt;curl&lt;/code&gt; session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Req 1: TTFB 0.648s, total 0.708s   ← cold connection, full TLS handshake
Req 2: TTFB 0.210s, total 0.530s   ← reused connection, hit edge cache
Req 3: TTFB 0.211s, total 0.275s   ← same
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Request 1 is dominated by the TLS handshake (~440ms in our &lt;code&gt;time_appconnect&lt;/code&gt;). Cloudflare HIT or origin pass-through, that handshake cost is the same. Requests 2 and 3 reuse the connection and now expose the actual cache delta: 660ms → 210ms = &lt;strong&gt;−68%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Most real users don't run &lt;code&gt;curl&lt;/code&gt; cold once. They open a tab, the browser establishes a connection, and then loads the HTML + dozens of subresources over that same connection. The "cold first hit" is a small fraction of total user-perceived latency. The cache win shows up on every subsequent request.&lt;/p&gt;

&lt;p&gt;For Googlebot and other crawlers, the win shows up differently again. From the Cloudflare Tokyo POP, our origin in LAX is ~120ms RTT away. From an Asian user/crawler, hitting the Tokyo POP is ~30ms RTT. &lt;strong&gt;Pre-cache: 30ms RTT (user→Tokyo) + 120ms RTT (Tokyo→LAX) + origin work + return path = ~180-220ms.&lt;/strong&gt; &lt;strong&gt;Post-cache: 30ms RTT (user→Tokyo) + 5-15ms (cached HTML out) + return path = ~50-80ms.&lt;/strong&gt; The win on a global audience is much larger than the LA-to-LA test shows.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Don't start with Cache Rules on a Free plan if your goal is HTML caching.&lt;/strong&gt; Cache Rules are the future, the docs treat them as canonical, and on Pro+ they work perfectly for HTML. On Free in mid-2026, Page Rules with &lt;code&gt;Cache Everything&lt;/code&gt; are still the proven path. Start there. Migrate to Cache Rules when you upgrade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always verify with &lt;code&gt;age:&lt;/code&gt; not just &lt;code&gt;cf-cache-status: HIT&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;cf-cache-status&lt;/code&gt; field is generated as Cloudflare formats the response; it can theoretically be &lt;code&gt;HIT&lt;/code&gt; while the origin was actually consulted. The &lt;code&gt;age:&lt;/code&gt; header is harder to fake — it's the seconds since the cached response was stored. If &lt;code&gt;age:&lt;/code&gt; increments across requests, you have real edge caching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test with TLS handshake folded out.&lt;/strong&gt; Cold-&lt;code&gt;curl&lt;/code&gt; TTFB will mislead you when the cache delta is similar in size to the TLS handshake. Use keep-alive (curl with multiple URLs in one invocation) or run from a region far from your origin to expose the real cache benefit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free plan trade-offs are real but not large.&lt;/strong&gt; We get 3 Page Rules; we used them on &lt;code&gt;/games/*&lt;/code&gt;, &lt;code&gt;/categories/*&lt;/code&gt;, and &lt;code&gt;/tags/*&lt;/code&gt; (the three highest-traffic surfaces). The other ~1% of pages (&lt;code&gt;/blog&lt;/code&gt;, &lt;code&gt;/news&lt;/code&gt;, &lt;code&gt;/about&lt;/code&gt;, root) stay uncached. For our use case, that's fine — those routes aren't crawled or hit at the same volume. If you have 10+ high-traffic surfaces, Pro is $240/year, which most production sites can absorb.&lt;/p&gt;




&lt;h2&gt;
  
  
  Read more
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The site this came from: &lt;a href="https://doodoo.love" rel="noopener noreferrer"&gt;DooDoo.Love&lt;/a&gt;, 6,800+ free HTML5 browser games.&lt;/li&gt;
&lt;li&gt;Earlier in this debugging arc: &lt;a href="https://doodoo.love/blog/why-we-banned-within-the-realm" rel="noopener noreferrer"&gt;Why We Banned 'Within the Realm of...' From Our AI Game Descriptions&lt;/a&gt; — the AI doorway story.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;About the author: Steuber Alberto is Editor-in-Chief at &lt;a href="https://doodoo.love" rel="noopener noreferrer"&gt;DooDoo.Love&lt;/a&gt;. Reach me at &lt;a href="mailto:support@doodoo.love"&gt;support@doodoo.love&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflarechallenge</category>
      <category>webperf</category>
      <category>seo</category>
      <category>devops</category>
    </item>
    <item>
      <title>Why We Banned 'Within the Realm of...' From Our AI Game Descriptions</title>
      <dc:creator>doodoolove</dc:creator>
      <pubDate>Wed, 29 Apr 2026 05:00:44 +0000</pubDate>
      <link>https://forem.com/doodoolove/why-we-banned-within-the-realm-of-from-our-ai-game-descriptions-2cb2</link>
      <guid>https://forem.com/doodoolove/why-we-banned-within-the-realm-of-from-our-ai-game-descriptions-2cb2</guid>
      <description>&lt;p&gt;&lt;em&gt;A small portal's accidental brush with Google's scaled-content-abuse algorithm — and the 11-rule prompt change that fixed it.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;We run a 6,800-game HTML5 portal where every game description is generated by GPT-4.1-mini using a prompt that tries to sound editorial. Last week we caught the prompt mid-disaster: &lt;strong&gt;56% of descriptions across our entire corpus opened with the same six words&lt;/strong&gt; ("What separates casual from committed play..."), 38% used the same skill-tier framing ("Veterans of [genre] recognize..."), and 86% violated our internal jargon budget by stuffing four or more of &lt;em&gt;hitbox&lt;/em&gt;, &lt;em&gt;frame-pacing&lt;/em&gt;, &lt;em&gt;tick rate&lt;/em&gt;, &lt;em&gt;RNG floor&lt;/em&gt; into a single page.&lt;/p&gt;

&lt;p&gt;A wedding-dress-up game on our site discussed &lt;em&gt;tick-rate&lt;/em&gt;. &lt;strong&gt;Dress-up games don't have a tick-rate.&lt;/strong&gt; That's when we knew we'd built a textbook scaled-doorway pattern straight into the GPT prompt — exactly what Google's March 2024 spam-policy update &lt;a href="https://developers.google.com/search/blog/2024/03/core-update-spam-policies" rel="noopener noreferrer"&gt;explicitly called out&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Scaled content abuse is when many pages are generated for the primary purpose of manipulating search rankings... It does not matter if you use generative AI, manual means, or a mix to produce this scaled content."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Our indexing rate was 18.6%. The pattern was both the cause and the diagnostic.&lt;/p&gt;

&lt;p&gt;This post is what we found, what we changed, and the verification script we now run in CI to prevent regression.&lt;/p&gt;




&lt;h2&gt;
  
  
  How a "neutral expert" prompt becomes a doorway pattern
&lt;/h2&gt;

&lt;p&gt;The original prompt was good-faith. We wanted descriptions that read like a reviewer wrote them, not like a marketer. To anchor the model away from generic Adventure-of-a-lifetime copy, we provided positive examples of "neutral-expert voice patterns" the model could draw from. Six of them, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;"Veterans of [X] recognize..."&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Serious players of the genre find..."&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"What separates casual from committed play is..."&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"A commonly overlooked mechanic..."&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"The game rewards a counter-intuitive approach..."&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We also asked for a tail line &lt;code&gt;Expert Tip: ...&lt;/code&gt;, a counter-intuitive truth, and "genre jargon used unapologetically" with examples of &lt;em&gt;hitbox&lt;/em&gt;, &lt;em&gt;tick rate&lt;/em&gt;, &lt;em&gt;frame-pacing&lt;/em&gt;, &lt;em&gt;metagame&lt;/em&gt;, &lt;em&gt;RNG floor&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;What happened: the model picked the path of least resistance. Each of the six voice patterns is high-quality on its own. But across 6,800 generations, the model converged on a tiny subset. It's the LLM equivalent of giving a student six sample answers and being surprised they only ever quote the first three.&lt;/p&gt;

&lt;p&gt;We discovered the problem when we wrote a homogeneity check (more on that below) and learned that &lt;strong&gt;3,797 of our 6,728 entries opened with the same eleven-word sentence&lt;/strong&gt;. We had effectively templated 56% of our content corpus.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this looks like to Google
&lt;/h2&gt;

&lt;p&gt;Google's structured-content classifier doesn't need to "understand" voice patterns — n-gram fingerprinting is enough. When 56% of pages on a domain share an opener of &amp;gt;40 characters, that domain pattern-matches with the documented "scaled content" spam category from the March 2024 update. The algorithmic response is documented and predictable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Google still crawls each page (the URLs are in our sitemap).&lt;/li&gt;
&lt;li&gt;Google evaluates the content quality signal at the domain level.&lt;/li&gt;
&lt;li&gt;Pages that fingerprint as "templated content" land in &lt;strong&gt;"Crawled — currently not indexed"&lt;/strong&gt; in Search Console.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;a href="https://indexcheckr.com/resources/google-indexing" rel="noopener noreferrer"&gt;16-million-page IndexCheckr study&lt;/a&gt; puts the cross-web indexing average at 37%. We were at 18.6%. &lt;a href="https://www.gsqi.com/marketing-blog/google-december-2024-spam-update-case-studies/" rel="noopener noreferrer"&gt;GSQI's December 2024 spam-update case studies&lt;/a&gt; include a directory site with 140k programmatic URLs at 12% effective indexing rate — same shape, larger scale.&lt;/p&gt;

&lt;p&gt;The diagnostic is unambiguous: when &lt;em&gt;Crawled, not indexed&lt;/em&gt; dominates your index report and your domain-wide content shares a structural fingerprint, the algorithm has evaluated and rejected your content, not failed to find it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 11-rule v3 prompt
&lt;/h2&gt;

&lt;p&gt;We rewrote the system prompt with anti-doorway constraints as first-class. Here's the structure (full prompt is in our open-source repo):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hard constraints&lt;/strong&gt; (kept from v2):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;No first-person pronouns — write as a critic, not a player.&lt;/li&gt;
&lt;li&gt;No em-dashes (we sanitize these but reject if they leak past).&lt;/li&gt;
&lt;li&gt;No marketing filler ("amazing", "unbelievable adventure", "dive into", "embark on").&lt;/li&gt;
&lt;li&gt;No AI connector words ("In summary", "Furthermore", "It is worth noting").&lt;/li&gt;
&lt;li&gt;No invented numbers or personal events ("scored 4,500", "on level 7").&lt;/li&gt;
&lt;li&gt;Game name appears max 3 times in 180 words.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Anti-doorway diversity&lt;/strong&gt; (new in v3):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;BANNED OPENING TEMPLATES&lt;/strong&gt; — the description must NOT begin with: &lt;em&gt;"Within the realm of..."&lt;/em&gt;, &lt;em&gt;"Within the crowded field of..."&lt;/em&gt;, &lt;em&gt;"Within the niche of..."&lt;/em&gt;, &lt;em&gt;"Across the field of..."&lt;/em&gt;, &lt;em&gt;"Among browser-based..."&lt;/em&gt;, &lt;em&gt;"In the world of..."&lt;/em&gt;, or any near-paraphrase of &lt;em&gt;"[Prep] the [realm/field/niche/world] of [genre]"&lt;/em&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;BANNED VOICE PATTERNS&lt;/strong&gt; — do NOT use &lt;em&gt;anywhere&lt;/em&gt;: &lt;em&gt;"Veterans of [X] recognize..."&lt;/em&gt;, &lt;em&gt;"Veterans of the genre..."&lt;/em&gt;, &lt;em&gt;"Serious players of the genre find..."&lt;/em&gt;, &lt;em&gt;"What separates casual from committed play..."&lt;/em&gt;, &lt;em&gt;"Players of [X] often discover..."&lt;/em&gt;. The same ideas may be expressed, but rephrased every time using the game's specific mechanics.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;JARGON BUDGET&lt;/strong&gt; — across the entire description, use AT MOST 2 of: &lt;em&gt;hitbox&lt;/em&gt;, &lt;em&gt;tick rate&lt;/em&gt;, &lt;em&gt;frame-pacing&lt;/em&gt;, &lt;em&gt;metagame&lt;/em&gt;, &lt;em&gt;RNG floor&lt;/em&gt;, &lt;em&gt;input lag&lt;/em&gt;. Default to 0–1. Pick only what is actually visible in the game.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;OPENING ANCHOR&lt;/strong&gt; — the first sentence anchors on something CONCRETE and game-specific (not generic genre framing). Pick one of six modes: (a) Concrete action/mechanic, (b) Visual/scene specifics, (c) Input/control feel, (d) Design-choice observation, (e) Physics/numerical constraint, (f) Contrast/contradiction.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;NAME ANCHORING&lt;/strong&gt; — the game name must appear in the first 80 characters.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Server-side rejection&lt;/strong&gt;: after generation, we regex-test the output against the banned openings/voices and the jargon-count budget. Any violation is rejected and the generation retried.&lt;/p&gt;




&lt;h2&gt;
  
  
  What v3 actually produces
&lt;/h2&gt;

&lt;p&gt;A handful of side-by-side examples (real games, real outputs):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Game&lt;/strong&gt;: &lt;code&gt;urban-echo&lt;/code&gt; (parkour runner)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;v2&lt;/strong&gt;: &lt;em&gt;"Within the realm of stylized urban runner-platformers, this title positions itself as a deceptively straightforward exercise in rooftop momentum. Veterans of the genre recognize that what separates casual from committed play..."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v3&lt;/strong&gt;: &lt;em&gt;"Sliding under a low-hanging pipe in Urban Echo requires split-second timing that often outweighs raw speed. This browser-based parkour game challenges players to navigate a series of urban rooftops..."&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Game&lt;/strong&gt;: &lt;code&gt;bazooka-survivors&lt;/code&gt; (bullet-hell arena)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;v2&lt;/strong&gt;: &lt;em&gt;"Among browser-based bullet hell shooters, this title stands out by combining relentless enemy waves with surprisingly deliberate pacing..."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v3&lt;/strong&gt;: &lt;em&gt;"Swarms of enemies press relentlessly in Bazooka Survivors, forcing rapid decisions amid chaotic bullet patterns. This arcade shooter demands not only quick reflexes but also strategic positioning..."&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Game&lt;/strong&gt;: &lt;code&gt;granny-the-game&lt;/code&gt; (stealth horror)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;v2&lt;/strong&gt;: &lt;em&gt;"Within the claustrophobic confines of a decrepit house, players face a tense stealth challenge that hinges on sound as much as sight..."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v3&lt;/strong&gt;: &lt;em&gt;"Granny: 5-day stealth horror in a creepy house. Find the keys, avoid Granny's hearing radius, escape before sundown. Browser play, no download required."&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The v3 outputs share &lt;em&gt;no&lt;/em&gt; opening fingerprint across pages of different genres. They mention specific game mechanics (low-hanging pipe, hearing radius, 5-day timer) that the model could only emit by actually engaging with the prompt's reference to the source material.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verification script (run this in CI)
&lt;/h2&gt;

&lt;p&gt;We open-sourced the homogeneity check. It does five things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Counts banned-opening hits across the corpus&lt;/li&gt;
&lt;li&gt;Counts banned-voice phrase occurrences&lt;/li&gt;
&lt;li&gt;Builds a normalized 50-character prefix per entry (strips game name + digits) and reports duplicates&lt;/li&gt;
&lt;li&gt;Counts jargon-term overuse per entry (&amp;gt;2 = budget exceeded)&lt;/li&gt;
&lt;li&gt;Optional &lt;code&gt;--strict&lt;/code&gt; mode that exits 1 if any threshold fails — perfect for CI gates&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Sample output on our v2 corpus before the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1] Banned opening frequency (target: each &amp;lt; 5% of corpus)
  FAIL Within the realm of: 1576 / 6728 (23.42%)
  FAIL Within the crowded field of: 428 / 6728 (6.36%)

[2] Banned voice phrase frequency (target: each &amp;lt; 5%)
  FAIL Veterans of [X] recognize: 2571 (38.21%)
  FAIL What separates casual from committed play: 3804 (56.54%)

[3] Top 12 normalized prefixes:
  (70x | 1.04%) "within the realm of browser puzzle games this titl"
  (56x | 0.83%) "within the realm of browser based games this title"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same script on the v3 entries we've migrated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;--strict: PASSED
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a subtle lesson in step 3 of that script. When we first wrote it, the prefix-uniqueness test fired a false positive on small samples — every prefix in an 11-entry test set hit &lt;code&gt;1/11 = 9.09%&lt;/code&gt;, which our naïve threshold flagged as failure. The fix is to require &lt;em&gt;actual duplication&lt;/em&gt; (count &amp;gt;= 2) before any percentage threshold matters. Single occurrence is unique by definition. We learned this on a real CI failure that wasted ~$2 of API calls — not catastrophic, but a nice reminder that homogeneity checks need to distinguish "same prefix" from "small sample size".&lt;/p&gt;




&lt;h2&gt;
  
  
  What this hasn't fixed yet (honesty section)
&lt;/h2&gt;

&lt;p&gt;The v3 prompt is correct. But only a small fraction of our 6,820-entry corpus has been migrated as of writing. We're running batched migration via GitHub Actions (~$0.30 per 100-slug batch). At our current cadence we'll hit 50% v3 coverage in about three weeks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not all-at-once?&lt;/strong&gt; Two reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt;: ~$30 to redo the whole corpus + es/pt/fr translations. Manageable, but not casual.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Risk&lt;/strong&gt;: a single all-at-once rewrite means a single deploy where the entire site's content changes. If the v3 prompt has any latent issue we haven't found, it would ship to all 6,820 pages simultaneously. Batched migration gives us four daily checkpoints to verify the v3 corpus's homogeneity score before committing to the next batch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We're also not naïvely expecting a linear recovery. Google's domain-level quality assessment is built from the pages it has crawled. To shift the assessment, we need to change &lt;em&gt;enough&lt;/em&gt; of the corpus that the next time Google re-evaluates the domain, the n-gram fingerprint has measurably moved. The literature suggests 4–8 weeks after the corpus reaches &amp;gt;50% v3 before the indexing rate visibly responds.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bake banned patterns into the prompt from day one, not as positive examples.&lt;/strong&gt; The mistake we made was thinking &lt;em&gt;"giving the model six varied voice patterns will produce variety"&lt;/em&gt;. The model doesn't pick uniformly from six options across 6,800 generations — it picks the easiest one. The fix is to &lt;em&gt;prevent&lt;/em&gt; high-frequency openers, not to &lt;em&gt;suggest&lt;/em&gt; alternatives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat the homogeneity check as a CI gate, not a post-hoc audit.&lt;/strong&gt; If we'd had this in place at v2, we would have caught the 56% same-opener rate after generation #500, not after #6,728. Cheap dollars vs expensive ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't trust LLM "creativity" claims.&lt;/strong&gt; The v2 prompt was 480 words long and produced a 56%-identical opener. The v3 prompt is 730 words long, with 11 explicit constraints. Most of those constraints are negative ("don't do X"). LLMs need negative space defined; positive examples are not enough.&lt;/p&gt;




&lt;h2&gt;
  
  
  Read more
&lt;/h2&gt;

&lt;p&gt;If your portal or content site uses an LLM-driven descriptions pipeline, run a homogeneity check on your corpus today. Three minutes. If it passes, great. If it fails the way ours did, you have a working diagnosis before the next core update.&lt;/p&gt;

&lt;p&gt;The site this lesson came from: &lt;a href="https://doodoo.love" rel="noopener noreferrer"&gt;DooDoo.Love&lt;/a&gt; — a free HTML5 browser games portal with 6,800+ titles.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;About the author: Steuber Alberto is the editor at &lt;a href="https://doodoo.love" rel="noopener noreferrer"&gt;DooDoo.Love&lt;/a&gt;. Reach me at &lt;a href="mailto:support@doodoo.love"&gt;support@doodoo.love&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>seo</category>
      <category>webdev</category>
      <category>programmatics</category>
    </item>
  </channel>
</rss>
