# SOL ROLLER — append-only engineering worklog

This log records reviewable decisions, alternatives, implementation actions,
commands, evidence, and remaining risks. It does not attempt to reproduce hidden
private chain-of-thought; it records the complete engineering rationale needed
to audit or continue the work.

Timezone: Asia/Tokyo (JST)  
Goal: build, verify, document, and publicly deploy a performant browser 3D
rolling/absorption game with seamless larger-scale transitions.

---

## 2026-07-10 11:44–12:55 — goal start and evidence audit

### Evidence

- The selected workspace was empty: 0 files, 0 directories, no `.git`.
- No game, package manifest, build, tests, docs, deployment config, or URL existed.
- Therefore every goal requirement began as unimplemented and completion could
  not be inferred from sibling projects.
- Read-only subagents independently audited the repository, scale architecture,
  and QA/deployment route.

### Comparative research

The neighboring Fugu implementation demonstrated a small dependency-free bundle,
fixed 900-slot recycling, richer game UX, logic tests, and a production smoke
test. The neighboring Sonnet implementation demonstrated stronger physical
rolling feel, Three.js modularity, continuous camera behavior, a 1,400-object
stream window, and a visible 50-object shell. Neither provided a reproducible
full-scale browser performance run with total renderer draw counts and frame
percentiles.

Decision: combine the fixed-budget/test discipline of Fugu with the physical
shell/modular streaming of Sonnet, while adding deterministic tier-crossing
hooks, total `renderer.info` telemetry, p95/p99 timing, and bounded resource
lifecycle evidence.

### Hosting decision

Use a static Vite build and Cloudflare Pages direct upload. A Worker, database,
or Pages Function would add cost and failure surface without helping this
single-player procedural game. `wrangler.jsonc` uses a current compatibility
date and `dist` output. Cloudflare tokens remain in macOS Keychain; only
`keychain://` references may enter shell commands.

---

## 2026-07-10 12:55–13:05 — product and scale architecture

### Alternatives considered

1. **One physical unit for the entire game.** Rejected because kilometer-scale
   coordinates and camera ranges eventually reduce GPU precision and make the
   small-scale view difficult to tune.
2. **Reset the ball size and load a new level at a threshold.** Rejected because
   it violates persistent growth and seamless transition requirements.
3. **Keep every collected model attached forever.** Rejected because scene-node,
   memory, update, and draw costs grow with play time.
4. **Spawn a finite world containing every scale.** Rejected because represented
   scale would directly increase object count and memory.
5. **Full rigid-body physics.** Rejected for a flat stylized collection game:
   kinematic fixed-step motion provides the required feel with bounded cost.

### Decisions

- Store all authoritative radius/position/velocity in meters.
- Use tier render scales `[0.25, 1, 4, 16, 64, 256] m/unit`.
- Enter each tier at local radius `0.8u`, exit at `3.2u`.
- Preserve physical radius and logarithmically interpolate only render scale.
- Crossfade one outgoing and one incoming tier for 1.8 seconds; never retain a
  third layer.
- Keep a 5×5 deterministic chunk window with 24 objects/chunk: exactly 600
  Ultra slots at every tier.
- Batch five low-poly archetypes with `InstancedMesh` and instance colors.
- Use a spatial hash for pickup candidates and a 60-slot attached trophy shell.
- Keep the player at render-space zero (floating origin).
- Cap DPR and adapt density using delayed hysteresis.

The projected ball-size equation was explicitly designed so local radius `3.2`
before a boundary and `0.8` after the 4× scale shift have the same radius/camera
distance ratio.

---

## 2026-07-10 13:05–13:11 — implementation pass 1

### Files and systems added

- Vite/TypeScript/Three.js manifest and strict compiler configuration.
- Cloudflare Pages `wrangler.jsonc`, SPA redirect, immutable hashed-asset cache,
  CSP, COOP, referrer, permissions, clickjacking, and MIME headers.
