DEV Community

ZHIWEI
ZHIWEI

Posted on

How I Keep a Fan Guide in Sync With a Viral Browser Game Using a GitHub Actions Cron

A Nigerian browser game called Lagos Life launched at the start of October 2026 and went viral fast. I run an independent fan guide for it, and within a few days I hit the problem every guide writer for a live game hits: the game changes faster than I can edit pages.

Players elect a governor who sets a law that changes the economy. There's a new in-game headline every day, a daily gem hunt with a fresh clue, and the developers tweak job and business tables without notice. A hand-maintained page is stale within days.

So I stopped editing by hand. This post walks through the small pipeline that now does it for me: a GitHub Actions cron that reads what the official site already serves to every visitor, writes a few JSON files, and rebuilds a static site only when something actually changed.

The setup

  • The guide is a prerendered static site (TanStack Start) on Cloudflare Pages.
  • Pages read their data from JSON files committed in the repo (live-log.json, jobs.json, …) at build time.
  • One workflow runs every 3 hours:
fetch scripts ──▶ anything changed? ──no──▶ stop
                        │ yes
                        ▼
                 build ▶ deploy ▶ ping IndexNow ▶ commit the JSON
Enter fullscreen mode Exit fullscreen mode
on:
  schedule:
    - cron: '17 */3 * * *'   # minute 17, not 0: avoid the top-of-hour rush
  workflow_dispatch:

concurrency:
  group: live-log
  cancel-in-progress: false  # never kill a run halfway through a deploy
Enter fullscreen mode Exit fullscreen mode

Source 1: the public JSON the site already serves

The official site calls a few JSON endpoints for logged-out visitors: the current governor and law, the daily headline, and the gem hunt. Reading those is the easy part — a plain fetch with a timeout, a couple of retries, and a User-Agent that says who I am and where the data ends up:

const UA = 'my-guide live log (+https://example.com/updates/)';

async function get(path) {
  for (let i = 1; ; i++) {
    try {
      const res = await fetch(`${API}/${path}`, {
        headers: { 'User-Agent': UA, Accept: 'application/json' },
        signal: AbortSignal.timeout(20_000),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return await res.json();
    } catch (e) {
      if (i >= 3) throw new Error(`${path}: ${e.message}`);
      await new Promise((r) => setTimeout(r, 5_000));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Two decisions mattered more than the code:

  1. Store only what the page shows. The governor endpoint also returns election candidates and player-written announcements. I never write those to disk. The log keeps the law id, when it was set, the daily date/weather/headline/tip, and the hunt's clue — nothing else.
  2. Append, don't overwrite. A new law creates a new entry with a firstSeen timestamp, so the page becomes a dated history instead of a snapshot. If only the term-end time moves, the last entry is updated in place.

The optional endpoint (the gem hunt) is wrapped in its own catch, so if it breaks, the law and headline log still runs.

Source 2: when there is no API — read the bundle

Job tables and pay rules aren't exposed as JSON; they live in the game's client JavaScript. The game is a Next.js app, so the homepage HTML references chunks under /_next/static/chunks/, and chunks reference more chunks.

The script does a small breadth-first crawl: start from the chunk names in the homepage, fetch six at a time, queue any new chunk names found inside, and stop as soon as every value it needs has been found (hard cap: 400 chunks).

Each value has its own finder, a function that returns null until it sees its pattern in some chunk:

const FINDERS = {
  careers:   (t) => (t.includes('"CAREERS",0,') ? true : null),
  starBonus: (t) => {
    const m = t.match(/"STAR_BONUS",0,([\d.]+)/);
    return m ? Number(m[1]) : null;
  },
  // ...one finder per rule constant
};
Enter fullscreen mode Exit fullscreen mode

Minified variable names change on every build, so the patterns anchor on things that survive minification — string literals and object shapes like {id:"…",name:"…",levels:[{title:"…",pay:…}]} — never on short identifiers like e or a.

Guard rails: fail loudly, keep the last good data

A regex scraper against minified code will break one day. The goal is that when it breaks, the site stays correct and I get told.

  • Validate before writing. If fewer than 30 careers parse, or any career doesn't have five paid levels, or a workplace has no name, the script writes nothing and exits 1.
  • Isolate failures. Each fetch script is its own workflow step with continue-on-error: true, so a broken job-table parser never blocks the law log.
  • Two dates, not one. checkedAt moves on every good read; changedAt moves only when the table actually differs. The page shows both, so readers can tell "verified today" from "changed today".
  • Be polite. The bundle crawl runs at most once per Lagos day (WAT, UTC+1). If today's check already happened, it makes zero requests.
const WAT_MS = 60 * 60 * 1000;
const today = new Date(Date.now() + WAT_MS).toISOString().slice(0, 10);
if (data?.checkedAt === today) return; // already checked today
Enter fullscreen mode Exit fullscreen mode

Commit only after a successful deploy

This one took me a while to get right. If the workflow committed the new JSON before deploying and the deploy then failed, the next run would see "no change" and the live site would stay stale forever.

So the order is: fetch → build → deploy → commit. The data file only lands in git once the site that shows it is live. A failed run leaves git untouched, and the next run three hours later simply tries again.

The build step also greps the generated sitemap.xml for the expected URLs before deploying — a cheap check that catches a build that "succeeded" but produced the wrong site.

Tell search engines only about pages that changed

After a deploy, the workflow pings IndexNow — but only for the pages whose content changed. A new check date alone rebuilds the jobs page without a ping; a new law pings only the updates page. Pinging unchanged URLs every three hours would just be noise.

paths=""
if [ "$LIVE" = true ]; then paths="/updates/"; fi
if [ "$JOBS_TABLE" = changed ]; then paths="${paths:+$paths,}/jobs/"; fi
Enter fullscreen mode Exit fullscreen mode

What it produces

Since 8 October the log has been filling itself: each law with the date it was first seen, every daily headline, every gem-hunt clue (clues only — no answers). That's the updates page of the guide:

→ Lagos Life Updates: This Week's Law, Governor & Gem Hunt

The game itself is at lagoslife.app; my site is an independent fan guide, not affiliated with the developers, and everything it reads is what the official site already serves to any visitor.

Takeaways

  • For a fast-changing live game, a 3-hourly cron + static rebuild is plenty — no server, no database.
  • Anchor bundle-scraping patterns on string literals and shapes, and validate hard before writing.
  • Commit after deploy, so a failed run is automatically retried.
  • Store the minimum — especially anything player-written or personal.

If you've built something similar for another live game, I'd love to hear how you handle the parser breaking.

Top comments (0)