Skip to content

Commit dd3a867

Browse files
feat: offer installed apps a reload when a new build is live
iOS keeps the Home Screen app alive in the background and players rarely force close it, so they kept playing an old build long after a deploy. There was no way for a running copy to learn that a newer one was live. Each production build now carries an id: BUILD_ID, else GITHUB_SHA (CI and the Alchemy deploy), else the local commit with a timestamp when the tree is dirty, else a timestamp. A small Vite plugin defines it as __BUILD_ID__ and writes dist/version.json. The dev server uses "dev" and never checks; the tests use "test". The game fetches version.json with no-store and a time query when the page becomes visible again, on a back forward cache restore, on window focus, and every five minutes on the title, pause, end screens, and the break between waves. update only records the newer build as an Option, and failures are ignored. A "New version ready" chip shows on those quiet screens, never mid-wave, and tapping it reloads. public/_headers serves version.json no-store and the HTML no-cache. Story and Scene tests cover the checks, the chip on each quiet screen, the reload, failures, and the subscription filters. tools/audit/shots26.ts shoots the chip layout and runs the end to end rebuild and reload check.
1 parent 0415242 commit dd3a867

20 files changed

Lines changed: 962 additions & 11 deletions

‎AGENTS.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ Before you finish a change, run `typecheck`, `lint`, `test`, and `build`. All fo
7272
| `src/effectsView.ts` | Bullets, decals, particles, blasts, popups, and rattling doors |
7373
| `src/hud.ts` | Hearts, money, the streak, gun and trap slots, and the round text |
7474
| `src/wavePreview.ts` | The door markers that preview a wave, and their spoken text |
75-
| `src/touchControls.ts` | Sticks, ghost sticks, icon buttons, gun slots, and the thumb corner |
75+
| `src/touchControls.ts` | Sticks, ghost sticks, icon buttons, gun slots, and the thumb corner |
7676
| `src/menus.ts` | Mode buttons, the new zombie card, corner icons, tips, rotate notice |
7777
| `src/pauseHelp.ts` | The pause screen's panel of controls and tips for each device |
7878
| `src/powerupView.ts` | Pickups, callouts, the boost aura, and HUD timers on canvas |
@@ -84,6 +84,8 @@ Before you finish a change, run `typecheck`, `lint`, `test`, and `build`. All fo
8484
| `src/sprite.ts` | Pixel maps turned into canvas shapes |
8585
| `src/icons.ts` | Pixel maps turned into SVG icons for the HTML buttons |
8686
| `src/prng.ts` | The seeded random number generator |
87+
| `src/version.ts` | The running build id, the `version.json` check, and reloading |
88+
| `src/updateChip.ts` | The "New version ready" chip shown on quiet screens |
8789
| `src/constants.ts` | The view size, speeds, and other tuning numbers |
8890