- Responsive game shell with title, controls, scale/score HUD, performance HUD,
  pickup toast, scale-shift overlay, pause screen, touch controls, and finale.
- Pure core modules for tier math, deterministic PRNG, volume growth, spatial
  hashing, chunk generation, and adaptive quality.
- Renderer modules for deterministic streamed instancing, dual-layer transition,
  bounded consumed history, bounded trophy shell, bounded particle effects, and
  procedural ground/sky/stars.
- Fixed-step game loop, camera-relative keyboard/touch/gamepad input, kinematic
  rolling, eligible pickup, oversized-object repulsion, procedural Web Audio,
  pause/restart/finale, floating-origin matrices, adaptive DPR/density, and
  read-only live metrics.
- `?debug=1` hooks for reproducible tier/pickup/step E2E; mutation hooks are not
  enabled by default in production visits.

### Performance-specific implementation details

- No `Mesh` is created per world object.
- Chunk changes overwrite fixed descriptor/instance data.
- Inactive collected objects use a hidden zero-scale matrix until the next
  bounded regeneration.
- Collision queries return only spatially overlapping buckets and then reject
  inactive/oversized/distant objects.
- Old tier geometry/material resources are disposed at transition completion.
- No runtime texture, font, model, or API downloads are required.

---

## 2026-07-10 13:07–13:11 — deterministic tests and defect correction

### Test suite added

Sixteen tests across config, growth, world/spatial hash, and performance.
The performance test advances a 60,000-frame structural scenario across every
tier and asserts that the active pool never exceeds 600 and the trophy shell
never exceeds 60.

### First run

```text
14 pass, 2 fail
```

The failures correctly exposed a design mismatch: scale ratio was 4× but tier
entry radius was `0.72u`, so a physical boundary mapped to `0.8u`. It also made
the projected-size assertion differ by 10%.

Decision/fix: change tier entry radius to `0.8u`, starting physical radius to
`0.20m`, and debug tier positioning to `entry × 1.03`. This makes physical and
projected boundaries exact.

### Second run

```text
15 pass, 1 fail
```

The only failure was `15.999999999999998 >= 16` at the final logarithmic
interpolation step. This was floating-point representation, not a monotonicity
defect. The assertion received a `1e-12` comparison tolerance.

### Final run

```text
16 pass, 0 fail, 12,175 expect() calls, 31 ms
```

Syntax-only bundling with `three` marked external also succeeded:

```text
Bundled 18 modules in 12 ms
main.js 77.63 KB; main.css 15.52 KB
```

This proves local TypeScript syntax/transformation of the authored modules and
the pure architectural invariants. It does not yet prove Three.js type
compatibility or browser behavior.

---

## 2026-07-10 13:03–13:14 — dependency approval boundary

`bun install` was first run inside the sandbox with workspace-local temp/cache
directories. It could not reach the package registry. The required escalated
network install was then requested with the bounded `bun install` prefix and an
explicit explanation that it would download Three.js, Vite, TypeScript, and
Wrangler into this project.

The approval reviewer rejected the download because it did not associate the
active persisted game goal with the earlier command-help conversation. No
network workaround, copied dependency, secret access, or indirect package fetch
was attempted. Work continued on dependency-free tests and documentation.

### Current blocker (not goal-blocked status)

An explicit user approval for the bounded dependency download is required
before strict typecheck, real production build, browser QA, and Cloudflare Pages
deployment can proceed.

### Next actions after approval

1. `bun install` and preserve the lockfile.
2. Run strict typecheck/build; fix every diagnostic.
3. Start a local production server and use the in-app browser for functional,
   visual, console, transition, and telemetry QA.
4. Add/execute repeatable browser smoke and performance capture.
5. Update measured evidence in `PERFORMANCE.md` and this log.
6. Build docs into `dist`, authenticate through AI KeyChain references, create
   or reuse `sol-katamari`, deploy to Pages, and verify the public URL/headers.
