Skip to content

Cache battlefield artwork across visits instead of re-fetching 41 MB - #159

Merged
Knorcedger merged 5 commits into
mainfrom
claude/game-artwork-caching-tyri11
Aug 23, 2026
Merged

Cache battlefield artwork across visits instead of re-fetching 41 MB#159
Knorcedger merged 5 commits into
mainfrom
claude/game-artwork-caching-tyri11

Conversation

@Knorcedger

Copy link
Copy Markdown
Owner

What changed?

Every page refresh sat through the full "Loading battlefield artwork…" stage again: the deployment serves the ~41 MB HD atlas set (36 sheets + JSON, through 4 bounded lanes) with cache-control: public, max-age=0, must-revalidate, so a warm visit paid ~85 conditional round-trips and a cold or evicted HTTP cache re-downloaded everything. Nothing implemented the loading screen's "usually quickest after the first visit" promise.

Now:

  • tools/hd-art/build.ts stamps assetHashes into manifest.json (16 hex chars of sha256 per emitted file, tools/hd-art/manifestHash.ts); the committed manifest is stamped for the shipped files, and a test recomputes every stamp so a stale one cannot be committed.
  • On web origins the renderer keeps HD atlas responses in a Cache Storage cache (packages/game/src/artworkStore.ts) keyed by <file>?v=<hash>. The manifest is always revalidated (cache: 'no-cache'), so a warm boot costs one conditional manifest request and zero HD transfers; an art drop re-fetches only files whose hash changed; unreferenced entries are pruned after a proven-complete load.
  • Downloaded bytes are verified against their stamped hash before being stored, so a half-updated host can never poison the cache (mismatched or unverifiable bytes are served that session but never stored).
  • Failure paths hardened per review: only a definitive 404/410 counts as "no HD art" (and then clears the whole store rather than stranding ~41 MB); a 5xx/429 behaves like a network failure and boots from the cached set; every Cache Storage await races the caller's abort signal so a wedged cache degrades to the network instead of hanging boot past its deadlines. When manifest revalidation fails but a stored copy exists, the match boots from cache.
  • localhost origins (dev server, Capacitor shells reading from the app bundle) and environments without usable Cache Storage keep exactly the previous network path; baseline pixel atlases still load over plain HTTP.

Related issue

Closes #149

Why this approach?

  • App-level Cache Storage keyed by content hash works on any static host and needs no hosting configuration; fixing this with Cache-Control alone lives in the Vercel dashboard (outside the repo) and either keeps per-file revalidation or risks staleness on un-hashed names.
  • A service worker could cache transparently but adds lifecycle/update complexity, risks caching app code stale, and still needs the same manifest-driven invalidation for correctness; deliberately out of scope.
  • Consistency: each cached file is pinned by a hash from the freshly revalidated manifest, and network bytes are verified before storing, so the cache can never mix files from two art drops — preserving the First-time visitors cannot start a match: 41 MB of HD atlases race one shared 20s deadline #132/Fix cold-cache boot failure: give each HD atlas its own deadline #135 completeness contract for cached boots.
  • No determinism impact: everything lives in the renderer's loading path (packages/game); the sim is untouched.
  • No migration: an old client ignores assetHashes; a manifest without hashes simply keeps the plain fetch path.

Verification

  • npm run typecheck — clean

  • npm run test — 127 files / 1424 tests passed (11 skipped, pre-existing)

  • npm run build, npm run bundle:check, npm run bundle:self-test — green

  • New unit coverage: store hit/miss/verify/prune/clear, wedged-cache degradation, no-WebCrypto behavior, manifest revalidation (write-through, offline fallback, 404-clears-set, 503-keeps-set), assetHashes parsing, and a committed-manifest hash-truthfulness test over all 72 shipped files

  • Confirmed the live deployment serves ?v=<hash> URLs correctly (200, full body, correct content type)

  • Not device-tested in this environment; behavior is unchanged on localhost/native by design

  • npm run typecheck

  • npm test

  • npm run build

  • I added or updated tests where behavior changed

  • I included screenshots or a recording for visible changes — no visual change; loading gets faster on repeat visits

Provenance and AI assistance