8991
### Deploying
@@ -107,6 +109,16 @@ Credentials: locally, the Cloudflare API token is in the macOS keychain under `c
107109

108110
GitHub Pages is gone: its workflow is deleted and the site is disabled. Cloudflare Workers Builds is not connected either; the Deploy workflow is the only deploy path, so do not connect the repo in the Cloudflare dashboard.
109111

112+
#### How updates reach installed apps
113+
114+
iOS keeps a Home Screen app alive in the background, so players can keep an old build for days after a deploy. There is no service worker, on purpose. Instead:
115+
116+
- **Build id.** A small plugin in `vite.config.ts` stamps every production build with an id: `BUILD_ID` if set, else `GITHUB_SHA` (CI and the Alchemy deploy in CI), else the local commit (with `-dirty-<timestamp>` when the tree has uncommitted changes, since a local deploy ships them), else a timestamp. The id is the compile-time constant `__BUILD_ID__` and is also written to `dist/version.json` as `{ "build": "<id>" }`. `bun dev` uses `dev` and never checks; the tests use `test`.
117+
- **Never cached.** `public/_headers` serves `/version.json` with `no-store` and `/` and `/index.html` with `no-cache`, and the game fetches `version.json` with `cache: 'no-store'` and a time query.
118+
- **When it checks.** When the page becomes visible again, on a back forward cache restore, on window focus, and every five minutes on the title, pause, Game Over, Victory, and the break between waves. Never on a timer mid-wave. A failed or offline check is ignored.
119+
- **The prompt.** If the live build differs from the running one, a "New version ready" chip shows on those same quiet screens, never during a wave. Tapping it reloads the page. Its place lives in `.update-chip` in `src/styles.css`, as arena pixels (`--chip-arena-x`, `--chip-arena-y`), so it is easy to move.
120+
- **Check it.** `PART=e2e bun shots26.ts` in `tools/audit` builds, serves, rebuilds under an open page, and checks that the chip appears and the reload lands on the new build.
121+
110122
### Rules
111123

112124
- **Keep the game deterministic.** `update` is pure. It never calls `Math.random` and never reads the clock. Randomness comes from the seeded generator whose state lives in the Model, so the same seed and the same Messages give the same game.

‎PLATFORM.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ All on 2026-10-28.
88

99
- **Everything moves to Cloudflare.** GitHub Pages was only for testing on other devices. It was removed (the site and `.github/workflows/pages.yml`) on 2026-10-28, once the Cloudflare deploy was live.
1010
- **The game is hosted at https://munch-manor.tanner-scadden.workers.dev**, the free `workers.dev` address, with no custom domain yet. The account's `workers.dev` subdomain is `tanner-scadden`.
11+
- **Installed apps find new builds with `version.json`, not a service worker.** Each build stamps its commit into the bundle and into `version.json`, which is never cached. The game fetches it when it comes back into view, on focus, and every five minutes on quiet screens, and offers a tap to reload when the build differs. See "Deploying" in `AGENTS.md`.
1112
- **GitHub Actions is the only deploy path.** Cloudflare Workers Builds (the dashboard's git integration) stays disconnected, so one push never fires two deploys.
1213
- **Free plan first.** Upgrade to Workers Paid only if the game takes off.
1314
- **Our own mini sync engine over scopes**, for personal and shared state. See "Sync engine".
@@ -59,7 +60,7 @@ Package names and APIs need checking against the Effect version the game uses (E
5960
- **Token permissions.** CI deploys with an account-owned token, `munch-manor-ci`, minted by `infra/github.ts`: Workers Scripts Write, Secrets Store Write, Account Settings Read, Workers Tail Read. Secrets Store Write is needed even to read, because Alchemy reads the state store's token by binding the secret to a short-lived preview Worker. The keychain token used by hand is much broader (it can mint tokens) and never goes to CI. Cloudflare's Workers Builds API rejected that account-owned token, so whether Workers Builds is connected can only be seen in the dashboard or in a Worker's deployment history (a `wrangler` source).
6061
- **The account had no `workers.dev` subdomain.** Alchemy's state store needs one, so `tanner-scadden` was claimed through the API before the first deploy.
6162
- **Two deploy paths collide quietly.** While Workers Builds was still connected, one push ran `wrangler deploy` and overwrote the Worker with a single page fallback. Alchemy's next deploy saw no changed inputs, and `alchemy drift` saw no drift, so both reported success. `alchemy deploy --force` put the declared version back.
62-
- **Routing and caching.** The game has no routes, so unknown paths answer 404 (`notFoundHandling: 'none'`). The default single page fallback served `index.html` at nested paths, where its relative asset paths missed. `public/_headers` makes the hashed files in `/assets` immutable for a year, and `index.html` revalidates on every load.
63+
- **Routing and caching.** The game has no routes, so unknown paths answer 404 (`notFoundHandling: 'none'`). The default single page fallback served `index.html` at nested paths, where its relative asset paths missed. `public/_headers` makes the hashed files in `/assets` immutable for a year, `index.html` and `/` are `no-cache` so every load revalidates, and `version.json` is `no-store`.
6364

6465
## Sync engine
6566

‎infra/README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ bun alchemy deploy github.ts --stage prod
4646

4747
If the repository is renamed, change `REPOSITORY` and deploy twice, the second time with `--force`. The rename makes Alchemy replace each secret: it writes the new one, then deletes the old one, and GitHub redirects the old name to the same repository, so the delete removes the secret just written. The forced deploy writes it again. Check with `gh secret list`.
4848

49+
## Updates on installed apps
50+
51+
The Vite build stamps the bundle and `version.json` with `GITHUB_SHA`, which the Alchemy build child inherits in CI. A local deploy stamps the local commit, plus a timestamp when the tree is dirty. Alchemy skips the build when no input changed, so `version.json` only changes when the game does. See "How updates reach installed apps" in `AGENTS.md`.
52+
4953
## Check a deploy
5054

5155
```sh

