fuzzyworld

explodorama / commit

Semver 0.1.0 as the base release, with an in-game list of changes

5607bea2a511193858423d1dfa16f8a80e69663b · Pavel Safronov · 2026-10-31 11:58:43 -0700 · parents 3f63b718d445

Claude Code usage: this commit 31.9K out / 2.7M cached (first commit of its session: everything since it began) · cost unknown (no per-model trailer) · session 7 tally 100 in / 31.9K out / 2.7M cached · usage by commit and session

Semver 0.1.0 as the base release, with an in-game list of changes

The game's version is now semver (0.1.0); SIM_VERSION stays as the rules
version, shown beside it. src/changelog.ts holds the releases and UNRELEASED;
the menu's version opens the list, and a returning player is shown what came
after the version they last played (localStorage), when the demo ends.

FW-Tokens-In: 100
FW-Tokens-Out: 31914
FW-Tokens-Cached: 2679855
FW-Session: fd332071-d2da-4a3e-a9ee-59cdeec5b405
FW-Rtk-Saved: 2702746
FW-Rtk-Cmds: 7281

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 CHANGELOG.md           | 15 +++++++--
 README.md              | 20 ++++++++----
 decisions.md           | 14 ++++++++
 src/changelog.ts       | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++
 src/main.ts            | 27 +++++++++++++---
 src/style.css          | 17 ++++++++++
 src/ui/whatsnew.ts     | 85 ++++++++++++++++++++++++++++++++++++++++++++++++
 test/changelog.test.ts | 53 ++++++++++++++++++++++++++++++
 test/version.test.ts   |  4 +--
 9 files changed, 309 insertions(+), 14 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75fafb7..e7d9903 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,11 @@
 # Changelog
 
 All notable changes to Explodorama are written down here. The format follows