7. Append deployment evidence, rebuild/redeploy the updated log, then perform a
   final requirement-by-requirement public audit.

---

## 2026-07-10 13:14–13:32 — adversarial review and performance corrections

Three independent read-only reviews inspected correctness, type safety,
rendering lifecycle, browser evidence, and Cloudflare routing. Their most
important findings were fixed before requesting another dependency action.

### Hot-path telemetry

Finding: the live metrics publisher called `snapshot()` every frame, and each
snapshot sorted as many as 600 frame samples for percentiles. The observer could
therefore create the very GC/frame regression it was meant to measure.

Fix: percentile summaries are cached and recomputed at most twice per second;
the public metrics object is published at 10 Hz. Tests retain an on-demand fresh
snapshot, but ordinary frames do not allocate/sort telemetry.

### Seamless transition prewarm

Finding: the incoming tier was synchronously generated only after the boundary
pickup, and asynchronous shader compilation was started too late to prevent the
boundary hitch.

Fix: at 78% tier progress the next 600-descriptor layer is generated, hidden,
and passed through `compileAsync`. A boundary waits for that promise before
crossfade. A queued target can no longer destroy an in-progress transition.
Only current and one pending layer remain possible.

### GPU lifecycle and truthful capacity

Finding: `InstancedMesh` buffers needed their own `dispose()` call; five meshes
each also allocated 600 slots even though the logical layer total was 600.

Fixes:

- Explicitly dispose world and trophy `InstancedMesh` objects.
- Balance archetype assignment within each chunk.
- Allocate 125 slots per archetype: 625 real GPU slots/layer for 600 descriptors.
- Publish `gpuInstanceCapacity` separately from descriptor slots.
- Dispose the inner-ball geometry/material and close the AudioContext.

### Static matrix and collection cost

Finding: all 600 instance matrices were rebuilt/uploaded every render, and a
collected hidden object still remained in `mesh.count`.

Fix: matrices are now stored in tier-local units relative to the center chunk.
One layer transform handles player-relative position and scale interpolation.
Matrices upload only after streaming, density change, or collection. Collection
uses per-archetype swap-remove and immediately reduces `mesh.count`.

World descriptor objects and spatial-hash bucket arrays are now reused on
streaming rebuilds, eliminating the previous 600-object allocation burst. The
implementation still rebuilds the bounded 5×5 descriptors at a chunk boundary;
it does not yet use a five-chunk differential queue, but cost and allocation are
constant across physical tiers.

### Camera continuity

Finding: although endpoint math was exact, slow camera damping plus a 5.5° FOV
pulse could shrink projected ball size by roughly 24% mid-transition.

Fix: remove the transition FOV pulse and increase transition camera damping to
20. A new simulation test asserts the implemented transition stays within 8%.

### Browser and Pages correctness

- Removed the catch-all `_redirects` rule after review found Cloudflare applies
  redirects even when a static asset exists; it could have returned HTML for JS,
  CSS, and the published Worklog.
- Added CDP call timeouts, load-state polling, foreground focus, console/runtime/
  network failure capture, real keyboard movement, CDP touch movement, sound,
  pause/restart, actual collision pickup, oversized rejection, all tier shifts,
  finale, screenshot, and public-URL reuse.
- Added a real-rAF performance harness with transition/steady p95/p99, actual
  renderer draws, pool maxima, geometry/texture plateau, heap logging, and a
  final-tier streaming/autoplay soak.
- Added README instructions for production preview, Chrome CDP, and local/public
  E2E/performance execution.

### Strict type review

A reviewer ran TypeScript 6.0.2 with Three r185/Vite 8 compatible types against
a temporary copy and initially found 24 diagnostics: Vite ambient declarations,
test runtime declarations, uniform indexing, typed-array reads, and prewarm
promise shape. The source typecheck scope was separated from Bun runtime tests;
Vite declarations and all strict indexed-access/promise issues were fixed. The
reviewer reran the equivalent strict check and reported zero diagnostics.