‎public/_headers‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,12 @@
33

44
/manifest.webmanifest
55
Content-Type: application/manifest+json
6+
7+
/
8+
Cache-Control: no-cache
9+
10+
/index.html
11+
Cache-Control: no-cache
12+
13+
/version.json
14+
Cache-Control: no-store

‎src/main.ts‎

Lines changed: 124 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,15 @@ import {
118118
refusePlacement,
119119
trapCenter,
120120
} from './traps'
121+
import {
122+
RUNNING_BUILD,
123+
UPDATE_CHECK_INTERVAL,
124+
fetchRemoteBuild,
125+
isCheckingForUpdates,
126+
isUpdateWindow,
127+
maybeNewerBuild,
128+
reloadPage,
129+
} from './version'
121130
import {
122131
FINAL_WAVE,
123132
isRoundActive,
@@ -210,6 +219,14 @@ export const Message = defineMessageUnion({
210219
FailedPlaySound: { sound: SoundEffect, reason: Schema.String },
211220
CompletedCueMusic: { mood: MusicMood },
212221
FailedCueMusic: { mood: MusicMood, reason: Schema.String },
222+
FocusedWindow: {},
223+
RestoredPageFromCache: {},
224+
TickedUpdateCheck: {},
225+
CompletedFetchBuild: { build: Schema.String },
226+
FailedFetchBuild: { reason: Schema.String },
227+
ClickedUpdateButton: {},
228+
CompletedReloadPage: {},
229+
FailedReloadPage: { reason: Schema.String },
213230
})
214231
export type Message = typeof Message.Type
215232

@@ -416,6 +433,28 @@ export const FocusShopItem = Command.define('FocusShopItem', {
416433
),
417434
})
418435

436+
export const FetchBuild = Command.define('FetchBuild', {
437+
messages: [Message.CompletedFetchBuild, Message.FailedFetchBuild],
438+
execute: pipe(
439+
fetchRemoteBuild,
440+
Effect.map(build => Message.CompletedFetchBuild({ build })),
441+
Effect.catch(error =>
442+
Effect.succeed(Message.FailedFetchBuild({ reason: error.reason })),
443+
),
444+
),
445+
})
446+
447+
export const ReloadPage = Command.define('ReloadPage', {
448+
messages: [Message.CompletedReloadPage, Message.FailedReloadPage],
449+
execute: pipe(
450+
reloadPage,
451+
Effect.as(Message.CompletedReloadPage()),
452+
Effect.catch(error =>
453+
Effect.succeed(Message.FailedReloadPage({ reason: error.reason })),
454+
),
455+
),
456+
})
457+
419458
// INIT
420459