-[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). There has been no tagged release
-yet, so everything so far is under **Unreleased**.
+[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Releases are semver, git-tagged
+by Pavel. **0.1.0 is the base**: everything under it below is part of it, and the player-facing list
+of changes in the game ([src/changelog.ts](src/changelog.ts)) starts there. From now on, each entry
+that reaches players is also added to `UNRELEASED` in that file. "Version N" in older entries is
+the rules version N (`SIM_VERSION`).
 
 A saved run is only resumed if it was made with the same version of the rules
 (`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)). When a change below bumps it, a run
@@ -12,6 +15,14 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Added
 
+- **Semver releases and an in-game list of changes:** the game's version is 0.1.0 (the base); the menu shows it with the rules version, and tapping it lists every release. A returning player is shown what came after the version they last played, when the opening demo ends.
+
+## [0.1.0] - 2026-10-31
+
+The base: everything below.
+
+### Added
+
 - **Deadly moves are marked:** Wait turns red when waiting would cost you a life, and with a button picked, a tile that would is red with a cross. Taps, keys with nothing picked, the long press and auto-move refuse such a move (the tile flashes red); the button then the tile still makes it.
 
 - **Demo scenes for the board objects** (hiding in a bush, a bush fire, metal stopping flame, an oil spill, crossing spikes, a barrel going off), and a **Board** tab in the almanac listing crates, pillars, every object and rubble, with clips.
diff --git a/README.md b/README.md
index b577e8d..506cb77 100644
--- a/README.md
+++ b/README.md
@@ -11,12 +11,20 @@ seven charges (one carried at a time, used when you choose), a hidden gem on eve
 mid-run work. Curses and the Reaper enemy are planned —
 see [PLAN.md](PLAN.md).
 
-Version: **40**, shown at the bottom of the menu with the commit the game was built from (a link
-to it on GitLab, plus "plus local changes" in a build with uncommitted changes) and a link to the
-[source code](https://code.fuzzyworld.net/explodorama/). The version is the rules version
-(`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)): it goes up by one whenever a rule
-change would make an old run replay differently. A run saved under another version is not
-resumed, and a seed gives the same dungeon only within one version.
+Version: **0.1.0** (semver; the base release). The menu's bottom line shows it with the rules
+version and the commit the game was built from (a link to it on GitLab, plus "plus local
+changes" in a build with uncommitted changes) and a link to the
+[source code](https://code.fuzzyworld.net/explodorama/). Tapping the version opens the list of
+changes in every release, each with its date and, for most, a demo scene. A player who comes
+back on a newer version is shown what came after the one they last played (kept in this
+browser's localStorage) when the opening demo ends; a first visit, and a test URL, show nothing. Releases are git tags, made when Pavel decides a batch is ready; between
+them, finished changes collect in `UNRELEASED` in [src/changelog.ts](src/changelog.ts), and
+tagging moves them into a new release there and bumps `version` in package.json.
+
+Rules version: **40** (`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)). It goes up by one
+whenever a rule change would make an old run replay differently, independent of the release
+version (one release can span several). A run saved under another rules version is not resumed,
+and a seed gives the same dungeon only within one rules version.
 
 ![The board on a phone, with a bomb counting down](docs/screenshots/countdown.png)
 
diff --git a/decisions.md b/decisions.md
index 2e133d7..93840da 100644
--- a/decisions.md
+++ b/decisions.md
@@ -2251,3 +2251,17 @@ ink (`#353541`) and sizes to 128 px: four tall-grass tufts (`grass_a`–`grass_d
 canopies (`canopy_a`, `canopy_b`, composed but not used yet). A bush's picture is picked from its
 cell index (`bushSprite`), so neighbours differ but a bush never changes picture between frames.
 Credited in the menu and the README with the other packs.
+
+### 2026-10-31 — Semver releases and an in-game list of changes
+
+User request (2026-10-31): the game gets semver versions, git-tagged releases, and a "what's new
+since you last played" dialog with demo scenes and text. Decided with the user: **0.1.0 is the
+base** (no history is rebuilt; the list starts there); the last version played is kept in
+localStorage (`explodorama.lastVersion`); Claude keeps the version, `UNRELEASED` and the tags
+consistent, **Pavel decides when a release happens**. `SIM_VERSION` stays as the rules version
+(shown as "rules 40" beside the release) and is independent of it. The data lives in
+`src/changelog.ts` (releases, each change with kind, date, text and optional clip); a first
+visit only records the version and shows nothing; the dialog opens when the demo ends. Tapping the
+version in the menu lists every release. A change reaching players goes into `UNRELEASED` as it
+is finished; tagging moves them into a new release and bumps package.json (checked by
+test/changelog.test.ts).
diff --git a/src/changelog.ts b/src/changelog.ts
new file mode 100644
index 0000000..44fe341
--- /dev/null
+++ b/src/changelog.ts
@@ -0,0 +1,88 @@
+import type { SceneKind } from './sim/demo'
+
+/**
+ * What changed in each release, for the player (user request, 2026-10-31). Shown in the game as
+ * "what's new since you last played" and, from the menu's version, as the full list. Not the
+ * developer's CHANGELOG.md, which is longer and written for people who read the code.
+ *
+ * Versions are semver, git-tagged by hand when Pavel decides a batch is ready to release. 0.1.0
+ * is the base: nothing before it is listed. Between releases, finished changes collect in
+ * UNRELEASED; when a release is tagged they move into a new entry at the top of RELEASES, and
+ * `version` in package.json goes up to match (test/changelog.test.ts checks that).
+ *
+ * This is separate from SIM_VERSION (sim/record.ts), the rules version that decides whether a
+ * saved run resumes. One release can contain several rules versions.
+ */
+export interface Change {
+  kind: 'added' | 'changed' | 'removed'
+  /** Sprite name, from `assets/kenney/`. */
+  icon: string
+  title: string
+  /** Paragraphs of plain text, as in the almanac. */
+  text: string[]
+  /** The demo scene its entry plays as a looping clip (ui/clip.ts); none for a removal. */
+  clip?: SceneKind
+  /** When it was added, as YYYY-MM-DD. */
+  date: string
+}
+
+export interface Release {
+  /** Semver, x.y.z. */
+  version: string
+  /** YYYY-MM-DD. */
+  date: string
+  /** One line: what this release is. */
+  summary: string
+  changes: Change[]
+}
+
+/** Newest first. */
+export const RELEASES: Release[] = [
+  {
+    version: '0.1.0',
+    date: '2026-10-31',
+    summary: 'The first release, and the base later ones are compared with. The Almanac in the menu describes everything in it.',
+    changes: [],
+  },
+]
+
+/** Finished since the latest release, not yet part of one. Moved into RELEASES when one is tagged. */
+export const UNRELEASED: Change[] = []
+
+/** The version being played. */
+export const VERSION = RELEASES[0]!.version
+
+/** Parse "x.y.z" into numbers; null if it isn't that. */
+export function parseVersion(v: string): [number, number, number] | null {
+  const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v)
+  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
+}
+
+/** Negative if a < b, 0 if equal, positive if a > b. An unparseable version counts as older than any. */
+export function compareVersions(a: string, b: string): number {
+  const pa = parseVersion(a)
+  const pb = parseVersion(b)
+  if (!pa || !pb) return pa ? 1 : pb ? -1 : 0
+  for (let i = 0; i < 3; i++) {
+    const d = pa[i]! - pb[i]!
+    if (d) return d
+  }
+  return 0
+}
+
+/** The releases newer than `last`, newest first; none if `last` is current (or newer, after a rollback). */
+export function releasesSince(last: string, releases: Release[] = RELEASES): Release[] {
+  return releases.filter((r) => compareVersions(r.version, last) > 0)
+}
+
+/**
+ * What to show a player who last played `last` (null: never, or a value that wasn't kept), and
+ * whether to record the current version now. A first visit has nothing to compare with: nothing
+ * to show, record it. Newer releases: show them (recording waits until they are shown). Current:
+ * nothing. Newer than the game (a rollback): nothing, and the newer record stays.
+ */
+export function newsFor(last: string | null, releases: Release[] = RELEASES): { news: Release[]; record: boolean } {
+  if (last === null) return { news: [], record: true }
+  const news = releasesSince(last, releases)
+  return { news, record: !news.length && compareVersions(last, releases[0]!.version) < 0 }
+}
diff --git a/src/main.ts b/src/main.ts
index 66d1c3d..7984925 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -34,6 +34,8 @@ import { LAST_LEVEL } from './sim/levelgen'
 import { step } from './sim/turn'
 import { CHARGE_IDS, DEFAULT_OPTIONS, DIRS, type Action, type ActionId, type ChargeId, type Dir, type GameEvent, type GameState, type RunOptions, type Vec } from './sim/types'
 import { openAlmanac } from './ui/almanac'
+import { VERSION } from './changelog'
+import { newSinceLastVisit, openChanges } from './ui/whatsnew'
 import { createControls } from './ui/controls'
 import { closeDialog, dialogOpen, openDialog, setDialogInDemo, type DialogButton } from './ui/dialog'
 import { actionInfo, chargeInfo, hudInfo, tileInfo, type Info } from './ui/info'
@@ -434,7 +436,7 @@ function bugDetails(withRecord: boolean): string {
   const modes = [state.options.dev && 'Developer', state.options.immortal && 'Immortal'].filter(Boolean).join(', ') || 'none'
   const errors = recentErrors()
   return [
-    `Version ${SIM_VERSION}${commit}`,
+    `Version ${VERSION} (rules ${SIM_VERSION})${commit}`,
     `Device: ${device()}; ${navigator.userAgent}`,
     `Seed ${state.seed}, level ${state.level}, turn ${state.turn}, ${state.lives} lives, ${state.status}; modes: ${modes}`,
     `Errors caught: ${errors.length ? '\n' + errors.map((e) => `- ${e}`).join('\n') : 'none'}`,
@@ -450,7 +452,7 @@ function bugDetails(withRecord: boolean): string {
 function reportBug(): void {
   const full = bugDetails(true)
   const mailto = (details: string) =>
-    `mailto:${BUG_EMAIL}?subject=${encodeURIComponent(`Bug report: version ${SIM_VERSION}, level ${state.level}`)}` +
+    `mailto:${BUG_EMAIL}?subject=${encodeURIComponent(`Bug report: version ${VERSION} (rules ${SIM_VERSION}), level ${state.level}`)}` +
     `&body=${encodeURIComponent(`What happened:\n\n\n\n---\n${details}`)}`
   const withRecord = mailto(full)
   if (withRecord.length <= MAILTO_MAX) {
@@ -487,8 +489,9 @@ function showMenu(): void {
       feedbackChoices() +
       `<p class="credit">Art and sounds by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a>: ` +
       `New Platformer Pack, UI Pack, UI Pack: RPG Expansion, Game Icons, Board Game Icons, Splat Pack and Foliage Sprites (CC0). Thank you!</p>` +
-      // The version is the rules version: it changes exactly when old saves stop resuming.
-      `<p class="credit">Version ${SIM_VERSION}${commitLine()}. ` +
+      // The version is the release (semver, tagged by hand); the rules version, in brackets, changes
+      // exactly when old saves stop resuming. The version opens the list of changes.
+      `<p class="credit">Version <a href="#" id="changes-link">${VERSION}</a> (rules ${SIM_VERSION})${commitLine()}. ` +
       `<a href="${REPO_URL}/" target="_blank" rel="noopener">Source code</a></p>`,
     buttons: [
       { label: 'Resume', kind: 'primary', icon: playIcon },
@@ -500,6 +503,10 @@ function showMenu(): void {
     ],
     className: 'menu',
   })
+  document.getElementById('changes-link')!.addEventListener('click', (e) => {
+    e.preventDefault()
+    closeDialog(() => openChanges())
+  })
   // The switches work at once, without a button (user request, 2026-10-24: sounds and vibration).
   for (const input of document.querySelectorAll<HTMLInputElement>('#dialog-body .feedback input')) {
     input.addEventListener('change', () => {
@@ -1246,6 +1253,15 @@ function endDemo(): void {
   showOffer()
   showChargeOffer()
   if (state.shop) openShopDialog()
+  // What changed since the player last played, once the demo is out of the way.
+  // After the demo's own dialog has finished closing (dialog.ts); not over a choice that reopened.
+  if (news.length) {
+    setTimeout(() => {
+      if (dialogOpen()) return
+      openChanges(news, true)
+      news = []
+    }, 250)
+  }
 }
 
 // The tap or key that ends the demo goes no further: not to the board, nor to a button that
@@ -1292,6 +1308,9 @@ addEventListener(
   true,
 )
 
+// What came after the version the player last played, shown when the demo ends (endDemo). Test
+// URLs neither show it nor change what is remembered.
+let news = testing ? [] : newSinceLastVisit()
 if (!testing || params.has('demo')) startDemo()
 
 // LittleJS's development build writes its version and frame rate top left; the top bar used to
diff --git a/src/style.css b/src/style.css
index cabd1ba..9a9f8d5 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1608,3 +1608,20 @@ canvas.clip[hidden] {
   white-space: pre;
   pointer-events: none;
 }
+
+/* The changes dialog (ui/whatsnew.ts): a heading per release, each change tagged with its kind and date. */
+#dialog-body .release-head {
+  margin: 14px 0 2px;
+  font-size: 16px;
+}
+
+#dialog-body .release-head small {
+  color: var(--muted);
+  font-size: 11px;
+  font-weight: normal;
+}
+
+#dialog-body .almanac-entry p.change-kind {
+  font-size: 11px;
+  color: var(--muted);
+}
diff --git a/src/ui/whatsnew.ts b/src/ui/whatsnew.ts
new file mode 100644
index 0000000..5822546
--- /dev/null
+++ b/src/ui/whatsnew.ts
@@ -0,0 +1,85 @@
+import { newsFor, RELEASES, VERSION, type Change, type Release } from '../changelog'
+import { spriteUrl } from '../render/sprites'
+import type { SceneKind } from '../sim/demo'
+import { startClip } from './clip'
+import { openDialog } from './dialog'
+
+/**
+ * The changes dialog (user request, 2026-10-31). Opened on its own when the game opens on a
+ * newer version than the player last played, listing what came after it; or from the version in
+ * the menu, listing every release. The last version played is kept in this browser.
+ */
+const LAST_KEY = 'explodorama.lastVersion'
+
+function lastPlayed(): string | null {
+  try {
+    return localStorage.getItem(LAST_KEY)
+  } catch {
+    return null
+  }
+}
+
+function markPlayed(): void {
+  try {
+    localStorage.setItem(LAST_KEY, VERSION)
+  } catch {
+    // No storage (a private window): the player sees the changes again next time.
+  }
+}
+
+/** What to tell the player about when the game opens (newsFor, changelog.ts); records the version where that says to. */
+export function newSinceLastVisit(): Release[] {
+  const { news, record } = newsFor(lastPlayed())
+  if (record) markPlayed()
+  return news
+}
+
+const KIND_LABEL: Record<Change['kind'], string> = { added: 'Added', changed: 'Changed', removed: 'Removed' }
+
+function change(c: Change): string {
+  const inner =
+    `<img src="${spriteUrl(c.icon)}" alt="" draggable="false"><div><strong>${c.title}</strong>` +
+    `<p class="change-kind ${c.kind}">${KIND_LABEL[c.kind]}, ${c.date}</p>${c.text.map((t) => `<p>${t}</p>`).join('')}</div>`
+  return c.clip
+    ? `<button type="button" class="almanac-entry has-clip" data-change-clip="${c.clip}" data-sprite="${c.icon}" data-name="${c.title}">${inner}</button>`
+    : `<div class="almanac-entry">${inner}</div>`
+}
+
+function release(r: Release): string {
+  return (
+    `<h3 class="release-head">${r.version} <small>${r.date}</small></h3><p>${r.summary}</p>` +
+    r.changes.map(change).join('')
+  )
+}
+
+/** The dialog: `releases` newest first; with `intro` it is the "what's new" greeting. */
+export function openChanges(releases: Release[] = RELEASES, intro = false): void {
+  if (intro) markPlayed()
+  openDialog({
+    title: intro ? `What's new in ${VERSION}` : 'Changes',
+    body: (intro ? '<p class="almanac-intro">Since you last played:</p>' : '') + releases.map(release).join(''),
+    buttons: [{ label: intro ? 'Got it' : 'Close', kind: 'plain' }],
+  })
+  shown = { releases, intro }
+}
+
+let shown = { releases: RELEASES, intro: false }
+
+/** A change's own dialog, its clip above what it does; Back returns to the list (from its top). */
+function openChange(button: HTMLElement): void {
+  const { changeClip, sprite, name } = button.dataset
+  const text = [...button.querySelectorAll('p')].map((p) => p.outerHTML).join('')
+  const back = shown
+  openDialog({
+    icon: spriteUrl(sprite!),
+    title: name!,
+    body: '<canvas class="clip" aria-hidden="true"></canvas>' + text,
+    buttons: [{ label: 'Back', kind: 'plain', onClick: () => openChanges(back.releases, back.intro) }],
+  })
+  startClip(document.querySelector<HTMLCanvasElement>('#dialog-body canvas.clip')!, changeClip as SceneKind)
+}
+
+document.getElementById('dialog-body')!.addEventListener('click', (e) => {
+  const entry = (e.target as Element).closest<HTMLElement>('.almanac-entry[data-change-clip]')
+  if (entry) openChange(entry)
+})
diff --git a/test/changelog.test.ts b/test/changelog.test.ts
new file mode 100644
index 0000000..4fa82d1
--- /dev/null
+++ b/test/changelog.test.ts
@@ -0,0 +1,53 @@
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+import { compareVersions, newsFor, parseVersion, RELEASES, releasesSince, UNRELEASED, VERSION, type Release } from '../src/changelog'
+
+const release = (version: string): Release => ({ version, date: '2026-01-01', summary: '', changes: [] })
+
+describe('changelog', () => {
+  it('the newest release is the version in package.json', () => {
+    expect(VERSION).toBe(JSON.parse(readFileSync('package.json', 'utf8')).version)
+  })
+
+  it('releases are valid semver, newest first, with no repeats', () => {
+    for (const r of RELEASES) expect(parseVersion(r.version), r.version).not.toBeNull()
+    for (let i = 1; i < RELEASES.length; i++) {
+      expect(compareVersions(RELEASES[i - 1]!.version, RELEASES[i]!.version)).toBeGreaterThan(0)
+    }
+  })
+
+  it('every change has a date, a title and text', () => {
+    for (const c of [...UNRELEASED, ...RELEASES.flatMap((r) => r.changes)]) {
+      expect(c.date).toMatch(/^\d{4}-\d{2}-\d{2}$/)
+      expect(c.title).not.toBe('')
+      expect(c.text.length).toBeGreaterThan(0)
+    }
+  })
+
+  it('compares numerically, not as text', () => {
+    expect(compareVersions('0.10.0', '0.9.0')).toBeGreaterThan(0)
+    expect(compareVersions('1.0.0', '0.99.99')).toBeGreaterThan(0)
+    expect(compareVersions('0.1.0', '0.1.0')).toBe(0)
+  })
+
+  it('lists the releases after the last one played', () => {
+    const all = [release('0.3.0'), release('0.2.0'), release('0.1.0')]
+    expect(releasesSince('0.1.0', all).map((r) => r.version)).toEqual(['0.3.0', '0.2.0'])
+    expect(releasesSince('0.3.0', all)).toEqual([])
+    expect(releasesSince('0.4.0', all)).toEqual([])
+  })
+
+  it('decides what a returning player sees', () => {
+    const all = [release('0.2.0'), release('0.1.0')]
+    expect(newsFor(null, all)).toEqual({ news: [], record: true })
+    expect(newsFor('0.1.0', all)).toEqual({ news: [all[0]], record: false })
+    expect(newsFor('0.2.0', all)).toEqual({ news: [], record: false })
+    expect(newsFor('0.3.0', all)).toEqual({ news: [], record: false })
+    // A stored value that isn't a version counts as older than any: everything is shown.
+    expect(newsFor('junk', all).news).toEqual(all)
+  })
+
+  it('the README states the current version', () => {
+    expect(readFileSync('README.md', 'utf8')).toContain(`Version: **${VERSION}**`)
+  })
+})
diff --git a/test/version.test.ts b/test/version.test.ts
index 01d1a0a..59cbca2 100644
--- a/test/version.test.ts
+++ b/test/version.test.ts
@@ -3,9 +3,9 @@ import { describe, expect, it } from 'vitest'
 import { SIM_VERSION } from '../src/sim/record'
 
 describe('version', () => {
-  // The game's version is SIM_VERSION (shown in the menu); the README states it by hand.
+  // The rules version is SIM_VERSION (shown in the menu); the README states it by hand.
   it('the README states the current SIM_VERSION', () => {
-    const stated = readFileSync('README.md', 'utf8').match(/^Version: \*\*(\d+)\*\*/m)?.[1]
+    const stated = readFileSync('README.md', 'utf8').match(/^Rules version: \*\*(\d+)\*\*/m)?.[1]
     expect(Number(stated)).toBe(SIM_VERSION)
   })
 })