跳到正文
DEV Community· Yuriy Stasyuk·· 3 小時前AI 評分49

Elemon 開發筆記:單一資料檔、多個建置腳本與網域遷移

One Data File, a Pile of Build Scripts and a Domain Move: Notes From Building Elemon

AI 導讀

Elemon 開發者分享這款瀏覽器卡牌遊戲的建置方式:118 種元素卡的資料集中在 data.js,供遊戲、生成工具及測試共用。各生成器支援 --check,CI 會在每次 pull request 執行;Node 測試亦以化學事實驗證遊戲數值。遊戲遷至 elemon.win 時,透過 301 重新導向保留舊連結的路徑及查詢字串。

正文

Elemon is a free browser card game I built with my sons. Every one of the 118 elements is a monster card, and every move on a card is a true fact about that element. Kids play the computer or a friend, and there's no account, download or ads.

The game part is fun to talk about. This post is about the boring parts that ended up mattering more: where the element data lives, how the static pages stay in sync with it, how I test facts, and what I did when the game moved to its own domain in late October.

The home page on a phone
The home page on a phone. Everything on it comes out of one data file.

All 118 elements live in one file

There's a single file, data.js, with one object per element. It sets window.ELEMON to an array of 118 records and runs about 5,000 lines. Here's sodium, trimmed:

{
  "z": 11,
  "sym": "Na",
  "name": "Sodium",
  "epithet": "the Salt Lamp",
  "family": "Alkali",
  "combat": "Blast",
  "attacks": [
    {
      "name": "Salt Bond",
      "effect": "team move: hits harder when Chlorine, its bond buddy, is on your team.",
      "why": "Sodium grabs onto chlorine and the two atoms lock together as the same table salt you eat."
    }
  ],
  "bonds": [{ "with": "Cl", "as": "Salt" }],
  "period": 3,
  "file": "011-Na-Sodium.jpg"
}

The why field is the one I care about. That's the sentence a kid reads when the move fires, so it's the sentence that has to be true.

Because it's a plain script that assigns to window, the browser loads it directly with no bundler. Node scripts load it too, with new Function("window", src) or vm.runInNewContext, so the same file feeds the game, the build tools and the tests without a second copy.

The catalog builder that feeds the game server starts like this:

const cards = new Function("window", src + "\nreturn window.ELEMON;")({});
if (!Array.isArray(cards) || cards.length !== 118) {
  throw new Error("expected 118 cards, got " + (cards && cards.length));
}

The first 20 elements also get a separate elements-1-20.json with quick facts for their own pages (state at room temperature, who discovered it, four facts). Its _about field notes that the discovery dates were cross-checked against Wikipedia's "Discovery of chemical elements" page in October 2026.

Every generator has a --check mode

The site is static. There's no framework and no build step on deploy. Instead there's a folder of small Node scripts that read the data and write HTML that gets committed:

  • the Worker's card catalog
  • the /learn/ guides, from markdown files
  • one page per element for the first 20, plus the sitemap
  • the prerendered Elemondex and home page
  • the teacher page and the printables pages

Each one takes --check, which rebuilds in memory, diffs the result against what's committed, and exits 1 if anything differs. CI runs all of them on every pull request. So if I fix a fact in data.js and forget to rerun the builders, the PR goes red before the wrong version of the fact can sit on some page nobody remembered.

This is the cheapest fact-checking tool I have. A fact gets fixed in one place, and the build refuses to ship until every page agrees.

Tests that know some chemistry

The rules engine has 22 tests that run with plain node --test, no test framework. Most are game logic, like switches happening before attacks or a match ending at three knockouts. A few pin real chemistry:

test("real chemistry drives the stats", () => {
  assert.equal(STATS.Os.tiers.hp, 3, "osmium is the densest element");
  assert.equal(STATS.H.tiers.spd, 3, "hydrogen is the lightest");
  assert.equal(STATS.Cs.tiers.pow, 3, "caesium explodes in water");
  assert.equal(STATS.Og.tiers.hp, 1, "superheavies fall apart in a blink");
});

Stats come from a formula over density, reactivity and atomic weight, and formulas drift when you tune them. These asserts are tripwires. If osmium ever stops being tough, a test tells me before a kid does.

The tests also lock the type counts (12 Blast, 7 Life, 5 Acid and so on, adding up to 118), and they check that every card's three moves do three different things.

Not every fact can be a test. In October I had to fix a nitrogen fact (it's about 78% of the air) and pull some claims about mixing elements that the game couldn't back up. Those were plain edits to the data file, and the --check builders made sure every page picked them up.

Getting a JavaScript game indexed

The interactive parts of the home page and the Elemondex are drawn by JavaScript. Crawlers and no-JS readers would see an empty shell, so two of the generators prerender those sections into the raw HTML from the same data. When the page loads, the script swaps in identical markup, so nothing on screen changes.

The /learn/ guides are markdown with a tiny supported subset: headings, paragraphs, lists, pipe tables, bold and links. If a guide has a "Frequently asked questions" section, the builder turns those questions into FAQPage JSON-LD too, and they're visible on the page.

A learn guide on a phone
A learn guide on a phone. It started as a markdown file in the repo.

Moving to elemon.win without breaking links

Elemon used to live on a subdomain of my personal site. In late October it moved to its own domain, elemon.win. One PR changed the canonicals, the sitemap and robots.txt, and added a Pages Function that sends the old host to the new one:

// 301 old hosts to the canonical domain, keeping path + query.
const OLD = new Set([OLD_SUBDOMAIN, "www.elemon.win"]); // the old host is a plain string in the real file
export async function onRequest({ request, next }) {
  const url = new URL(request.url);
  if (OLD.has(url.hostname)) {
    return Response.redirect("https://elemon.win" + url.pathname + url.search, 301);
  }
  return next();
}

Keeping the path matters. A teacher who bookmarked the printables page on the old subdomain lands on the same printables page on the new domain, and every old URL in search results points at its exact new page.

Two small gotchas. The redirect only works while the old hostname still points at the Pages project, so the old DNS record and custom domain have to stay in place even though nothing is served from them. And the *.pages.dev address still serves the site directly, so a host-scoped rule in _headers sends X-Robots-Tag: noindex there to keep the duplicate out of search.

Multiplayer with no game server to babysit

Two kids on two phones need one shared referee, and the table should vanish when they leave. I didn't want another server to keep running. The setup I landed on is a Worker with Durable Objects: one object per table name, plus a lobby object that lists open tables. Sockets use the WebSocket hibernation API, which keeps idle tables cheap, and when a table closes, its object wipes its own storage.

Pages can't define Durable Object classes, so the rooms live in a sibling Worker and the static site talks to it over HTTPS and WebSockets. Solo matches against the computer run in the same Worker.

Reviewing generated art

The card art is AI-generated, and generated art gets details wrong. So the repo has a small review page that shows every card with a "Mistake, regenerate" checkbox. It exports the flagged cards as a text file, and that list is what gets redone.

If you want to see how the data turns into a game, it's at elemon.win. The Elemondex is all 118 records, laid out the way the periodic table lays them out.

來源:DEV Community · dev.to