Implemented and reviewed with Claude Code under maintainer direction (coordination in #149). No art assets were created or modified; the only asset change is stamping sha256 content hashes of the existing committed files into manifest.json.

Contributor checklist

  • I searched issues and active pull requests before implementation and coordinated ownership in the linked issue
  • This pull request closes or references exactly one primary coordination issue
  • I read CONTRIBUTING.md and followed the deterministic simulation rules
  • I reviewed and understand every submitted change
  • I have the right to submit this code and any included assets
  • My commits are signed off under the Developer Certificate of Origin (git commit -s)
  • This PR contains no credentials, private player data, or unlicensed third-party material

🤖 Generated with Claude Code

https://claude.ai/code/session_01QhdcsdfhBzJW4VBKWKoRsu


Generated by Claude Code

Knorcedger and others added 4 commits August 23, 2026 09:09
Every boot fetched the full HD atlas set — 36 sheets plus their JSON,
~41 MB through four bounded lanes — and the hosting serves those files
with `max-age=0, must-revalidate`, so even a warm refresh paid ~85
conditional round-trips, and a cold or evicted HTTP cache paid the whole
transfer again. The loading screen promised "usually quickest after the
first visit" with nothing behind the promise.

The HD build now stamps assetHashes into manifest.json — 16 hex chars of
sha256 per emitted file (tools/hd-art/manifestHash.ts) — and the
committed manifest is stamped for the shipped files; a test recomputes
every stamp so a stale one cannot be committed. On web origins the
renderer keeps HD atlas responses in a Cache Storage cache keyed by
<file>?v=<hash>. The manifest itself is always revalidated
(cache: 'no-cache'), so a warm boot costs one conditional manifest
request and zero HD transfers; an art drop re-fetches only the files
whose hash changed, and entries the manifest no longer names are pruned
after a proven-complete load.

Downloaded bytes are verified against their stamped hash before being
stored, so a half-updated host can never seed the cache with bytes that
disagree with the manifest — the no-mixed-sets completeness contract
holds by construction. When revalidation fails but a manifest copy is
stored, the match boots from the cached set instead of the recovery
screen.

localhost origins (the dev server, and the Capacitor shells reading from
the app bundle) and environments whose Cache Storage is missing or
refuses to open keep exactly the previous network path, as do the
baseline pixel atlases, which have no manifest to pin their versions to
and revalidate cheaply in parallel.

Closes #149

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhdcsdfhBzJW4VBKWKoRsu
Signed-off-by: Achilleas Tsoumitas <knorcedger@gmail.com>
Review of #149 caught fetchVersioned contradicting its own contract: when
WebCrypto is unavailable (an insecure LAN playtest origin whose browser
still exposes Cache Storage), contentHashOf returns null and the null was
accepted as "verified", pinning unverifiable bytes under the new hash key.
A half-updated host could then seed the cache with stale art that every
later boot serves until the next art drop rotates the key.

Bytes now go into the cache only when a computed digest matches the
manifest stamp. Unverifiable bytes are still returned — the render
proceeds exactly as it would without a store — but never stored, so such
origins simply keep today's fetch-every-boot behavior. No warning is
logged for the null-digest case: a missing WebCrypto is an environment
property, not a host misdeploy.

Refs #149

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhdcsdfhBzJW4VBKWKoRsu
Signed-off-by: Achilleas Tsoumitas <knorcedger@gmail.com>
…aching-tyri11

Signed-off-by: Achilleas Tsoumitas <knorcedger@gmail.com>
Three review findings on the #149 cache, all in its failure paths.

A transient 503 or 429 on the manifest was read as "this deployment ships
no HD art": it skipped the cached-set boot that an equivalent network
failure would have used, and erased the stored manifest — the offline
anchor — so later offline boots failed too. Only a definitive 404/410
now takes that branch; every other failed status raises and falls into
the same cached-manifest fallback as a connection failure.

That definitive branch also deleted only the manifest entry, while
prune() runs solely after the completeness gate passes — unreachable
once the manifest is gone — so ~41 MB of versioned atlas entries would
have sat stranded on the device until quota eviction. The path now
clears the whole store, which only ever holds HD files and their
manifest.

Cache Storage awaits also ignored the abort signal. The loaders'
deadlines are cooperative, so one wedged cache promise — a real Chromium
storage failure mode that rejects nothing and never settles — would have
hung the loading screen forever, past every timeout that boundedAssetLoad
promises. Every cache await now loses a race against the caller's signal,
and through the existing attempt() guard a wedged cache degrades to the
plain network path instead.

Also corrects an overclaim in ASSET_CONTRACT.md: hash verification
protects the cache from poisoning; a half-updated host can still put
mismatched bytes into that one session's render, exactly as it could
before the store existed.

Refs #149

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhdcsdfhBzJW4VBKWKoRsu
Signed-off-by: Achilleas Tsoumitas <knorcedger@gmail.com>
@vercel

vercel Bot commented Aug 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
bannerfall Ready Ready Preview Aug 23, 2026 10:08am

Request Review

…aching-tyri11

Signed-off-by: Achilleas Tsoumitas <knorcedger@gmail.com>
@Knorcedger
Knorcedger merged commit 226541f into main Aug 23, 2026
3 checks passed
@Knorcedger
Knorcedger deleted the claude/game-artwork-caching-tyri11 branch August 23, 2026 10:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Artwork re-downloads on every visit: HD atlases ship with max-age=0 and the client never caches them

1 participant