421460
export const init: Runtime.ApplicationInit<Model, Message> = () => ({
@@ -1060,6 +1099,13 @@ const isFreshRun = (model: Model): boolean =>
10601099
Phase.isAnyOf(['Preparation'])(model.phase) &&
10611100
model.elapsedMs < START_SOUND_GRACE_MS
10621101

1102+
// VERSION
1103+
1104+
const checkForUpdate = (model: Model): UpdateReturn =>
1105+
isCheckingForUpdates(RUNNING_BUILD)
1106+
? { model, commands: [FetchBuild()] }
1107+
: { model }
1108+
10631109
const updateGame = (model: Model, message: Message) =>
10641110
Message.match<UpdateReturn>(message, {
10651111
TickedFrame: ({ deltaTime }) => {
@@ -1146,7 +1192,7 @@ const updateGame = (model: Model, message: Message) =>
11461192
if (visibility === 'Hidden') {
11471193
return pauseWhenAway(noted)
11481194
} else {
1149-
return { model: noted }
1195+
return checkForUpdate(noted)
11501196
}
11511197
},
11521198

@@ -1396,6 +1442,26 @@ const updateGame = (model: Model, message: Message) =>
13961442
CompletedCueMusic: () => ({ model }),
13971443

13981444
FailedCueMusic: () => ({ model }),
1445+
1446+
FocusedWindow: () => checkForUpdate(model),
1447+
1448+
RestoredPageFromCache: () => checkForUpdate(model),
1449+
1450+
TickedUpdateCheck: () => checkForUpdate(model),
1451+
1452+
CompletedFetchBuild: ({ build }) => ({
1453+
model: modifyFields(model, {
1454+
maybeNewerBuild: () => maybeNewerBuild(RUNNING_BUILD, build),
1455+
}),
1456+
}),
1457+
1458+
FailedFetchBuild: () => ({ model }),
1459+
1460+
ClickedUpdateButton: () => ({ model, commands: [ReloadPage()] }),
1461+
1462+
CompletedReloadPage: () => ({ model }),
1463+
1464+
FailedReloadPage: () => ({ model }),
13991465
})
14001466

14011467
const dismissHomeScreenTip = (model: Model): Model =>
@@ -1501,6 +1567,32 @@ export const maybeWindowBlurMessage = ({
15011567
? Option.some(Message.BlurredWindow())
15021568
: Option.none()
15031569

1570+
export const maybeWindowFocusMessage = ({
1571+
target,
1572+
}: Readonly<{ target: EventTarget | null }>): Option.Option<Message> =>
1573+
target === window ? Option.some(Message.FocusedWindow()) : Option.none()
1574+
1575+
export const maybePageShowMessage = ({
1576+
persisted,
1577+
}: Readonly<{ persisted: boolean }>): Option.Option<Message> =>
1578+
persisted ? Option.some(Message.RestoredPageFromCache()) : Option.none()
1579+
1580+
export const pageVisibilityMessage = (
1581+
visibilityState: DocumentVisibilityState,
1582+
): Message =>
1583+
Message.ChangedPageVisibility({
1584+
visibility: visibilityState === 'hidden' ? 'Hidden' : 'Visible',
1585+
})
1586+
1587+
export const updateCheckActivity = (model: Model): Activity =>
1588+
isCheckingForUpdates(RUNNING_BUILD) && isUpdateWindow(model.phase)
1589+
? 'Active'
1590+
: 'Idle'
1591+
1592+
const updateCheckMessages = Stream.map(Stream.tick(UPDATE_CHECK_INTERVAL), () =>
1593+
Message.TickedUpdateCheck(),
1594+
)
1595+
15041596
const keyboardMessages = Stream.merge(
15051597
Dom.streamFromEventFilterMap({
15061598
target: document,
@@ -1602,14 +1694,40 @@ export const subscriptions = Subscription.make<Model, Message>()(entry => ({
16021694
Dom.streamFromEvent({
16031695
target: document,
16041696
type: 'visibilitychange',
1605-
mapEvent: () =>
1606-
Message.ChangedPageVisibility({
1607-
visibility:
1608-
document.visibilityState === 'hidden' ? 'Hidden' : 'Visible',
1609-
}),
1697+
mapEvent: () => pageVisibilityMessage(document.visibilityState),
1698+
}),
1699+
),
1700+
1701+
windowFocus: Subscription.persistentEntry(
1702+
Dom.streamFromEventFilterMap({
1703+
target: window,
1704+
type: 'focus',
1705+
filterMapEvent: maybeWindowFocusMessage,
16101706
}),
16111707
),
16121708

1709+
pageRestores: Subscription.persistentEntry(
1710+
Dom.streamFromEventFilterMap({
1711+
target: window,
1712+
type: 'pageshow',
1713+
filterMapEvent: maybePageShowMessage,
1714+
}),
1715+
),
1716+
1717+
updateCheck: entry(
1718+
{ activity: Activity },
1719+
{
1720+
modelToDependencies: model => ({
1721+
activity: updateCheckActivity(model),
1722+
}),
1723+
dependenciesToStream: ({ activity }) =>
1724+
Stream.when(
1725+
updateCheckMessages,
1726+
Effect.sync(() => activity === 'Active'),
1727+
),
1728+
},
1729+
),
1730+
16131731
mouseAim: entry(
16141732
{ activity: Activity },
16151733
{

‎src/model.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -898,6 +898,7 @@ export const Model = Schema.Struct({
898898
homeScreenTip: HomeScreenTip,
899899
homeScreenSteps: Dialog.Model,
900900
pageVisibility: PageVisibility,
901+
maybeNewerBuild: Schema.Option(Schema.String),
901902
appearance: Appearance,
902903
character: CharacterPicker,
903904
})
@@ -1080,6 +1081,7 @@ export const initialModel: Model = {
10801081
homeScreenTip: 'Unseen',
10811082
homeScreenSteps: Dialog.init({ id: HOME_SCREEN_STEPS_ID }),
10821083
pageVisibility: 'Visible',
1084+
maybeNewerBuild: Option.none(),
10831085
appearance: defaultAppearance,
10841086
character: closedCharacterPicker,
10851087
}

‎src/music.story.test.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ import { describe, expect, test } from 'vitest'
66
import { ARENA_LEVEL } from './arena'
77
import type { SoundEffect } from './audio'
88
import { ZOMBIE_MAX_HP } from './constants'
9-
import { CueMusic, Message, PlaySound, ResumeAudio, update } from './main'
9+
import {
10+
CueMusic,
11+
FetchBuild,
12+
Message,
13+
PlaySound,
14+
ResumeAudio,
15+
update,
16+
} from './main'
1017
import {
1118
AudioState,
1219
type Model,
@@ -342,8 +349,13 @@ describe('music only plays when it can be heard', () => {
342349
message(Message.ChangedPageVisibility({ visibility: 'Visible' })),
343350
Command.expectExact(
344351
CueMusic({ mood: 'Creep', level: 'Full', stinger: 'None' }),
352+
FetchBuild,
345353
),
346354
resolveCue('Creep'),
355+
Command.resolve(
356+
FetchBuild,
357+
Message.FailedFetchBuild({ reason: 'offline' }),
358+
),
347359
)
348360
})
349361

‎src/styles.css‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1796,3 +1796,57 @@ body {
17961796
animation: none;
17971797
}
17981798
}
1799+
1800+
.update-chip-frame {
1801+
position: fixed;
1802+
top: env(safe-area-inset-top, 0px);
1803+
right: env(safe-area-inset-right, 0px);
1804+
bottom: env(safe-area-inset-bottom, 0px);
1805+
left: env(safe-area-inset-left, 0px);
1806+
container-type: size;
1807+
pointer-events: none;
1808+
z-index: 20;
1809+
}
1810+
1811+
.update-chip {
1812+
--arena-px: min(100cqw / 320, 100cqh / 180);
1813+
--chip-arena-x: 4;
1814+
--chip-arena-y: 28;
1815+
position: absolute;
1816+
left: calc(
1817+
(100cqw - 320 * var(--arena-px)) / 2 + var(--chip-arena-x) * var(--arena-px)
1818+
);
1819+
top: calc(
1820+
(100cqh - 180 * var(--arena-px)) / 2 + var(--chip-arena-y) * var(--arena-px)
1821+
);
1822+
display: flex;
1823+
flex-direction: column;
1824+
align-items: flex-start;
1825+
min-height: 40px;
1826+
padding: 4px 12px;
1827+
border: 2px solid rgba(155, 227, 106, 0.7);
1828+
border-radius: 12px;
1829+
background: rgba(8, 10, 12, 0.85);
1830+
color: #f4f1de;
1831+
font: bold 11px/1.25 monospace;
1832+
text-align: left;
1833+
white-space: nowrap;
1834+
cursor: pointer;
1835+
pointer-events: auto;
1836+
touch-action: manipulation;
1837+
}
1838+
1839+
.update-chip-action {
1840+
color: #9be36a;
1841+
text-decoration: underline;
1842+
text-underline-offset: 2px;
1843+
}
1844+
1845+
.update-chip:hover {
1846+
border-color: #9be36a;
1847+
}
1848+
1849+
.update-chip:focus-visible {
1850+
outline: 2px solid #ffe066;
1851+
outline-offset: 2px;
1852+
}

0 commit comments

Comments
 (0)