<?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[MathLogic Games Dev Notes]]></title><description><![CDATA[MathLogic Games Dev Notes]]></description><link>https://thanhnt9x.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 26 Oct 2026 10:14:03 GMT</lastBuildDate><atom:link href="https://thanhnt9x.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[600+ game pages from one TypeScript file: lessons from building a static games site with Astro]]></title><description><![CDATA[So new features go in as optional fields. A few examples: Then I check it. I build the current main in a git worktree and diff the two dist/ folders: For…]]></description><link>https://thanhnt9x.hashnode.dev/600-game-pages-from-one-typescript-file-lessons-from-building-a-static-games-site-with-astro</link><guid isPermaLink="true">https://thanhnt9x.hashnode.dev/600-game-pages-from-one-typescript-file-lessons-from-building-a-static-games-site-with-astro</guid><category><![CDATA[Astro]]></category><category><![CDATA[webdev]]></category><category><![CDATA[SEO]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Thành Nguyễn]]></dc:creator><pubDate>Tue, 06 Oct 2026 10:22:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac4ca48899ded1fbe4fd72a/bcfad217-9931-481a-b69e-28296f3b7396.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>So new features go in as optional fields. A few examples:</p>
<ul>
<li><code>faqSchema: true</code> on a hub page adds FAQPage JSON-LD. Hubs built before the flag existed stay exactly as they were.</li>
<li><code>fullscreenButton: true</code> on one landscape-only game adds a "Play full screen" button for phones. The button's styles and script are written with <code>&lt;style is:inline&gt;</code> and <code>&lt;script is:inline&gt;</code> inside the conditional block. That way they don't go into the shared CSS bundle, whose hashed filename would otherwise change on every page.</li>
<li>Adding a second game platform meant making <code>wgId</code> optional and putting the iframe URL behind one function:</li>
</ul>
<pre><code class="language-ts">export function embedUrl(game: ExternalGame): string {
  return game.gamepix
    ? `https://play.gamepix.com/${game.gamepix}/embed?sid=${GAMEPIX_SID}`
    : `https://play.wgplayground.com/ifr/${game.wgId}`;
}
</code></pre>
<p>Then I check it. I build the current <code>main</code> in a git worktree and diff the two <code>dist/</code> folders:</p>
<pre><code class="language-bash">git worktree add ../main-build origin/main
ln -s "$PWD/node_modules" ../main-build/node_modules
(cd ../main-build &amp;&amp; npm run build)
npm run build
diff -rq ../main-build/dist dist
</code></pre>
<p>For the second-platform change, all 668 existing game pages came out byte-identical. Only the About page, the Privacy page and <code>llms.txt</code> (which now name the second platform) and the sitemap changed. That one command turned "I think this is safe" into "I know exactly which pages changed."
One gotcha: counters such as "Search 618+ games" or category counts in the sidebar legitimately change whenever a game is added. I normalize those with <code>sed</code> before comparing, so real changes stand out.</p>
<h2>3. Measuring play time inside a cross-origin iframe</h2>
<p>GA4 counts engagement time while the page has focus. The moment someone clicks into an embedded game, focus moves into a cross-origin iframe, and GA4 more or less stops the clock. A ten-minute session can show up as seven seconds.
You can't read anything from inside the iframe. You can, however, tell that it is the active element. So a small script polls once a second:</p>
<pre><code class="language-js">var frame = document.querySelector('.game-embed iframe');
var milestones = [60, 300];
var seconds = 0;
var timer = setInterval(function () {
  if (document.activeElement !== frame || document.hidden) return;
  if (seconds === 0) gtag('event', 'game_start', params);
  seconds += 1;
  if (seconds &gt;= milestones[0]) {
    gtag('event', 'game_play_' + milestones.shift() + 's', params);
    if (!milestones.length) clearInterval(timer);
  }
}, 1000);
</code></pre>
<p>I tried <code>blur</code>/<code>focus</code> events first, but browsers don't fire them reliably when focus moves in and out of an iframe. Polling is simple, never touches the iframe, and stops after the last milestone. That gave me the number I actually care about: what share of game-page views turn into a real play (a <code>game_start</code>), and how many of those last a minute or more.</p>
<h2>4. Full screen on phones, with an honest fallback</h2>
<p>Some embedded games only work in landscape. On a phone held upright they show "rotate your device", and if auto-rotate is locked, that's where the visit ends. The fix is one button:</p>
<pre><code class="language-js">btn.addEventListener('click', function () {
  var req = box.requestFullscreen || box.webkitRequestFullscreen;
  if (!req) return openTab(); // iPhone Safari: no element fullscreen
  Promise.resolve(req.call(box)).then(function () {
    if (screen.orientation &amp;&amp; screen.orientation.lock) {
      screen.orientation.lock('landscape').catch(function () {});
    }
  }, openTab);
});
</code></pre>
<p>On Android, Chrome makes the container full screen and locks landscape even when auto-rotate is off. iPhone Safari doesn't support element fullscreen or orientation lock, so the button opens the embed in its own tab, which at least gets rid of the site's header and sidebar. CSS on <code>:fullscreen</code> (and <code>:-webkit-full-screen</code>) makes the iframe fill the screen. The button only shows on <code>(hover: none) and (pointer: coarse)</code>, so desktop pages stay the same.</p>
<h2>5. Picking what to build next from search data, on GitHub Actions</h2>
<p>The most useful tool I built isn't part of the site. It's a Python script in a separate private repo that runs on GitHub Actions. For a list of keywords, often game names, it collects:</p>
<ul>
<li><strong>Google Trends</strong>: interest relative to a fixed anchor keyword, and a "rising" ratio of the last 4 weeks against the 8 before;</li>
<li><strong>Bing Webmaster keyword stats</strong>: actual weekly impressions;</li>
<li><strong>Google autocomplete</strong>: whether the suggestions carry game intent ("… online", "play …").
Each source gets a level (high / medium / low / none), and the verdict is the <strong>lower</strong> of the Google and Bing levels. One source alone fooled me more than once, and two agreeing is a much better filter.
There's also a mode that starts from <strong>Trends' "rising" related queries</strong> for seeds like "browser games" or "puzzle games", and then measures each one the same way. It shows which games are taking off, as opposed to which games I already thought of. It also kept me honest: most of the games that were rising fastest are exclusive to big portals and can't be embedded legally. The useful finds were the categories behind them. "Typing games", for example, was high and rising, and the site had none.
A few practical notes:</li>
<li>Trends often answers <code>429</code> to cloud IPs. GitHub's runners got through far more reliably than my dev machine did.</li>
<li>Keep secrets (the Bing API key) in repository secrets, and never print request URLs that carry them.</li>
<li>Keep only one run in flight per workflow (<code>concurrency</code>), and copy each day's CSV out before the next run overwrites it.</li>
</ul>
<h2>6. Small things that added up</h2>
<ul>
<li><strong>IndexNow on deploy.</strong> A workflow on every push to <code>main</code> builds the site and sends the changed URLs to Bing. New pages show up as "discovered" in Bing Webmaster Tools the same day.</li>
<li><strong><code>ads.txt</code> is part of the site.</strong> One surprise was a redirect rule in the Cloudflare dashboard that quietly sent <code>/ads.txt</code> somewhere else, so the file in the repo was never served. Check the live response, not the file.</li>
<li><strong>Canonical from <code>Astro.site</code>.</strong> <code>new URL(Astro.url.pathname, Astro.site)</code> gives every page an apex-domain canonical, even when it's served from <code>www</code> or a preview host.</li>
<li><strong>Check copy automatically.</strong> Each game description is written by hand. A small script checks it against the platform's own text for any shared six-word sequence before it ships.</li>
</ul>
<h2>What I'd tell myself at the start</h2>
<ol>
<li>Put everything in data and derive pages from it. Validate the data at build time.</li>
<li>Make every change opt-in, and diff the build output to prove its reach.</li>
<li>Measure the action you care about (a play), not page views.</li>
<li>Pick work from search demand checked against two sources, and start from what is rising rather than what you already know.
The site is <a href="https://mathlogicgames.com/">mathlogicgames.com</a> if you want to see how it turned out. I'm happy to answer questions about any of this in the comments.</li>
</ol>
]]></content:encoded></item></channel></rss>