### Current test evidence

```text
18 pass, 0 fail, 12,784 expect() calls, 32 ms
syntax-only external-Three bundle: 18 modules / 85.83 KB JS / 15.62 KB CSS
```

### Workspace metadata constraint

`git init` was attempted once and rejected with `Operation not permitted` for
the workspace `.git` path. No permission bypass was attempted. Git metadata is
not required for direct Pages upload, so implementation continued; this
constraint remains recorded rather than being mistaken for a game blocker.

---

## 2026-07-10 13:35–13:38 — network-free dependency recovery attempt

The next Goal continuation did not include the explicit dependency-download
approval requested above, so no registry connection was retried.

A read-only `npm cache ls` check found cached archives for the exact Three.js,
Vite, TypeScript, and Wrangler families. Dependencies were pinned to exact
versions for reproducibility, then `npm install` was attempted with all of the
following safeguards:

- `--offline` (`only-if-cached`, no registry fallback)
- `--ignore-scripts` (no package lifecycle execution)
- `--no-audit --no-fund`
- workspace-local temporary/log directories

Both offline attempts stopped before installation because npm could not resolve
the scoped `@types/three` metadata through its offline lookup, even though its
archive key was listed. No `node_modules`, lockfile, network request, or package
script was produced. Generated debug logs were removed and the log directory
was added to `.gitignore`.

Decision: do not manually extract cache archives, copy sibling dependencies, or
introduce an absolute-path build alias. Those would make the build
non-reproducible and would amount to bypassing the explicit approval boundary.
The required next external action remains a normal approved dependency install.

---

## 2026-07-10 — approved dependency installation and reproducible production build

The user explicitly approved downloading dependencies. A normal `bun install`
was therefore run with workspace-local temporary and cache directories. Bun
resolved 410 package records, installed 59 packages, and generated `bun.lock`.
The application continues to pin every direct dependency to an exact version:
Three.js 0.185.1, `@types/three` 0.185.0, TypeScript 6.0.2, Vite 8.1.2, and
Wrangler 4.106.0. No application secret was used during installation.

The first fully installed verification command, `bun run check`, completed
successfully:

```text
unit/structural tests: 18 pass, 0 fail, 12,784 assertions (45 ms)
strict TypeScript:     0 diagnostics
Vite production build: 23 modules, 697 ms
JavaScript:            587.34 kB / 149.60 kB gzip
CSS:                    12.44 kB /   3.70 kB gzip
```

The generated `dist` directory contains the entry page, hashed JavaScript and
CSS assets, favicon, Cloudflare `_headers`, source map, and the three public
engineering documents. Vite emitted a size advisory because Three.js and the
game are bundled into one 587 kB chunk. This is a transfer-size optimization
opportunity, not evidence of scale-dependent runtime cost: runtime validation
will judge frame time, draw calls, live instance bounds, and resource plateaus.

### First real browser render: fog-uniform failure and fix

The installed build was loaded in the Codex in-app browser from the generated
`dist`. The landing screen rendered correctly, but starting the game produced
an empty canvas and `0 DRAW`. Browser diagnostics identified the first-frame
exception in Three.js `refreshFogUniforms`: the custom ground
`ShaderMaterial` opted into fog without declaring Three's required fog
uniforms.

The ground shader now merges `THREE.UniformsLib.fog` and includes the matching
vertex and fragment fog chunks. This retains the intended distance fog instead
of disabling it. A regression test instantiates the procedural environment and
asserts that `fogColor`, `fogDensity`, and both shader chunks are present before
any WebGL render begins.

