DEV Community

Cover image for 600+ game pages from one TypeScript file: lessons from building a static games site with Astro
Thành Nguyễn
Thành Nguyễn

Posted on AI-assisted

600+ game pages from one TypeScript file: lessons from building a static games site with Astro

I run MathLogic Games, a free site of math, logic and puzzle games that play in the browser. The games themselves are licensed HTML5 embeds from two distribution platforms. My job is the part around them: a page per game with a real description, how-to-play steps and an FAQ, plus category pages, topic hubs and a small blog.
It is a static Astro site on Cloudflare. It has a little over 600 game pages, one person working on it, and no backend. Here are the patterns that turned out to matter, in case you are building something similar: a catalog site, a directory, or any site where most pages come from data.

1. One data file, every page derived from it

Every game is one object in a single TypeScript file:

export interface ExternalGame {
  wgId?: string;        // id on the first platform
  gamepix?: string;     // or: namespace on the second platform
  slug: string;
  title: string;
  categories: string[]; // first one is the "primary" category
  description: string;  // 150–160 chars, used for meta + cards
  longDescription?: string;
  howToPlay?: string[];
  faq?: { q: string; a: string }[];
  thumbnail: string;
  updatedAt: string;    // feeds the sitemap <lastmod>
  isNew?: boolean;
  tags?: string[];      // e.g. 'halloween' → links to a seasonal hub
}
Enter fullscreen mode Exit fullscreen mode

Category pages, the "new this week" shelf, the search index, hubs, related-game shelves and the sitemap all read from this one array. Adding a game is a single edit, and it shows up everywhere it should.
Two small things made this hold up as the list grew past a few hundred entries.
A guard that fails the build. Pasting in a batch of games is exactly when you end up with duplicate ids or slugs. So the data file checks itself when it is imported:

for (const g of externalGames) {
  if (!g.wgId === !g.gamepix) {
    throw new Error(`Game "${g.title}" needs exactly one of wgId or gamepix`);
  }
  const id = g.wgId ?? `gamepix:${g.gamepix}`;
  if (seenIds.has(id)) throw new Error(`Duplicate game id "${id}"`);
  seenIds.add(id);
  if (seenSlugs.has(g.slug)) throw new Error(`Duplicate slug "${g.slug}"`);
  seenSlugs.add(g.slug);
}
Enter fullscreen mode Exit fullscreen mode

Sitemap lastmod from the data, not the build time. @astrojs/sitemap accepts a serialize hook. Each game URL gets its own updatedAt, a category page gets the newest updatedAt among its games, and a handful of static pages have hard-coded dates. If every URL says it changed on every deploy, the signal stops meaning anything.

2. Add features as opt-in fields, then prove nothing else changed

The rule I follow: a change aimed at a few pages must not touch the HTML of the other 600. It keeps search engines from seeing a site-wide change I didn't mean to make, and it makes it possible to tell later what actually moved the numbers.
So new features go in as optional fields. A few examples:

  • faqSchema: true on a hub page adds FAQPage JSON-LD. Hubs built before the flag existed stay exactly as they were.
  • fullscreenButton: true on one landscape-only game adds a "Play full screen" button for phones. The button's styles and script are written with <style is:inline> and <script is:inline> inside the conditional block. That way they don't go into the shared CSS bundle, whose hashed filename would otherwise change on every page.
  • Adding a second game platform meant making wgId optional and putting the iframe URL behind one function:
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}`;
}
Enter fullscreen mode Exit fullscreen mode

Then I check it. I build the current main in a git worktree and diff the two dist/ folders:

git worktree add ../main-build origin/main
ln -s "$PWD/node_modules" ../main-build/node_modules
(cd ../main-build && npm run build)
npm run build
diff -rq ../main-build/dist dist
Enter fullscreen mode Exit fullscreen mode

For the second-platform change, all 668 existing game pages came out byte-identical. Only the About page, the Privacy page and llms.txt (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 sed before comparing, so real changes stand out.

3. Measuring play time inside a cross-origin iframe

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:

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 >= milestones[0]) {
    gtag('event', 'game_play_' + milestones.shift() + 's', params);
    if (!milestones.length) clearInterval(timer);
  }
}, 1000);
Enter fullscreen mode Exit fullscreen mode

I tried blur/focus 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 game_start), and how many of those last a minute or more.

4. Full screen on phones, with an honest fallback

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:

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 && screen.orientation.lock) {
      screen.orientation.lock('landscape').catch(function () {});
    }
  }, openTab);
});
Enter fullscreen mode Exit fullscreen mode

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 :fullscreen (and :-webkit-full-screen) makes the iframe fill the screen. The button only shows on (hover: none) and (pointer: coarse), so desktop pages stay the same.

5. Picking what to build next from search data, on GitHub Actions

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:

  • Google Trends: interest relative to a fixed anchor keyword, and a "rising" ratio of the last 4 weeks against the 8 before;
  • Bing Webmaster keyword stats: actual weekly impressions;
  • Google autocomplete: whether the suggestions carry game intent ("… online", "play …"). Each source gets a level (high / medium / low / none), and the verdict is the lower 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 Trends' "rising" related queries 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:
  • Trends often answers 429 to cloud IPs. GitHub's runners got through far more reliably than my dev machine did.
  • Keep secrets (the Bing API key) in repository secrets, and never print request URLs that carry them.
  • Keep only one run in flight per workflow (concurrency), and copy each day's CSV out before the next run overwrites it. ## 6. Small things that added up
  • IndexNow on deploy. A workflow on every push to main builds the site and sends the changed URLs to Bing. New pages show up as "discovered" in Bing Webmaster Tools the same day.
  • ads.txt is part of the site. One surprise was a redirect rule in the Cloudflare dashboard that quietly sent /ads.txt somewhere else, so the file in the repo was never served. Check the live response, not the file.
  • Canonical from Astro.site. new URL(Astro.url.pathname, Astro.site) gives every page an apex-domain canonical, even when it's served from www or a preview host.
  • Check copy automatically. 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. ## What I'd tell myself at the start
  • Put everything in data and derive pages from it. Validate the data at build time.
  • Make every change opt-in, and diff the build output to prove its reach.
  • Measure the action you care about (a play), not page views.
  • Pick work from search demand checked against two sources, and start from what is rising rather than what you already know. The site is mathlogicgames.com if you want to see how it turned out. I'm happy to answer questions about any of this in the comments.

Top comments (0)