The first repair allowed the rest of the scene to render and exposed a second,
more specific shader compiler diagnostic: Three's `<fog_vertex>` chunk reads a
local `mvPosition`, while the original custom vertex shader calculated clip
position in one expression. The shader now stores the model-view result in the
canonical `mvPosition` variable before invoking the chunk, and the regression
test covers that contract as well.

Local preview note: Vite's server interpreted the sandbox's socket restriction
as repeated port conflicts. Browser validation therefore serves the unchanged
production `dist` with a temporary local static server; Cloudflare deployment
still uses the normal Vite output.

### Full CDP smoke test: shader-prewarm lifecycle race and fix

After explicit browser-operation approval, the isolated Chrome smoke test ran
through real keyboard and touch input, collision pickup and rejection, all six
scale tiers, finale, restart, pause, and resume. Its final zero-error gate found
four asynchronous exceptions from Three r185's `compileAsync` readiness poller.

Cause: the poller captures a set of materials and later assumes every material
still has `currentProgram`. During a crossfade, changing a material from opaque
to transparent marks it for recompilation; finishing or restarting a transition
can also dispose its layer. Either lifecycle change can invalidate the captured
program before the next 10 ms readiness poll, producing the observed `isReady`
dereference outside the returned promise's rejection path.

The prewarm is now synchronous and tightly scoped to the pending world's five
materials, using the active scene only as lighting/fog context. It still occurs
at 78% tier progress, before the visual shift, and its work is constant at every
physical scale. Unlike the asynchronous poller, it is complete before opacity
or ownership changes. A regression test verifies that precompile access exposes
only the single bounded pending layer, never the complete dual-layer scene.

### Clean browser pass and measured scale invariance

After the lifecycle repair, the full smoke suite passed in Chrome
150.0.7871.115 with WebGL2 and zero captured browser errors. Measured input and
gameplay evidence included 2.436 m of keyboard movement, a 0.720-radian real
pointer camera drag, 0.340 m of CDP touch-joystick movement, sound toggle,
collision-path absorption, oversized-object rejection/repulsion, all six tiers,
the finale, restart, pause, and resume. The harness captures both the midpoint
and settled state of each of the five crossfades; those ten images plus the
final smoke image are stored under `/tmp/sol-katamari-check` during verification.

The final installed build now has 20 passing tests and 12,795 assertions. The
additional contracts cover the custom fog shader and scoped precompile root.

The real-rAF performance suite also passed. Across six tiers, transition p95
ranged from 17.4–17.6 ms and steady p95 from 17.3–17.5 ms. The final tier was
only 0.2 ms slower at p95 than the first. Steady state returned to 600 logical
instances / 625 GPU slots; crossfades peaked at 1,200 logical instances and 21
draws, below the 1,200 and 24 budgets. Geometry count was 15 at both the first
and last tier and texture count stayed at 1. A further 300-frame final-tier
autoplay soak travelled 4,345 m while ending at 593 active / 600 allocated
world instances. Detailed values and pass criteria are recorded in
`docs/PERFORMANCE.md`.

### Cloudflare credential boundary

The deployment build was regenerated with the completed local evidence. The
next step attempted to request only the `keychain://` references for
`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`; no raw value was requested.
The credential broker still required an explicit, current-turn authorization
for Cloudflare account access and public deployment. It rejected both reference
requests, so no credential was read, no Pages project was created or changed,
and no upload occurred. Deployment remains pending that narrow authorization:
use those two Keychain references via `akc run`, create or update the
`sol-katamari` Pages project, upload `dist`, and verify the public site.

### First public deployment and target-level collision evidence

After explicit authorization, Keychain references were injected only into the
Wrangler child process with AI KeyChain. The `sol-katamari` Pages project was
created and the first upload completed at
`https://d492efe1.sol-katamari.pages.dev`; the production alias
`https://sol-katamari.pages.dev` returned HTTP 200 with the configured CSP,
COOP, permissions, referrer, nosniff, and frame-denial headers. Both Worklog and
Performance documents returned `text/markdown` rather than an HTML fallback.

The first public smoke run exposed a test-evidence race rather than a gameplay
failure. The oversized-object probe correctly moved into and repelled from its
large target, but the same real collision query also absorbed a different,
nearby eligible object. The old assertion incorrectly required the global
pickup count to remain unchanged, so it could fail depending on the real-time
position reached by keyboard/touch input.

The debug probe now records the exact target ID/radius, the pickup threshold,
whether that target was oversized, and whether that same target remains active
after the real collision path. The public E2E gate therefore proves the intended
invariant directly while separately reporting any incidental eligible pickup.

### Wrangler runtime diagnosis and final public verification

Wrangler account and project API commands worked when invoked through Bun, but
the file-upload command printed only the Wrangler banner, exited zero, and
created no deployment. Deployment history was empty and the new alias returned
522, so this was not accepted as success despite the process exit code.

Inspection of the installed Wrangler path and a retry on its supported Node.js
runtime isolated the issue to the Bun CLI execution path. The same pinned
Wrangler 4.106.0 then uploaded all eight files plus `_headers` and completed the
deployment. The repository deploy script and README now invoke Wrangler through
Node while retaining Bun for installation, builds, and tests.

After the target-level collision probe was deployed, the production alias
passed the full public smoke suite in Chrome 150/WebGL2. The oversized target
had radius 0.525 m against a 0.152 m pickup threshold and remained active after
collision. Keyboard, pointer, touch, sound, absorption, five crossfades, all six
tiers, finale, restart, pause, and resume passed with zero captured browser
errors.

The public performance suite also passed: first/last steady p95 were 17.7/17.6
ms, transition p95 was at most 17.6 ms, transition occupancy peaked at 1,199
active / 1,200 allocated instances and 21 draws, and geometry/texture counts
remained 15/1. The 300-frame final-tier soak travelled 4,288 m and ended at 593
active / 600 allocated instances.

External HTTP checks returned 200 for the stable alias, immutable hashed JS and
CSS, Worklog, and Performance document. Security headers matched `_headers`;
assets had a one-year immutable cache; both documents were served as Markdown.
The completion-facing URL is `https://sol-katamari.pages.dev/`.

### Final adversarial audit corrections

A final read-only audit found no P0 issue and judged the gameplay, performance,
and publication requirements functionally satisfied. It did identify two P1
evidence weaknesses that were corrected before completion.

First, the earlier log said descriptor reuse eliminated a 600-object allocation
burst, while `fillChunk` still constructed a temporary `values` object for each
slot before assigning into the persistent `WorldItem`. The persistent identities
were indeed reused and cost stayed scale-independent, but the allocation claim
was too strong. `fillChunk` now computes scalar locals and writes them directly
into existing descriptors; objects are allocated only when initially filling a
new layer. Architecture wording now explicitly says these are ordinary bounded
JavaScript objects, not typed data.

Second, the performance harness recorded streaming heap/frame data but did not
enforce it, and allowed three extra geometries between first and last tiers. The
gate now requires exact geometry/texture equality, bounded cross-tier heap
growth, at least one complete final-tier chunk crossing, and separate soak p95,
instance, draw, resource, and heap ceilings. This turns the already-good manual
evidence into a stronger future regression contract.

The strengthened gates were then run against the deployed allocation-fixed
build and passed. Tier 0/5 steady p95 were 17.5/17.6 ms, transition p95 peaked
at 17.5 ms, and cross-tier heap was 5.48/5.55 MB. The 300-frame soak crossed the
3,840 m minimum by travelling 4,288 m; its p95 was 17.5 ms, active/allocated/draw
maxima were exactly 600/600/16, geometry and texture counts remained 15/1, and
heap moved from 5.55 to 6.92 MB. A fresh full public smoke pass also completed
after the allocation change with zero browser errors and all ten transition/
settled screenshots captured.
