@thunderclap-engine/engine 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (566) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/LICENSE +21 -0
  3. package/README.md +333 -2
  4. package/dist/analytics/analytics.d.ts +80 -0
  5. package/dist/analytics/analytics.js +129 -0
  6. package/dist/analytics/index.d.ts +1 -0
  7. package/dist/analytics/index.js +1 -0
  8. package/dist/anim/aim.d.ts +43 -0
  9. package/dist/anim/aim.js +157 -0
  10. package/dist/anim/clip.d.ts +98 -0
  11. package/dist/anim/clip.js +282 -0
  12. package/dist/anim/compress.d.ts +65 -0
  13. package/dist/anim/compress.js +339 -0
  14. package/dist/anim/foot-ik.d.ts +74 -0
  15. package/dist/anim/foot-ik.js +241 -0
  16. package/dist/anim/graph.d.ts +153 -0
  17. package/dist/anim/graph.js +532 -0
  18. package/dist/anim/ik.d.ts +105 -0
  19. package/dist/anim/ik.js +435 -0
  20. package/dist/anim/index.d.ts +36 -0
  21. package/dist/anim/index.js +21 -0
  22. package/dist/anim/match.d.ts +66 -0
  23. package/dist/anim/match.js +153 -0
  24. package/dist/anim/mixer.d.ts +113 -0
  25. package/dist/anim/mixer.js +314 -0
  26. package/dist/anim/morph.d.ts +41 -0
  27. package/dist/anim/morph.js +94 -0
  28. package/dist/anim/pose.d.ts +74 -0
  29. package/dist/anim/pose.js +254 -0
  30. package/dist/anim/quat.d.ts +44 -0
  31. package/dist/anim/quat.js +187 -0
  32. package/dist/anim/retarget.d.ts +97 -0
  33. package/dist/anim/retarget.js +366 -0
  34. package/dist/anim/root-motion.d.ts +44 -0
  35. package/dist/anim/root-motion.js +224 -0
  36. package/dist/anim/skin-pose.d.ts +39 -0
  37. package/dist/anim/skin-pose.js +53 -0
  38. package/dist/anim/socket.d.ts +47 -0
  39. package/dist/anim/socket.js +84 -0
  40. package/dist/anim/spring-chain.d.ts +73 -0
  41. package/dist/anim/spring-chain.js +294 -0
  42. package/dist/assets/atlas.d.ts +90 -0
  43. package/dist/assets/atlas.js +212 -0
  44. package/dist/assets/baked.d.ts +33 -0
  45. package/dist/assets/baked.js +38 -0
  46. package/dist/assets/bundle.d.ts +88 -0
  47. package/dist/assets/bundle.js +464 -0
  48. package/dist/assets/cache.d.ts +56 -0
  49. package/dist/assets/cache.js +123 -0
  50. package/dist/assets/gltf-load.d.ts +252 -0
  51. package/dist/assets/gltf-load.js +661 -0
  52. package/dist/assets/gltf-skin.d.ts +36 -0
  53. package/dist/assets/gltf-skin.js +109 -0
  54. package/dist/assets/gltf.d.ts +706 -0
  55. package/dist/assets/gltf.js +1376 -0
  56. package/dist/assets/index.d.ts +20 -0
  57. package/dist/assets/index.js +10 -0
  58. package/dist/assets/ktx2.d.ts +71 -0
  59. package/dist/assets/ktx2.js +213 -0
  60. package/dist/assets/manifest.d.ts +79 -0
  61. package/dist/assets/manifest.js +7 -0
  62. package/dist/assets/meshopt.d.ts +23 -0
  63. package/dist/assets/meshopt.js +403 -0
  64. package/dist/audio/audio.d.ts +733 -0
  65. package/dist/audio/audio.js +2013 -0
  66. package/dist/audio/dsp.d.ts +61 -0
  67. package/dist/audio/dsp.js +159 -0
  68. package/dist/audio/duck.d.ts +44 -0
  69. package/dist/audio/duck.js +101 -0
  70. package/dist/audio/envelope.d.ts +53 -0
  71. package/dist/audio/envelope.js +106 -0
  72. package/dist/audio/index.d.ts +13 -0
  73. package/dist/audio/index.js +14 -0
  74. package/dist/audio/music.d.ts +113 -0
  75. package/dist/audio/music.js +548 -0
  76. package/dist/audio/occlusion.d.ts +55 -0
  77. package/dist/audio/occlusion.js +61 -0
  78. package/dist/audio/persist.d.ts +23 -0
  79. package/dist/audio/persist.js +27 -0
  80. package/dist/audio/recipes.d.ts +55 -0
  81. package/dist/audio/recipes.js +227 -0
  82. package/dist/audio/send.d.ts +41 -0
  83. package/dist/audio/send.js +118 -0
  84. package/dist/audio/sprites.d.ts +59 -0
  85. package/dist/audio/sprites.js +43 -0
  86. package/dist/audio/synth.d.ts +103 -0
  87. package/dist/audio/synth.js +197 -0
  88. package/dist/audio/variations.d.ts +25 -0
  89. package/dist/audio/variations.js +55 -0
  90. package/dist/audio/zones.d.ts +100 -0
  91. package/dist/audio/zones.js +167 -0
  92. package/dist/core/detached-surface.d.ts +44 -0
  93. package/dist/core/detached-surface.js +104 -0
  94. package/dist/core/events.d.ts +24 -0
  95. package/dist/core/events.js +43 -0
  96. package/dist/core/game.d.ts +409 -0
  97. package/dist/core/game.js +530 -0
  98. package/dist/core/hash.d.ts +24 -0
  99. package/dist/core/hash.js +49 -0
  100. package/dist/core/index.d.ts +17 -0
  101. package/dist/core/index.js +9 -0
  102. package/dist/core/loop.d.ts +113 -0
  103. package/dist/core/loop.js +185 -0
  104. package/dist/core/math.d.ts +58 -0
  105. package/dist/core/math.js +210 -0
  106. package/dist/core/noise.d.ts +40 -0
  107. package/dist/core/noise.js +247 -0
  108. package/dist/core/pool.d.ts +42 -0
  109. package/dist/core/pool.js +85 -0
  110. package/dist/core/random.d.ts +61 -0
  111. package/dist/core/random.js +199 -0
  112. package/dist/core/scene.d.ts +79 -0
  113. package/dist/core/scene.js +195 -0
  114. package/dist/core/timer.d.ts +66 -0
  115. package/dist/core/timer.js +286 -0
  116. package/dist/fx/billboard-wgsl.d.ts +29 -0
  117. package/dist/fx/billboard-wgsl.js +158 -0
  118. package/dist/fx/billboard.d.ts +249 -0
  119. package/dist/fx/billboard.js +702 -0
  120. package/dist/fx/debug-draw.d.ts +127 -0
  121. package/dist/fx/debug-draw.js +523 -0
  122. package/dist/fx/debug-wgsl.d.ts +7 -0
  123. package/dist/fx/debug-wgsl.js +32 -0
  124. package/dist/fx/decals-wgsl.d.ts +15 -0
  125. package/dist/fx/decals-wgsl.js +173 -0
  126. package/dist/fx/decals.d.ts +216 -0
  127. package/dist/fx/decals.js +607 -0
  128. package/dist/fx/effect.d.ts +129 -0
  129. package/dist/fx/effect.js +349 -0
  130. package/dist/fx/effects/hit-spark.json +48 -0
  131. package/dist/fx/effects/index.d.ts +8 -0
  132. package/dist/fx/effects/index.js +9 -0
  133. package/dist/fx/effects/magic-aura.json +47 -0
  134. package/dist/fx/effects/smoke-puff.json +34 -0
  135. package/dist/fx/emit-surface.d.ts +40 -0
  136. package/dist/fx/emit-surface.js +124 -0
  137. package/dist/fx/flicker.d.ts +30 -0
  138. package/dist/fx/flicker.js +42 -0
  139. package/dist/fx/index.d.ts +31 -0
  140. package/dist/fx/index.js +20 -0
  141. package/dist/fx/particles-wgsl.d.ts +57 -0
  142. package/dist/fx/particles-wgsl.js +828 -0
  143. package/dist/fx/particles.d.ts +566 -0
  144. package/dist/fx/particles.js +1533 -0
  145. package/dist/fx/random.d.ts +15 -0
  146. package/dist/fx/random.js +20 -0
  147. package/dist/fx/sky-wgsl.d.ts +23 -0
  148. package/dist/fx/sky-wgsl.js +127 -0
  149. package/dist/fx/sky.d.ts +96 -0
  150. package/dist/fx/sky.js +175 -0
  151. package/dist/fx/text-atlas.d.ts +143 -0
  152. package/dist/fx/text-atlas.js +327 -0
  153. package/dist/fx/trail-wgsl.d.ts +11 -0
  154. package/dist/fx/trail-wgsl.js +42 -0
  155. package/dist/fx/trail.d.ts +194 -0
  156. package/dist/fx/trail.js +555 -0
  157. package/dist/fx/water-wgsl.d.ts +34 -0
  158. package/dist/fx/water-wgsl.js +197 -0
  159. package/dist/fx/water.d.ts +176 -0
  160. package/dist/fx/water.js +371 -0
  161. package/dist/geom/collide/bvh.d.ts +151 -0
  162. package/dist/geom/collide/bvh.js +493 -0
  163. package/dist/geom/collide/debug.d.ts +14 -0
  164. package/dist/geom/collide/debug.js +74 -0
  165. package/dist/geom/collide/grid.d.ts +26 -0
  166. package/dist/geom/collide/grid.js +91 -0
  167. package/dist/geom/collide/index.d.ts +8 -0
  168. package/dist/geom/collide/index.js +8 -0
  169. package/dist/geom/collide/queries.d.ts +70 -0
  170. package/dist/geom/collide/queries.js +583 -0
  171. package/dist/geom/collide/serialize.d.ts +14 -0
  172. package/dist/geom/collide/serialize.js +145 -0
  173. package/dist/geom/collide/shapes.d.ts +161 -0
  174. package/dist/geom/collide/shapes.js +686 -0
  175. package/dist/geom/collide/slide.d.ts +66 -0
  176. package/dist/geom/collide/slide.js +375 -0
  177. package/dist/geom/collide/tile-slide.d.ts +88 -0
  178. package/dist/geom/collide/tile-slide.js +255 -0
  179. package/dist/geom/geometry.d.ts +95 -0
  180. package/dist/geom/geometry.js +322 -0
  181. package/dist/geom/heightfield.d.ts +72 -0
  182. package/dist/geom/heightfield.js +295 -0
  183. package/dist/geom/hull.d.ts +27 -0
  184. package/dist/geom/hull.js +279 -0
  185. package/dist/geom/index.d.ts +35 -0
  186. package/dist/geom/index.js +23 -0
  187. package/dist/geom/iso.d.ts +74 -0
  188. package/dist/geom/iso.js +89 -0
  189. package/dist/geom/lod.d.ts +29 -0
  190. package/dist/geom/lod.js +27 -0
  191. package/dist/geom/ops.d.ts +47 -0
  192. package/dist/geom/ops.js +285 -0
  193. package/dist/geom/primitives.d.ts +97 -0
  194. package/dist/geom/primitives.js +359 -0
  195. package/dist/geom/shapes.d.ts +43 -0
  196. package/dist/geom/shapes.js +88 -0
  197. package/dist/geom/simplify.d.ts +38 -0
  198. package/dist/geom/simplify.js +519 -0
  199. package/dist/geom/split.d.ts +37 -0
  200. package/dist/geom/split.js +413 -0
  201. package/dist/geom/sweep.d.ts +83 -0
  202. package/dist/geom/sweep.js +534 -0
  203. package/dist/geom/tangents.d.ts +16 -0
  204. package/dist/geom/tangents.js +124 -0
  205. package/dist/geom/triangulate.d.ts +19 -0
  206. package/dist/geom/triangulate.js +147 -0
  207. package/dist/host/default.d.ts +10 -0
  208. package/dist/host/default.js +10 -0
  209. package/dist/host/index.d.ts +4 -0
  210. package/dist/host/index.js +3 -0
  211. package/dist/host/types.d.ts +144 -0
  212. package/dist/host/types.js +1 -0
  213. package/dist/host/web-links.d.ts +38 -0
  214. package/dist/host/web-links.js +110 -0
  215. package/dist/host/web.d.ts +30 -0
  216. package/dist/host/web.js +216 -0
  217. package/dist/host/xr.d.ts +24 -0
  218. package/dist/host/xr.js +59 -0
  219. package/dist/i18n/i18n.d.ts +59 -0
  220. package/dist/i18n/i18n.js +241 -0
  221. package/dist/i18n/index.d.ts +3 -0
  222. package/dist/i18n/index.js +3 -0
  223. package/dist/i18n/message.d.ts +72 -0
  224. package/dist/i18n/message.js +369 -0
  225. package/dist/i18n/negotiate.d.ts +17 -0
  226. package/dist/i18n/negotiate.js +58 -0
  227. package/dist/index.d.ts +7 -0
  228. package/dist/index.js +1 -0
  229. package/dist/input/bindings.d.ts +31 -0
  230. package/dist/input/bindings.js +141 -0
  231. package/dist/input/clock.d.ts +44 -0
  232. package/dist/input/clock.js +49 -0
  233. package/dist/input/follow.d.ts +133 -0
  234. package/dist/input/follow.js +218 -0
  235. package/dist/input/gestures.d.ts +281 -0
  236. package/dist/input/gestures.js +662 -0
  237. package/dist/input/glyphs.d.ts +35 -0
  238. package/dist/input/glyphs.js +216 -0
  239. package/dist/input/gyro.d.ts +85 -0
  240. package/dist/input/gyro.js +140 -0
  241. package/dist/input/index.d.ts +19 -0
  242. package/dist/input/index.js +16 -0
  243. package/dist/input/input.d.ts +543 -0
  244. package/dist/input/input.js +1492 -0
  245. package/dist/input/look.d.ts +327 -0
  246. package/dist/input/look.js +681 -0
  247. package/dist/input/magnify.d.ts +177 -0
  248. package/dist/input/magnify.js +131 -0
  249. package/dist/input/pointer-dispatch.d.ts +138 -0
  250. package/dist/input/pointer-dispatch.js +180 -0
  251. package/dist/input/pointer-router.d.ts +204 -0
  252. package/dist/input/pointer-router.js +533 -0
  253. package/dist/input/pointer-sources.d.ts +164 -0
  254. package/dist/input/pointer-sources.js +306 -0
  255. package/dist/input/rays.d.ts +138 -0
  256. package/dist/input/rays.js +615 -0
  257. package/dist/input/record.d.ts +209 -0
  258. package/dist/input/record.js +601 -0
  259. package/dist/input/touch-zones.d.ts +147 -0
  260. package/dist/input/touch-zones.js +300 -0
  261. package/dist/input/types.d.ts +158 -0
  262. package/dist/input/types.js +1 -0
  263. package/dist/input/xr-source.d.ts +53 -0
  264. package/dist/input/xr-source.js +164 -0
  265. package/dist/nav/astar.d.ts +45 -0
  266. package/dist/nav/astar.js +182 -0
  267. package/dist/nav/corridor.d.ts +40 -0
  268. package/dist/nav/corridor.js +95 -0
  269. package/dist/nav/crowd.d.ts +94 -0
  270. package/dist/nav/crowd.js +624 -0
  271. package/dist/nav/flow.d.ts +61 -0
  272. package/dist/nav/flow.js +193 -0
  273. package/dist/nav/grid.d.ts +108 -0
  274. package/dist/nav/grid.js +210 -0
  275. package/dist/nav/heap.d.ts +22 -0
  276. package/dist/nav/heap.js +92 -0
  277. package/dist/nav/index.d.ts +37 -0
  278. package/dist/nav/index.js +19 -0
  279. package/dist/nav/mesh/areas.d.ts +44 -0
  280. package/dist/nav/mesh/areas.js +98 -0
  281. package/dist/nav/mesh/bake-options.d.ts +33 -0
  282. package/dist/nav/mesh/bake-options.js +76 -0
  283. package/dist/nav/mesh/contour.d.ts +22 -0
  284. package/dist/nav/mesh/contour.js +323 -0
  285. package/dist/nav/mesh/flow3.d.ts +35 -0
  286. package/dist/nav/mesh/flow3.js +229 -0
  287. package/dist/nav/mesh/links.d.ts +38 -0
  288. package/dist/nav/mesh/links.js +72 -0
  289. package/dist/nav/mesh/mesh-path.d.ts +152 -0
  290. package/dist/nav/mesh/mesh-path.js +643 -0
  291. package/dist/nav/mesh/mesh-reach.d.ts +78 -0
  292. package/dist/nav/mesh/mesh-reach.js +450 -0
  293. package/dist/nav/mesh/navmesh.d.ts +117 -0
  294. package/dist/nav/mesh/navmesh.js +647 -0
  295. package/dist/nav/mesh/polymesh.d.ts +24 -0
  296. package/dist/nav/mesh/polymesh.js +384 -0
  297. package/dist/nav/mesh/regions.d.ts +12 -0
  298. package/dist/nav/mesh/regions.js +367 -0
  299. package/dist/nav/mesh/serialize.d.ts +13 -0
  300. package/dist/nav/mesh/serialize.js +197 -0
  301. package/dist/nav/mesh/voxelize.d.ts +111 -0
  302. package/dist/nav/mesh/voxelize.js +552 -0
  303. package/dist/nav/obstacles.d.ts +75 -0
  304. package/dist/nav/obstacles.js +294 -0
  305. package/dist/nav/path.d.ts +59 -0
  306. package/dist/nav/path.js +177 -0
  307. package/dist/nav/query-queue.d.ts +46 -0
  308. package/dist/nav/query-queue.js +308 -0
  309. package/dist/nav/reach.d.ts +53 -0
  310. package/dist/nav/reach.js +148 -0
  311. package/dist/nav/steer.d.ts +36 -0
  312. package/dist/nav/steer.js +78 -0
  313. package/dist/platform/boot.d.ts +28 -0
  314. package/dist/platform/boot.js +43 -0
  315. package/dist/platform/device-mode.d.ts +40 -0
  316. package/dist/platform/device-mode.js +104 -0
  317. package/dist/platform/device.d.ts +31 -0
  318. package/dist/platform/device.js +91 -0
  319. package/dist/platform/environment.d.ts +42 -0
  320. package/dist/platform/environment.js +82 -0
  321. package/dist/platform/gpu.d.ts +40 -0
  322. package/dist/platform/gpu.js +57 -0
  323. package/dist/platform/index.d.ts +18 -0
  324. package/dist/platform/index.js +10 -0
  325. package/dist/platform/letterbox.d.ts +43 -0
  326. package/dist/platform/letterbox.js +57 -0
  327. package/dist/platform/links.d.ts +20 -0
  328. package/dist/platform/links.js +43 -0
  329. package/dist/platform/screens.d.ts +36 -0
  330. package/dist/platform/screens.js +138 -0
  331. package/dist/platform/surface.d.ts +72 -0
  332. package/dist/platform/surface.js +157 -0
  333. package/dist/platform/xr-fake.d.ts +168 -0
  334. package/dist/platform/xr-fake.js +434 -0
  335. package/dist/platform/xr-surface.d.ts +120 -0
  336. package/dist/platform/xr-surface.js +214 -0
  337. package/dist/platform/xr-types.d.ts +262 -0
  338. package/dist/post/ao.d.ts +12 -0
  339. package/dist/post/ao.js +136 -0
  340. package/dist/post/blend.d.ts +27 -0
  341. package/dist/post/blend.js +129 -0
  342. package/dist/post/depth-pass.d.ts +43 -0
  343. package/dist/post/depth-pass.js +118 -0
  344. package/dist/post/dof.d.ts +1 -0
  345. package/dist/post/dof.js +66 -0
  346. package/dist/post/exposure.d.ts +80 -0
  347. package/dist/post/exposure.js +322 -0
  348. package/dist/post/index.d.ts +12 -0
  349. package/dist/post/index.js +11 -0
  350. package/dist/post/lens-dirt.d.ts +13 -0
  351. package/dist/post/lens-dirt.js +91 -0
  352. package/dist/post/lut.d.ts +19 -0
  353. package/dist/post/lut.js +50 -0
  354. package/dist/post/motion-blur.d.ts +9 -0
  355. package/dist/post/motion-blur.js +78 -0
  356. package/dist/post/post-stack.d.ts +302 -0
  357. package/dist/post/post-stack.js +1137 -0
  358. package/dist/post/settings.d.ts +485 -0
  359. package/dist/post/settings.js +416 -0
  360. package/dist/post/shaders.d.ts +25 -0
  361. package/dist/post/shaders.js +410 -0
  362. package/dist/post/ssr.d.ts +16 -0
  363. package/dist/post/ssr.js +155 -0
  364. package/dist/post/taa.d.ts +74 -0
  365. package/dist/post/taa.js +235 -0
  366. package/dist/post/white-balance.d.ts +25 -0
  367. package/dist/post/white-balance.js +98 -0
  368. package/dist/render/bindings.d.ts +98 -0
  369. package/dist/render/bindings.js +281 -0
  370. package/dist/render/blend.d.ts +13 -0
  371. package/dist/render/blend.js +26 -0
  372. package/dist/render/camera-2d.d.ts +72 -0
  373. package/dist/render/camera-2d.js +92 -0
  374. package/dist/render/camera.d.ts +302 -0
  375. package/dist/render/camera.js +671 -0
  376. package/dist/render/color.d.ts +16 -0
  377. package/dist/render/color.js +41 -0
  378. package/dist/render/compressed.d.ts +104 -0
  379. package/dist/render/compressed.js +248 -0
  380. package/dist/render/diagnostics.d.ts +15 -0
  381. package/dist/render/diagnostics.js +43 -0
  382. package/dist/render/dynamic-resolution.d.ts +70 -0
  383. package/dist/render/dynamic-resolution.js +126 -0
  384. package/dist/render/fake-gpu.d.ts +204 -0
  385. package/dist/render/fake-gpu.js +426 -0
  386. package/dist/render/flipbook.d.ts +56 -0
  387. package/dist/render/flipbook.js +87 -0
  388. package/dist/render/frame.d.ts +278 -0
  389. package/dist/render/frame.js +174 -0
  390. package/dist/render/fullscreen.d.ts +54 -0
  391. package/dist/render/fullscreen.js +91 -0
  392. package/dist/render/gpu-timer.d.ts +73 -0
  393. package/dist/render/gpu-timer.js +186 -0
  394. package/dist/render/index.d.ts +53 -0
  395. package/dist/render/index.js +29 -0
  396. package/dist/render/instance-list.d.ts +185 -0
  397. package/dist/render/instance-list.js +547 -0
  398. package/dist/render/lights.d.ts +181 -0
  399. package/dist/render/lights.js +233 -0
  400. package/dist/render/lod.d.ts +54 -0
  401. package/dist/render/lod.js +52 -0
  402. package/dist/render/material.d.ts +165 -0
  403. package/dist/render/material.js +292 -0
  404. package/dist/render/mesh.d.ts +284 -0
  405. package/dist/render/mesh.js +472 -0
  406. package/dist/render/mipmap.d.ts +16 -0
  407. package/dist/render/mipmap.js +121 -0
  408. package/dist/render/parallax.d.ts +50 -0
  409. package/dist/render/parallax.js +47 -0
  410. package/dist/render/pbr-wgsl.d.ts +31 -0
  411. package/dist/render/pbr-wgsl.js +119 -0
  412. package/dist/render/ping-pong.d.ts +42 -0
  413. package/dist/render/ping-pong.js +74 -0
  414. package/dist/render/renderer.d.ts +741 -0
  415. package/dist/render/renderer.js +2486 -0
  416. package/dist/render/scene-pipelines.d.ts +44 -0
  417. package/dist/render/scene-pipelines.js +92 -0
  418. package/dist/render/scene-target.d.ts +170 -0
  419. package/dist/render/scene-target.js +450 -0
  420. package/dist/render/shadow.d.ts +74 -0
  421. package/dist/render/shadow.js +208 -0
  422. package/dist/render/skin.d.ts +112 -0
  423. package/dist/render/skin.js +240 -0
  424. package/dist/render/snapshot.d.ts +36 -0
  425. package/dist/render/snapshot.js +34 -0
  426. package/dist/render/standard.d.ts +162 -0
  427. package/dist/render/standard.js +263 -0
  428. package/dist/render/texture.d.ts +142 -0
  429. package/dist/render/texture.js +228 -0
  430. package/dist/render/tile-layer.d.ts +110 -0
  431. package/dist/render/tile-layer.js +304 -0
  432. package/dist/render/transform.d.ts +115 -0
  433. package/dist/render/transform.js +318 -0
  434. package/dist/render/wgsl.d.ts +131 -0
  435. package/dist/render/wgsl.js +528 -0
  436. package/dist/save/codec.d.ts +49 -0
  437. package/dist/save/codec.js +169 -0
  438. package/dist/save/index.d.ts +8 -0
  439. package/dist/save/index.js +8 -0
  440. package/dist/save/journal.d.ts +24 -0
  441. package/dist/save/journal.js +102 -0
  442. package/dist/save/migrate.d.ts +15 -0
  443. package/dist/save/migrate.js +37 -0
  444. package/dist/save/schema.d.ts +72 -0
  445. package/dist/save/schema.js +154 -0
  446. package/dist/save/settings.d.ts +28 -0
  447. package/dist/save/settings.js +59 -0
  448. package/dist/save/slots.d.ts +56 -0
  449. package/dist/save/slots.js +200 -0
  450. package/dist/save/storage.d.ts +35 -0
  451. package/dist/save/storage.js +62 -0
  452. package/dist/save/store.d.ts +114 -0
  453. package/dist/save/store.js +269 -0
  454. package/dist/testing/fake-host.d.ts +103 -0
  455. package/dist/testing/fake-host.js +163 -0
  456. package/dist/testing/fixed-clock.d.ts +25 -0
  457. package/dist/testing/fixed-clock.js +30 -0
  458. package/dist/testing/index.d.ts +4 -0
  459. package/dist/testing/index.js +5 -0
  460. package/dist/testing/test-game.d.ts +76 -0
  461. package/dist/testing/test-game.js +92 -0
  462. package/dist/tween/ease.d.ts +77 -0
  463. package/dist/tween/ease.js +192 -0
  464. package/dist/tween/index.d.ts +8 -0
  465. package/dist/tween/index.js +7 -0
  466. package/dist/tween/timeline.d.ts +78 -0
  467. package/dist/tween/timeline.js +269 -0
  468. package/dist/tween/tween.d.ts +168 -0
  469. package/dist/tween/tween.js +320 -0
  470. package/dist/ui/anchor.d.ts +206 -0
  471. package/dist/ui/anchor.js +307 -0
  472. package/dist/ui/caption.d.ts +122 -0
  473. package/dist/ui/caption.js +286 -0
  474. package/dist/ui/cursor.d.ts +105 -0
  475. package/dist/ui/cursor.js +261 -0
  476. package/dist/ui/dom.d.ts +30 -0
  477. package/dist/ui/dom.js +92 -0
  478. package/dist/ui/floats.d.ts +86 -0
  479. package/dist/ui/floats.js +260 -0
  480. package/dist/ui/hud.d.ts +198 -0
  481. package/dist/ui/hud.js +431 -0
  482. package/dist/ui/index.d.ts +37 -0
  483. package/dist/ui/index.js +34 -0
  484. package/dist/ui/inventory-card.d.ts +52 -0
  485. package/dist/ui/inventory-card.js +173 -0
  486. package/dist/ui/inventory-context.d.ts +29 -0
  487. package/dist/ui/inventory-context.js +125 -0
  488. package/dist/ui/inventory-drag.d.ts +63 -0
  489. package/dist/ui/inventory-drag.js +394 -0
  490. package/dist/ui/inventory-grid.d.ts +66 -0
  491. package/dist/ui/inventory-grid.js +625 -0
  492. package/dist/ui/inventory-radial.d.ts +39 -0
  493. package/dist/ui/inventory-radial.js +168 -0
  494. package/dist/ui/inventory.d.ts +128 -0
  495. package/dist/ui/inventory.js +154 -0
  496. package/dist/ui/layout.d.ts +356 -0
  497. package/dist/ui/layout.js +856 -0
  498. package/dist/ui/link.d.ts +64 -0
  499. package/dist/ui/link.js +155 -0
  500. package/dist/ui/loading.d.ts +40 -0
  501. package/dist/ui/loading.js +157 -0
  502. package/dist/ui/loupe.d.ts +74 -0
  503. package/dist/ui/loupe.js +164 -0
  504. package/dist/ui/menu-nav.d.ts +33 -0
  505. package/dist/ui/menu-nav.js +28 -0
  506. package/dist/ui/minimap-canvas.d.ts +145 -0
  507. package/dist/ui/minimap-canvas.js +460 -0
  508. package/dist/ui/notify.d.ts +40 -0
  509. package/dist/ui/notify.js +141 -0
  510. package/dist/ui/orb.d.ts +53 -0
  511. package/dist/ui/orb.js +304 -0
  512. package/dist/ui/overlay.d.ts +213 -0
  513. package/dist/ui/overlay.js +394 -0
  514. package/dist/ui/padnav.d.ts +53 -0
  515. package/dist/ui/padnav.js +146 -0
  516. package/dist/ui/perf.d.ts +130 -0
  517. package/dist/ui/perf.js +329 -0
  518. package/dist/ui/rotate-card.d.ts +54 -0
  519. package/dist/ui/rotate-card.js +97 -0
  520. package/dist/ui/stack.d.ts +128 -0
  521. package/dist/ui/stack.js +312 -0
  522. package/dist/ui/styles.d.ts +13 -0
  523. package/dist/ui/styles.js +200 -0
  524. package/dist/ui/text-scale.d.ts +67 -0
  525. package/dist/ui/text-scale.js +102 -0
  526. package/dist/ui/theme.d.ts +70 -0
  527. package/dist/ui/theme.js +98 -0
  528. package/dist/ui/toast.d.ts +23 -0
  529. package/dist/ui/toast.js +46 -0
  530. package/dist/ui/tooltip.d.ts +89 -0
  531. package/dist/ui/tooltip.js +333 -0
  532. package/dist/ui/widgets-2.d.ts +134 -0
  533. package/dist/ui/widgets-2.js +399 -0
  534. package/dist/ui/widgets-3.d.ts +91 -0
  535. package/dist/ui/widgets-3.js +212 -0
  536. package/dist/ui/widgets.d.ts +34 -0
  537. package/dist/ui/widgets.js +50 -0
  538. package/dist/ui/world-panel/index.d.ts +5 -0
  539. package/dist/ui/world-panel/index.js +5 -0
  540. package/dist/ui/world-panel/layout.d.ts +38 -0
  541. package/dist/ui/world-panel/layout.js +68 -0
  542. package/dist/ui/world-panel/paint.d.ts +96 -0
  543. package/dist/ui/world-panel/paint.js +136 -0
  544. package/dist/ui/world-panel/panel.d.ts +235 -0
  545. package/dist/ui/world-panel/panel.js +762 -0
  546. package/dist/ui/world-panel/wgsl.d.ts +10 -0
  547. package/dist/ui/world-panel/wgsl.js +52 -0
  548. package/dist/ui/world-panel/widgets.d.ts +207 -0
  549. package/dist/ui/world-panel/widgets.js +569 -0
  550. package/dist/voxel/chunks.d.ts +77 -0
  551. package/dist/voxel/chunks.js +149 -0
  552. package/dist/voxel/grid.d.ts +84 -0
  553. package/dist/voxel/grid.js +223 -0
  554. package/dist/voxel/index.d.ts +22 -0
  555. package/dist/voxel/index.js +7 -0
  556. package/dist/voxel/mesher-worker.d.ts +14 -0
  557. package/dist/voxel/mesher-worker.js +47 -0
  558. package/dist/voxel/mesher.d.ts +73 -0
  559. package/dist/voxel/mesher.js +306 -0
  560. package/dist/voxel/ray.d.ts +32 -0
  561. package/dist/voxel/ray.js +73 -0
  562. package/dist/voxel/wgsl.d.ts +16 -0
  563. package/dist/voxel/wgsl.js +28 -0
  564. package/dist/voxel/worker-client.d.ts +79 -0
  565. package/dist/voxel/worker-client.js +122 -0
  566. package/package.json +177 -3
package/CHANGELOG.md ADDED
@@ -0,0 +1,390 @@
1
+ # Changelog: @thunderclap-engine/engine
2
+
3
+ Versions follow semver from `engine-v1.0.0`. `pnpm release <version> [--tag] [--dry]` (`scripts/release.mjs`) cuts a release from `main`: it runs `pnpm check` and `pnpm test:e2e`, bumps `package.json`, dates the top `## Unreleased` section, opens a fresh one above it and commits; with `--tag` it also creates the annotated tag `v<version>`. It never pushes. Since `1.0.0` the `api/` snapshots are frozen: a change to one needs a *Breaking* or *Added* entry naming its module under `## Unreleased` (`pnpm check:api`). Entries before this repo existed were written while the engine lived in [The Last Rune](https://github.com/Mandalorian007/last-rune) (`packages/engine`); their decision numbers (`D-0xx`) and paths refer to that repo.
4
+
5
+ ## Unreleased
6
+
7
+ - Release path fails safe and leaves a trail (ws/publish-2). No public API change (`api/` unchanged). *Fixed:* a plain `npm publish` (or `pnpm publish`) in the repo root or a host folder is refused: each package's `prepublishOnly` runs `scripts/refuse-publish.mjs`, which fails with a pointer to [Releasing](docs/releasing.md). npm runs that script only for a folder publish, so publishing a tarball made by `pnpm pack` still works. *Added:* `pnpm check:pack` proves both cases with `npm publish --dry-run` against a dead local registry, and checks `CHANGELOG.md` has a section for the root version. `scripts/pack-smoke.mjs --from-npm [--wait <seconds>]` installs each package at its `package.json` version from the registry instead of from tarballs, after polling until each is listed. `.github/workflows/publish.yml` runs it after publishing (`--wait 600`), then creates the tag's GitHub Release with `gh`, or updates it if it already exists, with that version's CHANGELOG section as the body (`scripts/publish-notes.mjs`, links made absolute at the tag). The workflow now has `contents: write`.
8
+
9
+ ## 1.1.0 (2026-11-07)
10
+
11
+ - Published to npm (ws/publish, E-037). No public API change (`api/` unchanged). *Added:* `pnpm build` (`scripts/build.mjs`) compiles the engine and the three hosts with `tsc` (`tsconfig.build.json` in each) to ESM JavaScript plus `.d.ts` in `dist/`, with explicit `.js` extensions and JSON import attributes, so the output runs in browsers and Node without a bundler; host-electron also builds its shell (`out/main.mjs`, `out/preload.cjs`). The published manifests point at `dist/` through `publishConfig` (`exports` with `types` before `import`, `files`, `sideEffects: false`, public access, provenance); the workspace still exports `./src/*.ts`. `@thunderclap-engine/engine` publishes at `1.x`; `@thunderclap-engine/host-electron`, `@thunderclap-engine/host-capacitor` and `@thunderclap-engine/host-pwa` publish at `0.1.0` with `@thunderclap-engine/engine` `^1.0.0` as a peer (Electron and Vite are optional peers of their hosts). `pnpm check:pack` (`scripts/pack-smoke.mjs`, CI job `pack`) packs all four, audits each tarball against its `files`, installs them into a fresh project outside the workspace, typechecks it (Bundler and NodeNext resolution, `skipLibCheck` off) and Vite-builds it. `.github/workflows/publish.yml` publishes on a `v*` tag through npm trusted publishing (OIDC, no token), skipping versions already on npm. [Releasing](docs/releasing.md) covers the one-time setup and the routine. *Fixed:* the WebXR ambient types (`platform/xr-types.d.ts`) ship in `dist/` and the declarations that use them reference them (`preserve="true"`).
12
+ - Public `Surface` interface (ws/platform-surface). *Added* (platform): `Surface` is an interface with the DOM surface's public members (`root`, `canvas`, `logicalWidth`, `logicalHeight`, `rect`, `fitRect`, `backbuffer`, `onResize`, `clientToLogical`, `logicalToClient`, `containsLogical`, `setFrame`, `layout`, `destroy`), and the value `Surface` is the DOM class that implements it (`new Surface(root, opts?)`, `extends Surface` and `instanceof Surface` work as before). The members and the constructor are unchanged; the class's private fields are no longer part of the `Surface` type, so a game-made surface is a `Surface` with no cast. `ctx.surface`, `BootResult.surface` and `mountSurface` keep the name. `Surface` is now an interface with a constructor of the same name rather than a class; existing code is unaffected (PM ruling: additive), and `Surface.name` reads `'DomSurface'`. Core's no-DOM surface (`createGame({ overlay: false })`) now implements it and drops its `as unknown as Surface` cast (and its internal `asSurface()`); `createTestGame` narrows `ctx.surface` with `instanceof DetachedSurface` instead of a cast. Tests in `src/platform/surface-types.test.ts`.
13
+ - TAA in XR (ws/xr-taa). No public change (`api/` unchanged). *Fixed:* `PostStack` kept one TAA history pair for every view, so with TAA on in XR scene mode the right eye reprojected the left eye's frame (and the left eye the right's previous one). The stack now keys its TAA history by the encode's `viewport`: the screen has one pair, and each eye's rect has its own history pair, aux pair and reset state, made on that eye's first TAA frame (at most five views; a sixth reuses the least recently used slot). Each eye reads its own jitter and reprojection from its staged uniform block (ws/render-12). `historyResets` counts one reset per eye. The screen path is unchanged (the same `post-taa-history-0/1` and `post-taa-aux-0/1` textures and groups). `postQualityFor('xr')` still keeps TAA off; the post README *XR* shows how to opt in (`setQuality({ ...postQualityFor('xr'), taa: true })` and `taa.enabled`). *Tests:* `src/platform/xr-taa.test.ts` (each eye's TAA pass reads the history it wrote last frame and its own uniform block at submit; steady state makes no textures or groups; no GPU error).
14
+ - Testing, fourth pass (ws/testing-4). No public change (`api/` unchanged). *Fixed:* `scripts/check-deprecated-game.mjs` types a member's receiver from its syntax node instead of the text before the `.`, so a receiver wrapped in parentheses or an assertion (`(x as T).m()`, `(<T>x).m()`, `(x satisfies T).m()`, `x!.m()`) resolves to the engine type and its removed member is reported (TS2339 on a game-wrapped `Stack` was missed). The "no row is still exported" test in `scripts/check-snippets.test.mjs` exempts the rows under the migration guide's *Reads that became getters* heading, as it exempts overload rows, so their *Module* cells have backticks again (`docs/guide/migrating-to-v1.md`). Tests `scripts/check-deprecated-game.test.mjs` (fixture `game-1x/src/wrapped.ts`), `scripts/check-snippets.test.mjs`.
15
+ - XR scene mode carries aux through post per eye (ws/render-12). No public change (`api/` unchanged). *Fixed:* `PostStack` resolving several XR eyes in one submit wrote every pass's uniforms with `queue.writeBuffer`, so each eye's passes read the last eye's block (reprojection, jitter, ray source) at submit; an encode with a `viewport` now stages its block in a four-slot ring (`post-eye-uniforms`, one buffer made on the first eye) and copies it into each pass's buffer on the encoder. The screen path is unchanged (direct writes, no copies). Each eye's resolve already read that eye's velocity from `XrSurface.scene` (encoder order); the eye layer targets stay without aux, since nothing writes them in scene mode. *Tests:* `src/platform/xr-scene-aux.test.ts` (object motion blur per eye: bound velocity, pass order, the uniform block each eye's pass sees at submit, no GPU error), a screen-path case in `src/post/post-stack.test.ts`.
16
+ - Core no-DOM mode (ws/core-9). *Added* (root `@thunderclap-engine/engine`, core): `CreateGameOptions.overlay?: boolean` (default `true`). `createGame({ overlay: false, gpu, host })` touches no DOM: no overlay, `styles`, zoom block, canvas, root, page lifecycle listeners, audio unlock or input listeners. `ctx.surface` letterboxes a virtual container of the design size at a pixel ratio of 1, and its `root` and `canvas` throw. Reading `ctx.ui` throws an error naming `overlay: false`. `ctx.padNav` never owns the pad. `gpu` and `host` are required in this mode. Tests in `src/testing/test-game-node.test.ts`.
17
+ - Testing, third pass (ws/testing-3). *Added:* `testing` `TestGameOptions` takes a third type parameter `P` (default `any`, as on `CreateGameOptions`), so `createTestGame` infers the pointer targets from `pointers.testers` as `createGame` does (`game.ctx.pointers.hit` is a `PickHit<{ id: number }>` for `planeTester(0, { id })`). `scripts/check-deprecated-game.mjs` also maps the old names whose error does not print the engine type, by the receiver's type from the checker: a read that became a getter, still called (TS2349: `cache.stats()`, `host.fullscreen.active()`, `host.lifecycle?.state()`), and a removed member on a type the game wraps (TS2339/TS2551, `type Stack = SceneStack & {…}`); `removedRowsFor` takes an optional `engineTypesAt(pos)`. `docs/guide/migrating-to-v1.md` lists the getters as table rows (*Reads that became getters*). Tests `src/testing/test-game-types.test.ts`, `scripts/check-deprecated-game.test.mjs` (fixture `game-1x/src/reads.ts`).
18
+ - Post-v1 render hardening (ws/render-11). *Added:* `render`: `SkinPalette.jointCount` (getter, the joints per slot). `platform`: `XrSurfaceOptions.aux` (`SceneTargetAux`): the eyes' scene pass gets the aux attachments (on `scene` in scene mode, on each eye's external target in direct mode); `createGame({ aux, xr })` passes its own, so XR with `aux` no longer draws a 2-attachment pass against aux pipelines. *Changed:* billboards and world-space particles write their own motion into a velocity attachment instead of masking it (a `:motion` pipeline variant: velocity written and alpha-blended, rough-metal still masked; billboards keep last frame's drawn anchors in a `<label>-prev` buffer, world particles step back by `sim.dt`); 2D particles, sky, water and decals stay masked, and without velocity the shaders are byte-identical. `skinPose` throws, before posing, when the palette holds fewer joints per slot than the skeleton has (it wrote past the slot). Each XR eye's velocity is measured against that eye's own `previousViewProj` (now tested). *Tests:* `src/fx/motion.test.ts`, `src/platform/xr-game-aux.test.ts`, aux cases in `src/platform/xr-surface.test.ts`, the `skinPose` refusal; `harness/render10.html?aux=velocity&blur` (object motion blur, a velocity probe grid) and `e2e/render10.spec.ts` check `particleBlurPx` and `billboardBlurPx` above 0 and `backgroundBlurPx` 0.
19
+ - *Added* (input, ws/input-12): `Input`'s constructor and `setBindings` take a `Partial<Bindings<A>>`, so with a typed action union the base map may leave out actions a pushed context binds (an untyped map still infers `A`; existing callers compile unchanged). `Input.control(id)` and `Input.replayControl(id)`, the `'control'` event and `InputFeedHook.control`: overlay controls fired by stable id ride the recording's new optional `ui` lane (`InputTickEvents.ui`, still `version: 3`; `parseRecording` validates it) and replay after `ptr`, before the tick's update. `PointerRouter` is now covariant in its target (`PointerRouter<T>` is a `PointerRouter<unknown>`).
20
+ - *Added* (ui, ws/input-12): `ButtonOptions.id`, `controlId(el, root?)`, `CONTROL_ID_ATTR` (`data-tc-id`), `CONTROL_PAGE_ATTR` (`data-tc-page`), `Overlay.onControl(fn)` and `Overlay.activate(id)`: every overlay button has a stable id (the game's `id`, else `page/key`, `#n` for duplicates), clicks are reported by it and a click can be replayed by it. A button without `id` renders as before.
21
+ - *Added* (root `@thunderclap-engine/engine`, core, ws/input-12): `CreateGameOptions.pointers.testers` (the router's first hit testers; `createGame` infers `P` from them) and a third type parameter `P` on `CreateGameOptions`; `input.bindings` is a `Partial<Bindings<A>>`. `createGame` records overlay button clicks by id (`ui.onControl` → `input.control`) and a replay presses them (`'control'` → `ui.activate`) on the same frame.
22
+ - Testing, second pass (ws/testing-2). *Added:* `testing` `FakeDevice.lost` (the device's `lost` promise) and `FakeDevice.loseDevice(reason = 'unknown', message?)`, which resolves it once; `device.destroy()` resolves it with `'destroyed'`. `TestGame.loseDevice(reason?, message?): Promise<void>` loses the device and resolves once `createGame` has handled the loss: the loop halted, `hooks.deviceLost`, audio halted, captions suspended, save flushed, `ctx.onDeviceLost` listeners run. `TestGameOptions.overlay` (default `true`): `overlay: false` runs `createTestGame` on `createGame`'s no-DOM mode (`CreateGameOptions.overlay`, ws/core-9), with no DOM library and no DOM global installed, in Vitest's `node` environment: reading `ctx.ui` throws an error naming `overlay: false`, and `viewport` sizes the virtual container. Without a DOM and without `overlay: false` it now throws an error that names both fixes. *Tooling:* `scripts/check-deprecated-game.mjs` is the migration guide's find-your-uses command for a game's repo. It lists only engine names: on 0.x, the suggestions for deprecated names that the engine declares; on 1.0.0, the type errors for removed names, each mapped to its row in the guide with the replacement. The DOM's `execCommand`, the game's own deprecations and its other errors drop out. It also exports `findGameDeprecations`. `pnpm check:deprecated` runs its tests (`scripts/check-deprecated-game.test.mjs`, fixtures in `scripts/fixtures/deprecated-game`). *Docs:* the testing README, [Testing your game](docs/guide/testing-your-game.md) and [Migrating to v1](docs/guide/migrating-to-v1.md). Tests `src/testing/test-game-node.test.ts`.
23
+
24
+ ## 1.0.0 (2026-11-07)
25
+
26
+ - engine-v1.0.0 cut (ws/v1-cut, E-036). *Breaking:* every 0.x deprecated alias is removed (73 names, listed under *Removed*); [Migrating to v1](docs/guide/migrating-to-v1.md) is the map from 0.x, one row per name with its replacement. `src/render/deprecated.ts` and `src/ui/deprecated.ts` are gone; the block offsets, shader sources and `*_CSS` constants they exported stay internal. The aliases' own tests went with them; code that still honoured an old field (`SunLight.dir`, `PostSettings.fog`, `mipFilter`, `TooltipHost.frame`, `WorldPanelInput.getPointers`, the positional `createSpriteMaterial`) now takes the v1 path only. `api/*.api.txt` lost 259 lines and gained none. *Added:* the freeze rule in `pnpm check:api` (`scripts/api-changelog.mjs`, tests in `scripts/api-surface.test.mjs`): an `api/` file that differs from the last release (tag `v<version>` or `engine-v<version>`, else the commit that set `package.json` to the version) fails unless this file's `## Unreleased` has a *Breaking* or *Added* entry naming its module; each snapshot opens with an `engine-v1.0.0 frozen surface` header line. *Changed:* `pnpm check:deprecated` drops the `// deprecated-alias test|impl` exemption markers (`EXEMPT`, `isExempt` and the `exempt` option of `findDeprecatedUses` are gone); the engine declares nothing deprecated, so the check guards the next deprecation, and the DOM's are filtered by where they are declared. The migration-guide test now fails when a row names something `api/` still exports. `pnpm release` reads `## Unreleased` (or the older `(unreleased)` heading) and opens a fresh one. Version `0.2.0-alpha.1` → `1.0.0`.
27
+ - *Removed* (each with its v1 replacement in the migration guide):
28
+ - `anim` (2): `BlendMode`, `PlayOptions`.
29
+ - `assets` (5): `GltfMaterial.baseColorTexture`, `GltfMaterial.texCoord`, `SKINNED_JOINTS_OFFSET`, `SKINNED_VERTEX_STRIDE`, `SKINNED_WEIGHTS_OFFSET`.
30
+ - `audio` (2): `FollowTarget`, `PlayOptions`.
31
+ - `core` (1): `SceneStack.list()`.
32
+ - `geom` (2): `Aabb`, `Hit`.
33
+ - `input` (11): `Aabb`, `FollowTarget`, `Grid`, `Hit`, `Rect`, `Input.contextStack()`, `Input.getBindings()`, `Input.getGestures()`, `Input.getLook()`, `Input.getPointers()`, `Input.listeningFor()`.
34
+ - `nav` (1): `Cell`.
35
+ - `platform` (2): `Rect`, `prefersReducedMotion()`.
36
+ - `post` (5): `FogSettings`, `PostSettings.fog`, `PostStack.addJitterCamera()`, `PostStack.removeJitterCamera()`, `defaultPostSettings()`.
37
+ - `render` (22): `BlendMode`, `Fog`, `LoadTextureArrayOptions.mipFilter`, `Renderer.white`, the positional `Renderer.createSpriteMaterial(texture, label?, opts?)`, `SunLight.dir`, `BUFFER_BINDING_BASE`, `CAMERA_INV_VIEW_PROJ_OFFSET`, `CAMERA_JITTER_OFFSET`, `CAMERA_PREV_VIEW_OFFSET`, `CAMERA_PREV_VIEW_PROJ_OFFSET`, `FOG_OFFSET_FLOATS`, `LAYERS_OFFSET_FLOATS`, `LIGHTS_BLOCK_FLOATS`, `POINT_LAYERS_OFFSET_FLOATS`, `POINT_LIGHTS_OFFSET_FLOATS`, `SHADOW_PARAMS_OFFSET_FLOATS`, `SKIN_PALETTE_HEADER_FLOATS`, `SPOT_LIGHTS_OFFSET_FLOATS`, `SUN_VIEW_PROJ_OFFSET_FLOATS`, `SPRITE_WGSL`, `STANDARD_WGSL`.
38
+ - `save` (1): `TYPED_CTORS`.
39
+ - `ui` (19): `Cell`, `Cooldown`, `CooldownOptions`, `prefersReducedMotion(win?)`, `TooltipHost.frame`, `WorldPanelInput.getPointers()`, `BASE_CSS`, `CANVAS_MINIMAP_CSS`, `CURSOR_CSS`, `FLOATS_CSS`, `HUD_CSS`, `INVENTORY_CSS`, `LOADING_CSS`, `NOTIFY_CSS`, `ORB_CSS`, `TOOLTIP_CSS`, `WIDGETS2_CSS`, `WIDGETS3_CSS`, `WORLD_PANEL_WGSL`.
40
+
41
+ - Engine source on v1 names (ws/src-v1, E-036). No public change (`api/` unchanged). *Changed:* `src`, `harness`, `e2e` and `packages` use no deprecated engine name: `lights.sun.dir` → `.direction`, `defaultPostSettings` → `cinematicPostSettings`, `fog` → `mist`, `Cell` → `NavCell`/`GridCell`, `Aabb` → `RayBox`, `SKINNED_*` → `VERTEX_SKINNED_STRIDE`/`SKIN_*_BYTE_OFFSET`, positional `createSpriteMaterial` → its options form, `listeningFor()` → `listening`, and ui's own `prefersReducedMotion` → `prefersReducedMotionIn`. The deprecated `*_CSS` and `WORLD_PANEL_WGSL` exports of `ui` move to `src/ui/deprecated.ts` (as `render/deprecated.ts` does), so the source constants carry no `@deprecated`. A test that exists only to cover an alias ends its line with `// deprecated-alias test`; the alias's own fallback read says `// deprecated-alias impl`. *Tooling:* one deprecated-name checker. `scripts/check-deprecated.mjs` now holds `findDeprecatedUses` (re-exported from `check-snippets.mjs`, so the migration guide's path works) with `declaredIn` (engine declarations only, aliases resolved: the DOM's `execCommand` does not count) and `exempt` options, and `pnpm check:deprecated` runs two passes: the suggestion pass over the root, e2e, `packages/*` and `templates/*` tsconfigs, then the strip pass over demos, harness and e2e, `packages/*` and `templates/*` (it alone catches a deprecated key in a mapped-type patch such as `post.set({ fog })`). Both skip `deprecated-alias` lines. `pnpm check:snippets` is unchanged. *Docs:* the migration guide gains § *Removal*. *Fixed:* the Render 10 entry and the `src/fx/render10.test.ts` note said `WorldPanel` is right in `renderer.target` only; ws/core-8 keyed it on `frame.auxKey`.
42
+ - Templates and demos, v1 polish (ws/templates-v1). *Added:* `pnpm check:deprecated` (in `pnpm check`, after `check:snippets`; `scripts/check-deprecated.mjs` with its tests in `scripts/check-deprecated.test.mjs`) emits the engine's declarations, strips every one marked `@deprecated` (top-level names, members, overloads, re-exports through the barrels) and typechecks `demos/**` and each template against the result, so any use of a deprecated export, field or overload fails the check. It found 16 uses and all are migrated: `sun.dir` → `direction` (arena, arpg, range, showroom, voxel, first-person, third-person), post `fog` → `mist` (arpg, dungeon, showroom twice, tactics, voxel), the positional `createSpriteMaterial` → its options object (dungeon, tactics, platformer), input `Hit` → `PickHit` (range), `Aabb` → `RayBox` (first-person) and `defaultPostSettings` → `cinematicPostSettings` (showroom); the template CLAUDE.md files and the showroom README use the new names. *Changed:* the third-person character poses with one `skinPose(skeleton, pose, palette, 0, undefined, world)` call per frame instead of `poseToWorld` + `skinMatrices` + `palette.set` (the frame is byte-identical). Every template has a `README.md` (what it is, how to run it, the files to edit first, the engine guides it builds on), and `create-game` writes it under the generated title and intro (dropping the engine-repo-only line). Every template has a game-logic test on `@thunderclap-engine/engine/testing`: puzzle and platformer save and reload progress on `createFakeHost` with a `createFixedClock` (puzzle's win fold is now `recordWin` in `save/schema.ts`), first-person uploads a meshed chunk on the fake GPU, and third-person runs the character on the fake GPU and a fixed clock (attack lock, one swing, root-motion lunge). `check:create-game` also runs each scaffolded game's own `pnpm test`.
43
+ - core, eighth pass (ws/core-8). *Added:* `createGame` runs headless: `CreateGameOptions.gpu?: GpuContext` skips the WebGPU boot and the canvas context (a `gpu.device.lost` promise still halts the loop), `root` is optional with `gpu` (default a detached `<div>`; a DOM is still needed for the overlay), and `clock?: TickClock` sets `ctx.clock`. `createTestGame(opts): Promise<TestGame>` in `@thunderclap-engine/engine/testing` is that call on a `FakeDevice`, a `FakeHost` and a `FixedClock`, with `step(n)` running `ctx.loop.tickOnce(step)` n times (`TestGameOptions`: `createGame`'s plus `host?`, `device?`, `viewport?`). `AttachOptions.bounds` / `PointerDispatcherOptions.bounds` name the element pointer positions, touch zones and `Look`'s move zone measure, and `PointerDispatcher.bounds()` reads it. *Changed:* `FixedClock.advance(stepMs)` counts as a step, so a game loop on it reads exactly N × step. *Fixed:* `ctx.replay` and `input.replay` measured pointers against their detached sink (a 0×0 box), so a touch stick or `Look` move zone replayed wrong; the sink now measures the canvas and a recorded stick drag replays frame for frame. `WorldPanel` keys its pipeline on `frame.auxKey` through `renderer.sceneTargets(blend, aux)` (one per depth mode × aux key, labels unchanged without aux), so it draws into an aux target and a plain target in the same frame. *Breaking* (E-036, reads are getters): `HostLifecycle.state` is a getter, not a method (`host.lifecycle?.state`); a custom `Host` writes it as a getter or a value, and `@thunderclap-engine/host-capacitor` and the fake host do. Tests `src/testing/test-game.test.ts`, `src/ui/world-panel/panel-aux.test.ts`.
44
+ - Assets and anim, fourth pass (ws/assets-4). *Added:* anim `skinPose(skeleton, source: Pose | SkinPoseSource, palette: SkinPoseTarget, slot = 0, dt?, world?): Float32Array` runs `source.update(dt)` (when `dt` is given), `poseToWorld`, `skinMatrices` and `palette.write(slot)` in one call, bit for bit the same as the steps by hand, and returns the node world matrices (a scratch array per skeleton, or `world`); no allocation after the first call per skeleton. `SkinPoseSource` is `{ readonly pose; update(dt) }` (an `AnimationMixer`); `SkinPoseTarget` is `Pick<SkinPalette, 'write'>`. *Changed:* `GltfSceneLights.directional` is `SunLight[]`: each has `direction`, the node's world -Z normalised, ready for `Lights.sun.direction`, and keeps the deprecated `dir` as the same array. `skinMatrices` without `inverseBind` copies each joint without making a subarray view, so it allocates nothing either.
45
+ - Render 10 (ws/render-10). *Fixed:* with aux targets on (`createGame({ aux: { velocity: true } })`, `RendererOptions.aux`), every engine drawable that builds its own scene-pass pipeline (`Sky`, `ParticleSystem` 2D and world, `Decals`, `Water`, `Billboards`, `DebugDraw`, `WorldPanel`) declared the two `renderer.sceneFormats` targets against a pass of three or four, a GPU validation error every frame. *Added:* `renderer.sceneTargets(blend?, aux = renderer.target.auxKey)` returns the scene pass's colour target list for an aux key (colour and emissive with `blend`, the aux targets with `writeMask: 0`), cached per (blend, aux); `frame.auxKey` is the aux key of the view being drawn. The drawables key their pipelines on the target set (depth format × aux key), built on first use, so an aux `renderer.target` and a plain offscreen target draw in the same frame; materials share the list builder. `WorldPanel` followed in ws/core-8 (it keys on `frame.auxKey` too). Without aux nothing changes (labels and the 2D goldens are identical). Page `harness/render10.html` (`?aux=velocity|all|none`) with `e2e/render10.spec.ts`; `src/fx/render10.test.ts` checks target counts on the fake GPU.
46
+ - core, ui and input API pass (ws/api-core, E-036): `ctx.replay` mutes `ctx.pointers` for its length, so a live XR controller or a screen source fed directly never reaches the game, and lifts only its own mute: `PointerRouter.mute(sources?)` now returns a remover (idempotent; `unmute()` still lifts all). `ctx.setCamera(camera)` swaps the camera; `ctx.camera` is a getter over `renderer.camera` and the loop reads it each frame. `EngineCtx`'s default `S` is `FlushableSave | null` (a no-save ctx is an `EngineCtx<A>`), and a fourth parameter `P` types `ctx.pointers` (`createGame<A, S, C, P>`; `any` by default on `EngineCtx`). `CreateGameOptions.theme` and `textScale`. `OverlayOptions.camera` is the default for `anchors()` and `floats()`, which `createGame` sets to the game's camera. While an XR session runs the router's camera is the head, so a billboard or head-locked `WorldPanel` faces the headset; `WorldPanel.attach(input, { camera })` names another, and `WorldPanelInput` takes `pointers`. `TouchZoneBinding<Z>` and `TouchZonesOptions<Z, A>` type the zone names and actions. A context's bindings may add an action the base map lacks (it reads unbound below the context; this used to throw). `planeTester` takes any `RayPlane { normal, offset? }` as well as a depth. Getters `Input.gestures`, `pointers`, `look`, `contexts`, `listening` and `SceneStack.scenes`. The save-flush and host docs name the lifecycle `background` flush, haptics and the safe area.
47
+ - render, post, fx v1 surface (ws/api-render, E-036). *Added:* `renderer.createSpriteMaterial({ texture, label?, blend?, sampler? })` (`SpriteMaterialOptions`); `Transform.setScale(s)` scales all three axes (`setScale(x, y, z?)` unchanged); `Lights.sun` is a `SunLight` with `direction`, the `SpotLight` spelling; `DistanceFog` (render's `lights.fog`) and `MistSettings` / `PostSettings.mist` (post's screen-space mist) give the two fogs distinct names, documented to stack; `neutralPostSettings()` (every pass off but an ACES tone map with dither) and `cinematicPostSettings()` (the tuned look `PostStack` starts from, unchanged); `GodRaySettings.direction` places the rays from a world-space light direction through the frame's view, off while the sun is behind the camera, and `godRaySource(viewProj, direction, w, h, out)` is that projection; `renderer.createMeshLod({ geometries, thresholdsPx, label? })` (`MeshLodDesc`) uploads geom's `createLodChain` output as a `MeshLodChain` (a `MeshLod` that keeps its `geometries`); `SkinPalette.write(slot)` (the slot's joint block to fill in place, e.g. `skinMatrices(skeleton, world, palette.write(0))`) and `SkinPalette.params(slot)` (cached `InstanceParams` naming the slot); `atlasFrames({ cols, rows, inset, width, height })` (`AtlasGrid`) insets each rect by texels; `camera.screenToWorld(sx, sy, out)` returns the point on the plane z = 0. The 3D guide's LOD example now builds the three levels it describes. *Deprecated* (removed next minor): the render exports `CAMERA_INV_VIEW_PROJ_OFFSET`, `CAMERA_PREV_VIEW_PROJ_OFFSET`, `CAMERA_PREV_VIEW_OFFSET`, `CAMERA_JITTER_OFFSET`, `POINT_LIGHTS_OFFSET_FLOATS`, `SUN_VIEW_PROJ_OFFSET_FLOATS`, `SHADOW_PARAMS_OFFSET_FLOATS`, `FOG_OFFSET_FLOATS`, `LAYERS_OFFSET_FLOATS`, `POINT_LAYERS_OFFSET_FLOATS`, `SPOT_LIGHTS_OFFSET_FLOATS`, `LIGHTS_BLOCK_FLOATS`, `SKIN_PALETTE_HEADER_FLOATS`, `BUFFER_BINDING_BASE`, `STANDARD_WGSL` and `SPRITE_WGSL` (internals; same values); `Renderer.white` (use `defaultTextures(device).white`); `BlendMode` (use `RenderBlendMode`; anim has its own `BlendMode`); the positional `createSpriteMaterial(texture, label?, opts?)`; `Lights.sun.dir` (use `direction`; the same array); `Fog` (use `DistanceFog`); post's `FogSettings` (use `MistSettings`) and the `fog` section (use `mist`; `settings.fog` and `fog` patches still read and write `mist`); `defaultPostSettings()` (use `cinematicPostSettings()`, the same values, or `neutralPostSettings()`). The 2D harness is pixel-identical.
48
+ - API rest (ws/api-rest, E-036) in analytics, anim, assets, audio, geom, host, i18n, nav, platform, save, tween and voxel. *Deprecated* (aliases kept until the next minor; same shapes): nav `Cell` → `NavCell`; anim `PlayOptions` → `AnimPlayOptions` and `BlendMode` → `AnimBlendMode`; audio `PlayOptions` → `AudioPlayOptions` and `FollowTarget` → `AudioFollowTarget`; geom `Aabb` → `GeomAabb` and `Hit` → `RayHit`; platform `Rect` → `LetterboxRect` and `prefersReducedMotion()` → `isReducedMotion(win?)`; save `TYPED_CTORS` (an internal table). *Breaking* (reads are getters): `AssetCache.stats` and `HostFullscreen.active` are getters, not methods (`cache.stats`, `host.fullscreen.active`); a custom `Host` writes `active` as a getter or a boolean, and `@thunderclap-engine/host-electron` and `@thunderclap-engine/host-capacitor` do. *Fixed*: tween `Tweens.update(dt)` treats a NaN `dt` as 0 (it advanced every tween to NaN). *Templates*: every template has `build` (`vite build` to `dist/`) and `preview` scripts, and its `package.json` description no longer says "Copy the folder". `create-game` now defaults the engine dependency to a `link:` to the clone it runs from, which is what a user can install while the package is not on npm (`PUBLISHED` in `scripts/create-game.mjs` switches the default to `^<version>` on the first publish); the generated README says so and lists `pnpm build`. `check:create-game` builds each scaffolded template after its offline install and typecheck.
49
+ - API surface (ws/harden-api): `api/<entry>.api.txt` snapshots every `exports` entry's names and signatures; `pnpm check:api` (in `pnpm check`) fails with a diff when `src` drifts from them and `pnpm api:update` rewrites them. `save` exports `NumberOptions` (the `s.number` options, formerly the unexported `NumberOpts`). `assets` deprecates `SKINNED_VERTEX_STRIDE`, `SKINNED_JOINTS_OFFSET` and `SKINNED_WEIGHTS_OFFSET` in favour of render's `VERTEX_SKINNED_STRIDE`, `SKIN_JOINTS_BYTE_OFFSET` and `SKIN_WEIGHTS_BYTE_OFFSET` (same values; the aliases go next minor).
50
+ - UI, eleventh pass (ws/ui-11): meters move and the minimap holds its size in a rail. `MeterOptions.place` (a region or a per-form-factor map, over `region`) moves the live meter on a form-factor change; `Meter.setPlace(place)`, `Meter.region` and `Meter.size` (the `FormSize`; `undefined` on a bar). A move keeps the element, so value, colour and the trail band carry over (the trail re-arms from where it stood). `WidgetOptions.onMove(region)` runs after a re-layout that moved a widget. Fix: every re-layout (resize, `refresh`, `add`, `setSize`) re-appended every widget, which cancelled running CSS transitions (orb fills and trails snapped) and dropped focus; a widget in place now stays put. Content regions carry `data-content`, where `ui.minimap` holds its `size` instead of shrinking past the rail's `max` (now reported by `overflows()`). `harness/ui5.html?minimap-rail` (`mapRegion`, `mapWidth`, `mapWant`) and an orb-move row (`moveOrb`, `trailGap`, `trailFrames`) in `e2e/ui5.spec.ts`.
51
+ - Input, eleventh pass (ws/input-11): `PointerRouter.mute(sources?)`, `unmute()` and `isMuted(source)`. A muted source (no argument: every source, later ones included) is polled but its pointers are dropped before routing; a pointer held when the mute begins gets `hitEnd` and `released` (`hit: null`) and reads up, and a button held through `unmute()` reads up until let go. `PointerRecord.synthetic` (set while `Input.replayPointer` dispatches) and optional `PointerSample.synthetic` mark a replay's pointers, which a mute never drops; `ScreenPointers.pointerDown`/`pointerMove` take a trailing `synthetic = false`.
52
+ - core, seventh pass (ws/core-7): `ReplayOptions.onEnd?(): void` (now an interface) runs once when a `ctx.replay` recording ends, on the step that reattaches the input, before that step's update; not on `stop()` or when replaced. `CreateGameOptions.aux?: SceneTargetAux` requests the renderer's aux targets (`RendererOptions.aux`): `{ velocity: true }` puts a velocity target in `PostInputs.velocity`, so `post.motionBlur` gets object motion; without it nothing changes.
53
+ - core, sixth pass (ws/core-6): `ctx.replay(recording, opts?: ReplayOptions): GameReplay` starts a replay mid-session: it installs an `InputPlayback` on `ctx.input` driven by `ctx.clock`, detaches the input from the canvas and window until the recording ends or `stop()` is called (real keys and pointers never reach it; recorded pointers still reach the gestures), then reattaches it; a long-press lands on its recorded tick at speed 1 and 4. `EngineCtx.clock` is now typed `TickClock`, so `{ clock: ctx.clock }` typechecks for `createInputPlayback` with no cast. `GameReplay` and `ReplayOptions` are exported from the root.
54
+ - Post 8 (ws/post-8): `PostStack` decides whether the jitter hook is its own by `renderer.jitter === hook` (the ws/render-8 getter) and the module `WeakMap<Renderer, PostStack>` is deleted; the ws/post-7 behaviour is unchanged, and a hook the page installs with `renderer.setJitter(fn)` is no longer cleared when a stack's TAA stops, it detaches or it is destroyed.
55
+ - Render 8 (ws/render-8): every draw kind writes true object motion into a velocity target. `frame.mesh(mesh, material, transform)` (and `frame.sprite` with a `Transform`) with an `aux` material into a velocity target now writes the object's own motion through the shared `instance-lane` (variant `...:aux2:p`): the renderer keeps each `Transform`'s world matrix per view (per camera) and hands the draw the one it held at that camera's last view. A transform first seen in a view, or not drawn in the view before, writes zero motion; a scratch transform drawn with different matrices in one view reports camera motion only. `DrawParams` draws, materials without `aux` and targets without velocity are unchanged (nothing kept, written or bound; the 2D goldens are pixel-identical). A velocity LOD `InstanceList` no longer has or uploads a `prevBuffer` (`prevBuffer` is null; `prevMatrices` stays and travels with the copy). `renderer.jitter` (read-only) returns the hook `setJitter` installed, or null. Page `harness/render7.html?single-velocity` (`&still`) with its rows in `e2e/render7.spec.ts`.
56
+ - Render 7 (ws/render-7): stable LOD keys and per-instance velocity on copied batches. `InstanceList.setKey(i, key | null)` gives an instance a stable finite number (`keyOf(i)`, `keyed`); a key stays with its slot through `set`, moves on `remove` and starts unset on an appended slot. A keyed list matches slots to the last flush by key at each flush (`previousIndex`, `flushCount`), so the per-view LOD level memory and the velocity history follow the instance through a swap-remove, a reorder or a clear and refill; without keys both still go by index. A LOD list with `velocity: true` now writes per-instance motion: its previous matrices are copied with the instances, bucketed by level, into the renderer's shared `instance-lane` buffer (48 bytes per instance, bound at slot 2 at the view's offset; variant `...:aux2:p`), read only by an `aux` material into a velocity target. `InstanceBatch.prevMatrices` does the same for `frame.meshes` batches (plain or LOD; too short throws). A new instance (a new key, or a slot past last flush's count) writes zero motion on its first frame. `renderer.lodLevelOf(batch, i, camera?)` reads the level an instance drew at last frame for a view. `Material.aux`. Without keys, `prevMatrices` or a velocity target nothing changes (the 2D goldens are pixel-identical). Fake GPU: `writeData` (each write's source), `setVertexBuffer` records `offset`. Page `harness/render7.html` (`?lod-keys`, `?instanced-velocity`, `?still`, `&unkeyed`) with `e2e/render7.spec.ts`.
57
+ - fx, seventh pass (ws/fx-7): `ParticleSystem.readbackFields(): Promise<ParticleFields>` (`debug: true` only) reads the live particles back unpacked, one named typed array per field (`position`, `velocity`, `age`, `life`, `radius` through the size curve, `sizeStart`, `sizeEnd`, `color0`, `color1`, `gravity`, `drag`, `emissive`, `rotation`, `spin`, `wind`, `tile`, `curveSlots`, `sub`, plus `alive`, `slots`, `time`) for either kernel's layout; calls in flight share one copy with `debugReadback()`, which is unchanged. `fx3?forces` and `fx3?collide` measure from the fields, and `unpackedError` state (asserted ≤ 1e-6 in `e2e/fx3.spec.ts`) checks them against the packed form of the same copy.
58
+ - core, fourth pass (ws/core-4): `seedKey(seed): number` is exported from `@thunderclap-engine/engine/core` (the 32-bit key `createRandom` and `hash2`/`hash3` start from; values unchanged). `createGame` builds one `createTickClock()` as `ctx.clock` (`EngineCtx.clock: InputClock`, ms from 0) and runs `ctx.gestures` on it: the loop advances it by the fixed step each fixed update (after `playback.step()`, before `input.update()`; by the frame delta with `sim: false`; not while paused or stopped), so long-press and double-tap windows are game time. A replay's playback is built with `{ clock: ctx.clock }` (ws/input-9) and advances it per fed tick, so the loop skips its own advance while it feeds (`playback.clock === ctx.clock`) and a long-press replays on the same tick at `speed` 1 or 2. A gesture or `Look` the game builds itself takes `{ clock: ctx.clock }`. With `input.replay` the input now attaches to a detached element (keys read from it too), so recorded pointers reach the gestures and pointer rays (they were dropped before) and a long-press replays on the tick it fired live; real pointers and keys still never reach a replay.
59
+ - Post 7 (ws/post-7): a `PostStack` recognises its own `renderer.setJitter` hook. Detached (`renderer.post` set to `null`, or to another processor so the stack stops resolving), its hook clears itself at the next frame, and a stack clears the hook on TAA off or `destroy()` only while it is still its own, so swapping `renderer.post` from TAA stack A to B and destroying A leaves B jittering; no `src/render` change. `harness/post3` no longer calls `addJitterCamera`; `addJitterCamera` / `removeJitterCamera` stay exported as deprecated no-ops until the second release after 0.2.0-alpha.1.
60
+ - Input, tenth pass (ws/input-10): a playback anchors recorded pointer times at the clock reading of its first `step()` (less `tick × tickMs`; again after a `seek`) instead of at the first timed entry, so a long-press whose down was handled late fires on its recorded tick at any speed however late the replay starts. The wall clock, and a step anchor that would put the first entry after it is fed, keep the old anchor. The input README *Clock* section says the clock under `createGame` is `ctx.clock`.
61
+ - Post 6 (ws/post-6): TAA jitters through the renderer and keeps sub-pixel motion sharp. While TAA runs `PostStack` hands Halton (2, 3) to `renderer.setJitter` (every camera the renderer draws, XR eyes and offscreen views included) and clears it with `null` when TAA stops or on `destroy()`; nothing in src/post calls `Camera.setJitter` (a test greps for it). `addJitterCamera` / `removeJitterCamera` are deprecated no-ops, kept for one release. `TaaSettings.historyFilter: TaaHistoryFilter` (`'catmull-rom'` default, `'bilinear'`) reads the history through a 5-tap Catmull-Rom filter clamped to the 3×3 neighbourhood (`taaCatmullRomWeights(f, out?)` is the CPU twin of its weights; uniform `taa3.z`); `harness/post4.html?taa` measures it on a cube creeping 0.37 px a frame on jitter (`ghostSubpixel*`, `blurSubpixel*`).
62
+ - fx, sixth pass (ws/fx-6): particle state can be read back from the GPU. `ParticleSystemOptions.debug` (opt-in: the particle buffer gains `COPY_SRC` and one staging buffer is made; off, no cost) enables `ParticleSystem.debugReadback(): Promise<ParticleReadback>` (`alive`, `positions` and `velocities` as xyz per live particle, `slots`, `time`), which copies and maps the buffer only when called. Depth collision takes its surface normal from pixel-centre depths (per axis the shorter one-sided difference) instead of differences from the particle's sub-pixel hit point, which tilted it for a resting particle: the drops resting on the fx3 slab slid 0.13 units/s, now 0.0008. `fx3?forces` and `fx3?collide` read the particles back every 500 ms; `e2e/fx3.spec.ts` asserts the resting drift under 0.05 units/s and the GPU shell's mean radius under 0.3 within 3 s (measured 1.57 s). `fx3` state: `meanRadius` is now the GPU measure, the CPU shadow moved to `shadowRadius`.
63
+ - UI, tenth pass (ws/ui-10): content rails measure `max-content` and meters size per form factor. A `content` `left`/`right` rail lays itself out at `max-content` (no stretch) for its one measure per re-layout, so a stretch-width widget contributes its intrinsic width and a rail follows a label that grows or shrinks; `MeterOptions.size` is a `FormSize` (a number, or `FormSizes` `{ phone?, 'phone-landscape'?, 'phone-portrait'?, tablet?, desktop?, default }`, theme px; `phone` covers both phone forms), `Meter.setSize(size)` changes it live and re-lays out once (a bar ignores it), the pure `formSize(size, ff)` resolves one, and `WidgetOptions.onForm(ff)` runs on every re-layout before the measure. `harness/ui5.html?orb-sized` (`orbWidths`, `orbWant`) and `e2e/ui5.spec.ts` check each orb is its map's size, with no overflow warning, on every row.
64
+ - UI, ninth pass (ws/ui-9): content-sized side rails. `BarName` is now `'top' | 'bottom' | 'left' | 'right'`: a `content` `left` or `right` rail grows its outer column track to its widest widget plus `pad`, between the form's track and `max`, and `fill`/`freeRect` get narrower; a widget wider than `max` allows still reports in `overflows()`. Corner rule: where a content bar and a content rail meet, the corner cell goes to the one whose content wants the larger track (a tie, or a swap that would break a region's rectangle, keeps the form's `areas`), in the pure state and the DOM grid alike. Fixed stays the default; existing layouts are unchanged. `resolveLayout`'s `content` argument also takes rail widths. `harness/ui5.html?orb-rails` puts the two orbs in content `left`/`right` rails (max 160; `orbRegions`, `railWidths` state) and `e2e/ui5.spec.ts` asserts no overflow warning on every row and wider-than-56 px rails on phones.
65
+ - UI, eighth pass (ws/ui-8): content-sized bars. `LayoutSpec.bars` and `FormSpec.bars` (`Partial<Record<BarName, BarSize>>`, `BarName = 'top' | 'bottom'`, `BarSize { size: 'fixed' | 'content'; max? }`; a form's entry wins over the spec's) let the `top` and `bottom` bars grow from their row track to their tallest widget plus `pad`, clamped by `max`; the grid re-flows and `freeRect` shrinks. Fixed bars, the default, are unchanged. `resolveLayout` takes an optional seventh argument `content` (tallest widget per bar, CSS px). `harness/ui5.html?orb` puts its orbs at the two ends of a content `bottom` bar (max 160), so no matrix row warns `[layout] … overflows`; `e2e/ui5.spec.ts` asserts that for every ui5 variant (`overflows` state).
66
+ - Input, ninth pass (ws/input-9): a recording replays exactly at any speed, gamepad look included. A linked `Look` reads the input's pads for the tick (`Look.padsFromInput`, set by `Input.setLook`; an explicit `LookOptions.getGamepads` still wins), so a playback's recorded pads turn it; `Input.update()` now reads the pads before gestures and look advance, and `InputFeedHook.padSource?` replaces them there. Recordings carry `tickMs` (optional, still version 3); `createInputPlayback(input, rec, { clock, tickMs, speed })` advances a `TickClock` by `tickMs` per fed tick, so a long-press replayed at speed 1, 2 or 4 fires on the same recorded tick; `InputPlayback.clock` and `InputPlayback.tickMs` read them back.
67
+ - Input, eighth pass (ws/input-8): gestures and look on one game-time clock. New `InputClock` (`{ now(): number }`), `TickClock` and `createTickClock(start = 0)` (`now()`, `advance(ms)`, called from the fixed step), `wallClock` (the `performance.now()` default), `GestureOptions.clock` and `LookOptions.clock` (win over the older `now`), `Gestures.clock`, `Look.clock`, and the factories `createGestures(opts)` / `createLook(opts)`. On a shared tick clock a recording replays the same gestures (a long-press fired by `update()` on a held pointer included) and the same look output on the same tick indices at half, the same or double the live tick spacing. Recorder and playback fall back to the linked look's clock when no gestures are linked. Playback now feeds a tick's pointer position before its mouse buttons, so a replayed orbit-look drag no longer jumps from the previous press's position. Defaults are unchanged. Closes the input-5 and input-7 clock wishes.
68
+ - UI, seventh pass (ws/ui-7): `layout.add` warns once per widget (`[layout] <id> overflows its "<region>" region in <form factor>: WxH px in RWxRH px`, again after `setPlace`) when the widget's measured box is larger than its region's inner box (the region less its `pad`), and `layout.overflows()` lists them now (`LayoutOverflow { id, region, w, h, rw, rh }`, CSS px). Measured once per re-layout (`add`, `setPlace`, resize, `refresh`), never per frame. Minimap markers face a way: `MinimapMarker.heading` (radians clockwise from north, `setPlayer`'s yaw convention, default 0), and `MinimapMarkerShape` gains `'arrow'` (the player's arrow shape, `size` its length), turned by the heading on a north-up map and by heading − yaw on a player-up one; `MinimapMarkerPlace.angle` (written by `markerAt`) is that turn. A heading change redraws; other shapes do not turn. `harness/ui5.html?minimap` adds an arrow marker (`headingMarkers` state).
69
+ - fx, fifth pass (ws/fx-5). Depth collision pushes a particle out along the surface normal reconstructed from the depth neighbours instead of back along the view ray; the ray push moved a resting particle toward the camera by its sink depth over the tangent of the grazing angle every frame, so drops crept off the front edge of `fx3?collide`'s slab (2.2% of live drops on average and 2.7% at peak at 60 fps; drops born 3 units from the edge got off in about 1.8 s), now 0. `ParticleForce` `attract` gains `falloff?: AttractFalloff` (`'constant'` default, unchanged; `'linear'` = `strength × r`; `'inverse-square'` = `strength / r²`) and `deadZone?: number` (no pull inside it). `fx3?forces` uses a linear pull with drag 3 so its motes gather into a blob instead of orbiting through the attractor.
70
+ - Render 6 (ws/render-6): LOD everywhere, alpha-masked shadows, a renderer jitter hook and GPU features. `frame.meshes(lod, material, batch)` and `renderer.createInstanceList(lod, material, capacity)` take a `MeshLod`: each instance picks its level with `pickLodLevel` and hysteresis, the instances are copied into the upload bucketed per level so each non-empty level is one instanced draw (scene and shadow pass), and `stats.lodCounts` counts them; `InstanceList.mesh` (`meshes[0]`) and `InstanceList.lod`; a LOD list is copied per frame like a batch and culled per instance (its velocity lane is not read). LOD level memory is kept per view (per camera), not per `Transform`, so each XR eye and split-screen camera holds its own hysteresis; the shadow pass draws the level its view picked. The standard material's `alphaMode: 'mask'` now discards below `alphaCutoff` in the shadow pass too (`shadowFragmentEntry: 'fs_shadow'`, which samples base alpha before its discard); opaque materials keep the depth-only shadow pipeline. `renderer.setJitter(fn: JitterFn | null)` (`JitterFn = (frame) => readonly [x, y]`) jitters every camera `render` draws, XR eyes included, with the renderer recording history first; `null` clears the cameras it reached; `Camera.setJitter` is unchanged (the README says how `PostStack.addJitterCamera` moves onto it). `initGpu` requests `compressionFeatures(adapter)` with its device (`GpuInitOptions.compression`, default true; a refused request retries without features), so `renderer.caps.compressed` is truthful on the shared device and `harness/render5?ktx2` no longer makes a second one. Harness `render5.html` gains `?lod&instanced` (`instancedDraws`) and `?mask` (`shadowHolePx`, `shadowDarkPx`, `litRef`, `darkRef`), with their rows in `e2e/render5.spec.ts`.
71
+ - Input, seventh pass (ws/input-7): recordings keep pointer timing, so a replayed gesture classifies as it did live. Recording format **version 3**: every `ptr` entry (`InputPointerEntry`) gains an eighth field, `time`, ms since the recorder was made on its clock (the input-6 `eventTime` stamp, to 0.01 ms). `createInputPlayback` replays timed entries at their recorded offsets on its clock, so a tap, long-press, double-tap and drag replay identically at any tick spacing; version 2 recordings (seven-field entries) and version 1 still parse and replay on the tick spacing. New: `InputRecorderOptions.now`, `InputPlaybackOptions.now` (default the linked gestures' clock), `Gestures.now` (public, read-only), `PointerRecord.age` (ms since the event happened; consumers time events as `now() - age`), `PointerDispatcher.replayAge`, and an optional trailing `age` on `Input.replayPointer`. `parseRecording` accepts versions 1 to 3.
72
+ - Post 5 (ws/post-5): TAA reprojects by the velocity target. `TaaSettings.reprojection: TaaReprojection` (`'velocity'` default, `'camera'`), `depthReject` (0.1) and `velocityReject` (2 px): with `PostInputs.velocity` the resolve samples each pixel's history at `uv - velocity` (the nearest depth's in its 3×3; the camera matrix where the target is zero) and drops it where last frame's depth or velocity there disagree, from a `TAA_AUX_FORMAT` (`rgba16float`) aux pair written beside the history (`taaRejects(...)` is the CPU twin); without the target, or in `'camera'`, the ws/post-4 resolve runs unchanged. `PostStack.taaVelocityActive`; the internal `DepthPass` takes several target formats. New uniform lanes `taa2.w` and `taa3` (`POST_UNIFORM_FLOATS` 188 → 192). The duplicate camera-layout test in `src/post/post4.test.ts` is gone (`src/render/camera-layout.test.ts`, ws/render-5). `harness/post4.html?taa` gains a striped cube moving under a still camera (`ghostObject`, `ghostObjectCamera`), asserted in `e2e/post4.spec.ts`.
73
+ - Navigation, sixth pass (ws/nav-6): in `@thunderclap-engine/engine/nav`, `bakeNavMesh` no longer returns an empty mesh silently. `NavBakeOptions` needs only `cellSize`; the rest default from it (`cellHeight` = `cellSize / 2`, `agentHeight` 2, `agentRadius` = `cellSize * 2`, `agentMaxClimb` = `cellHeight * 2`, `agentMaxSlope` 45, plus the existing region, edge, simplification and `maxVertsPerPoly` defaults), through the new `resolveNavBakeOptions(opts): ResolvedNavBakeOptions` and `NAV_BAKE_DEFAULTS`. A missing `cellSize` or a non-finite or out-of-range value throws a `NavBakeError { option, value }` naming both (`maxVertsPerPoly` outside 3..6 now throws where it used to clamp). A bake whose triangles give 0 polygons warns once (`console.warn`, `[nav] bakeNavMesh: 0 polygons from N triangles …`). `pnpm bake:nav` resolves its options through the same function (its `DEFAULT_OPTIONS` is now `{ cellSize: 0.3 }`, so an options-free bake uses `cellHeight` 0.15, a 2 m agent of radius 0.6 and climb 0.3) and exits 1 naming a bad option; `scripts/bake-nav.test.mjs` joins `pnpm check:bake`, with the arpg courtyard soup as `scripts/fixtures/nav-yard.glb`. The root README `bake.config.json` example and the nav README *Baking* table document the defaults.
74
+ - core, third pass (ws/core-3): positional hashing in `@thunderclap-engine/engine/core`: `hash2(seed, x, z)` and `hash3(seed, x, y, z)` return a uint32, `hash2f` / `hash3f` a float in [0, 1). The seed is a `Seed` hashed as `createRandom` hashes it, coordinates are truncated to int32, the mix is lowbias32 per coordinate, nothing allocates; golden values and a 256×256 mean / lag-1 correlation bound are tested. `createNoise` keeps its permutation-table lattice (its goldens are unchanged). The FNV-1a seed hash no longer creates a closure per call (same values). The demos (dungeon, tactics, voxel textures and world, arena, range) and the platformer and first-person templates use `hash2f` / `hash3f` in place of their own hashes, so their speckles, voxel trees and lanterns moved; first-person's exported `hash2` from `world/terrain.ts` is gone.
75
+ - UI, sixth pass (ws/ui-6): one floating-text API and one minimap. `ui.floats(camera?, opts?)` is now a method returning the overlay's `Floats` pool (`createFloats`, made on the first call and reused; `ui.destroy()` disposes it). `ui.minimap(opts)` returns the canvas `CanvasMinimap`, which gains `worldRadius` (world units to the rim, overriding `zoom`), `MinimapMarker.icon`, a default `self` kind, `rim` (markers held on the rim at the last draw), `markerAt(id, out?)` (`MinimapMarkerPlace { x, y, outside }`, map px after the rim clamp), and `place` (per form factor, overriding `region`); the map shrinks square to fit a smaller region. **Removed (breaking):** ws/ui-4's DOM float numbers and minimap: `createFloatNumbers`, `ensureFloatStyles`, `FLOAT_CSS`, `FloatKind`, `FloatNumbers`, `FloatNumbersHost`, `FloatNumbersOptions`, `FloatSpawnOptions`, `createMinimap`, `ensureMinimapStyles`, `MINIMAP_CSS`, `Minimap`, `MinimapHost`, `MinimapMarkerKind`, `MinimapMarkerOptions`, `MinimapOptions`. Nothing outside the harness and demos called them, so there is no alias: `ui.floats.spawn(x, y, z, text, { kind })` becomes `ui.floats(camera).spawn(text, point, { color, crit })`, and `setCenter`/`setMarker` become `setPlayer`/`setMarkers`. `harness/ui4` and `e2e/ui4.spec.ts` run on the unified calls. The arpg demo adopts them: engine orb meters with a damage trail, `ui.floats` damage numbers (the killing third hit a crit) and a courtyard minimap (braziers and ghouls as marker kinds; a tap walks the hero there over the navmesh path). The arpg demo's last `Math.random` (damage-number jitter) went with its hand pool, and the third-person template's enemies default to a seeded `createRandom` (`seeded(ENEMY_SEED)`) instead of `Math.random`.
76
+ - Render 5 (ws/render-5): compressed textures, per-instance LOD and the standard material's dissolve. `uploadKtx2(renderer, ktx2, { srgb?, label? })` (`UploadKtx2Options`, `CompressedUploadTarget`) uploads a parsed KTX2 file in its own block format, every mip at its block-aligned `bytesPerRow` (`ktx2LevelLayout` / `Ktx2LevelLayout`, `blockInfo` / `BlockInfo`), and throws `CompressedTextureError` (`format`, `family`, `caps`) for a format the device lacks, one WebGPU has no twin for, or a Basis / supercompressed payload (no transcoder is bundled). `renderer.caps: RendererCaps` (`caps.compressed.bc` / `etc2` / `astc`, `rendererCaps(device)`), `compressionFeatures(adapter)` and `COMPRESSION_FEATURES` (`CompressionFamily`) for `requestDevice`; `TextureDesc.mipLevelCount`. `frame.mesh` takes a `MeshLod` (`{ meshes, thresholdsPx }`, geom's `LodChain` with its meshes; `isMeshLod`): a level per instance from its projected sphere (`lodSizePx`, `pickLodLevel`), `camera.lodBias`, `renderer.lodHysteresis` (`DEFAULT_LOD_HYSTERESIS` 0.1), opaque draws grouped by mesh then material so each non-empty level is one draw, `stats.lodCounts`. `StandardMaterialDesc.dissolve: StandardDissolve` (`amount`, `edgeColor`, `edgeWidth`, `noise`; flag 8, `STANDARD_DISSOLVE_OFFSET` 60, `STANDARD_UNIFORM_FLOATS` is 68) discards by value noise with an emissive edge, and its shadow casters discard the same texels through `MaterialDesc.shadowFragmentEntry` (`material.shadowFragment`, `shadowPipelineFor`). Fix: `EngineCamera`'s pad after `view_index` is three `u32`s, not a `vec3u` that shifted `inv_view_proj`, `prev_view_proj`, `prev_view` and `jitter` by four floats in WGSL (same change as ws/post-4); `src/render/camera-layout.test.ts` checks the WGSL offsets against the `CAMERA_*_OFFSET` constants. `occlusionStrength`, `inv_view_proj` and the `'multiply'` blend were already on main. Fake GPU: `createFakeDevice(limits, features)`, `textureWrites[].mipLevel` / `rowsPerImage`. Page `harness/render5.html` (`?ktx2`, `?lod`, `?dissolve`, `?still`) with `e2e/render5.spec.ts`.
77
+ - Input, sixth pass (ws/input-6): gestures classify by when the event happened, not when it was handled. Attached `Gestures` time every down, move and up by `event.timeStamp`, moved onto their `now` clock by the event's age (`eventTime(timeStamp, now)` in `input/pointer-dispatch.ts`), so a tap on a stalled main thread is no longer a long-press and late taps are a double tap only if they happened within `doubleTapMs`; the long-press timer in `update()` still reads `now` but measures from the down's stamp, and a late up decides its race with the timer by its own stamp. `Look.onTap` times its press the same way. No API change.
78
+ - Audio 6 (ws/audio-6): two duckers on one bus combine instead of fighting. `AudioEngine.setBusScale(bus, scale, timeConstant?, key = 'default')` keeps one scale per key and the bus's effective scale is the product of its keys; `clearBusScale(bus, key, timeConstant?)` removes one; `busScale(bus, key?)` reads one key (1 when unset) or the product. Existing keyless callers share `'default'` and are unchanged. `createDucker` uses its own key (`Ducker.key`, `'ducker:<n>'`) and `dispose()` clears only that key instead of writing the bus back to 1.
79
+ - Audio 5 (ws/audio-5): ducking never fights a pause. `AudioEngine.setBusScale(bus, scale, timeConstant?)` / `busScale(bus)` multiply a bus's settings gain, and the engine owns every bus write: while `pause` holds the bus or `inBackground` is true the scale is only stored, `unpause` and `background(false)` ramp to `busGain(settings) × scale`, and a bus built after a pause starts silent. `createDucker` now drives `setBusScale` instead of writing the bus node, so a line spoken while paused no longer raises music (was 0 → 0.36, and the ducker's first write could land after a pause and restore the full gain), and resume lands on the ducked gain. Background now holds bus writes (`setVolume` while hidden lands on return). `Voice.doppler` (the Doppler rate multiplier, 1 on voices the mixer did not start) is on the `Voice` interface; external `Voice` implementations need the member.
80
+ - fx, fourth pass (ws/fx-4). Seeded fx: no `Math.random` left in `src/fx` (a test greps for it); `ParticleSystemOptions.random`, `system.random`, `emit(params, count, random?)`, `new ParticleEmitter(system, params, rate?, random?)` and `emitter.random`; `createEffect(renderer, desc, { textures?, random? })` (`CreateEffectOptions`; the effect forks its `random` once per system by name, so the same seed writes identical particle buffers); `DecalsOptions.random` for `DecalDesc.rotation: 'random'` and `tile: 'random'`; without one, each system, effect and decal batch forks a module root seeded `FX_DEFAULT_SEED` by kind and creation order. Decals on the depth hook: `DecalsOptions.readsDepth` (default true for `box`/`auto`, false for `quad`; live) makes the box decals read this frame's opaque depth through `afterOpaque`, so a decal on a moving surface no longer trails it by a frame; `readsDepth: false`, or a target that cannot copy its depth, keeps the previous-frame copy as the documented fallback. Text plates: `BillboardDesc.text` / `textStyle`, `handle.text`, `handle.setText(text)` (re-rasterises into the plate's own cell when it fits, grows the cell and the quad's width otherwise; no texture is allocated), `Billboards.measure`; `TextAtlas.cell`, `rewrite`, `release`, `cellSize` (owned, uncached cells, widths rounded up to 32 px). Harness `fx4.html` (`?seed=N`, `?decalLag`, `&copy`) with `e2e/fx4.spec.ts`.
81
+ - Navigation, fifth pass (ws/nav-5): in `@thunderclap-engine/engine/nav`, mesh reachability: `reachable(mesh, from, { budget, areaCost?, areaMask?, maxPolys?, snapRadius? }, out?)` (an overload of `reachable`) is a budget-bounded Dijkstra over the polygons with the `findMeshPath` cost model into a reused `MeshReach` (`count`, `polys` in cost order, `costs` to each centre, `parents`, `has`, `pathTo`, `forEach`, `version` / `stale`; `createMeshReach`), allocation-free after warm-up, a 10k-polygon mesh in 1.3 ms best; `reachableOutline(reach, out?)` returns the range's boundary as closed loops of polygon edges (`ReachOutline`, outer loops counter-clockwise, holes clockwise; `createReachOutline`). `NavPath3.cost` is the priced cost of the route found, and `FindMeshPathOptions.heuristicWeight` (default 1; 0 is a Dijkstra that agrees with the reach exactly). `loadNavMesh(bytes)` imports a shipped bake and validates it (length, vertices per polygon, finite vertices, vertex and neighbour indices), throwing a named error on a corrupt file. `pnpm bake:nav <input> <output> [--options nav.json]` (`scripts/bake-nav.mjs`) bakes one glTF or geometry JSON into a format-2 navmesh. New harness `nav5.html` (`?range`: a tactics board with the unit's range filled and outlined) and `e2e/nav5.spec.ts`.
82
+ - UI, fifth pass (ws/ui-5): `ui.meter({ shape: 'orb' })` (`createOrbMeter`, `ORB_CSS`, `ensureOrbStyles`) is a round fill gauge with a meniscus and gloss; `MeterOptions` gains `shape: MeterShape` (`'bar' | 'orb'`), `trail: boolean | MeterTrailOptions` (`{ delay, duration }`, 400 and 600 ms: a damage band in the new `colorMeterTrail` token that holds, then drains; off under reduced motion), `readout: MeterReadout` (`'value' | 'percent' | 'none'`), `color`, `size`, `reducedMotion` and `now`, and `Meter` gains `shape`, `trail` and `setColor`; a bar without them is unchanged. `createFloats(host, camera?, opts?)` returns `Floats` (`spawn(text, world, { color, size, rise, life, crit, stack })`, `update`, `clear`, `dispose`, `live`, `pooled`, `max`): pooled labels through `anchors.project`, hidden behind the camera, fanned out when stacked, a scale pop for a crit (`FloatsHost`, `FloatsOptions`, `FloatTextOptions`, `FLOATS_CSS`, `ensureFloatsStyles`). `createCanvasMinimap(host, { size, world, rotate, shape, center, zoom, background, kinds, onPick })` returns `CanvasMinimap` (`setMarkers`, `setPlayer(x, z, yaw)`, `setBackground`, `zoom`, `toMap`, `toWorld`, `flush`, `redraws`, `remove`): its own canvas, round or square, north- or player-up, redrawn only on change, marker shapes per kind, `onPick(worldX, worldZ)` (`CanvasMinimapHost`, `CanvasMinimapOptions`, `MinimapWorld`, `MinimapRotate`, `MinimapShape`, `MinimapMarker`, `MinimapMarkerShape`, `MinimapKindStyle`, `MinimapBackground`, `CANVAS_MINIMAP_CSS`, `ensureCanvasMinimapStyles`). ws/ui-4's `ui.floats` and `ui.minimap` are unchanged. `harness/ui5.html` (`?orb`, `?floats`, `?minimap`, `?still`) and `e2e/ui5.spec.ts`.
83
+ - Geometry, third pass (ws/geom-3): in `@thunderclap-engine/engine/geom`, `cloneGeometry(g)` (deep copy, same layout); `transformGeometry` and `mergeGeometry` work on any layout whose position sits at float 0, finding the normal (`@location(10)`) and tangent (`@location(13)`) lanes from its attributes (tangent xyz rotated, w kept, negated under a mirror; skinned geometry can now be transformed); `simplifyGeometry(g, { targetRatio, targetTriangles, preserveBorders, preserveUvSeams, errorLimit })` → `SimplifyResult { geometry, error }` (deterministic quadric-error half-edge collapse over epsilon-welded positions) and `createLodChain(g, ratios)`; `selectLod(chain | count, boundsRadius, distance, fovY, viewportHeight, thresholdsPx)`, `projectedSizePx` and the storage type `LodChain` (the renderer does not pick levels); `convexHull(points | geometry)` (quickhull, flat-shaded, outward) and `collisionProxy(g, { maxTriangles, kind: 'hull' | 'simplified' })`; `splitGeometry(g, 'island' | (tri) => group)` and `sliceGeometry(g, { nx, ny, nz, d }, { cap })` → `{ above, below }` with capped, closed halves (`SlicePlane`, `SliceOptions`, `SliceResult`); a positions-only collider source `PositionsSource { positions, indices, transform?, layer?, userData? }` beside `GeometryMeshSource`; `slideTiles(tiles, box, dx, dy, out, { oneWay, stepUp, movers })` (y-down AABB vs a `GridSource` or `TileQuery`, `TILE_EMPTY` / `TILE_SOLID` / `TILE_ONE_WAY`, no seam snagging, no tunnelling, allocation-free) with `createTileSlideResult` and `carryBy` for moving platforms (`TileBox`, `TileMover`, `TileKind`, `TileSlideOptions`, `TileSlideResult`). Harness `geom3.html` (`?lod ?hull ?slice ?tiles ?still`) and `e2e/geom3.spec.ts`.
84
+ - UI, fourth pass (ws/ui-4): `Anchors.frame` (`AnchorFrame { width, height, rect }`, read live from the surface) and `anchors.project(x, y, z, out)` (one world point into frame CSS px, no allocation); world-point tooltips and the float numbers project through it, and `TooltipHost.frame` is deprecated (read only before the anchors exist). `PanelStack.padOwner` (`PadOwner`, `'stack' | 'game'`) and `stack.onPadOwnerChange(fn)` (once per transition, returns the remover); `PadNav` and `ui.cursor` subscribe instead of polling `hasModal` and reset their latch on each transition (a `CursorHost` without `onPadOwnerChange` is still polled), `PadNav.dispose()`, `cursor.latched`, `ui.padOwner` (so `ctx.ui.padOwner` in `createGame`). `WorldPanel.attach(input)` / `detach()` (`WorldPanelInput`): registers the tester and polls `router.pointers` then `update(router.camera)` on `input.onUpdated`; `harness/world-panel.ts` uses it. New widgets: `createFloatNumbers` / `ui.floats` (`FloatNumbers`, `FloatNumbersOptions { max, duration, rise, style }`, `spawn(x, y, z, text, { kind: FloatKind })`, `update(dt)`, `clear()`, pooled DOM nodes, transform-only), `createRing` / `ui.ring` (`Ring`, `RingOptions`, `role="progressbar"`), `createCompass` / `ui.compass` (`Compass`, `CompassOptions`, `CompassMarker`, `bearingOffset`), `createMinimap` / `ui.minimap` (`Minimap`, `MinimapOptions`, `MinimapMarkerOptions`, `MinimapMarkerKind`, `MinimapMarkerPlace`, rim clamp with `tc-minimap-outside`, `rotate`), each with its stylesheet (`FLOAT_CSS`, `WIDGETS3_CSS`, `MINIMAP_CSS`) injected on first use. Harness `harness/ui4.html` (`?floats ?ring ?compass ?minimap ?modal ?still`), spec `e2e/ui4.spec.ts`.
85
+ - Assets, third pass (ws/assets-3). `parseGltf` reads `KHR_mesh_quantization` (integer attributes dequantized, normals and tangents renormalized), `EXT_meshopt_compression` (views decoded at parse time by the sync `decodeMeshopt(target, count, stride, source, mode, filter?)`, `MeshoptMode`, `MeshoptFilter`, `MeshoptError`; fallback buffers may be empty), `KHR_lights_punctual` (`doc.lights: GltfLight[]`), cameras (`doc.cameras: GltfCamera[]` with a `view` matrix), `KHR_materials_variants` (`doc.variants`, `GltfPrimitive.variants: GltfVariantMapping[]`) and the `KHR_materials_clearcoat`, `_sheen`, `_transmission`, `_volume`, `_ior`, `_specular` records into `GltfMaterial.extensions` (`GltfMaterialExtensions` and its record types; listed in `doc.unsupportedExtensions` with `KHR_texture_basisu`, whose `GltfTexture.basisuSource` is kept while the PNG / JPEG fallback loads). `loadGltf` gains `lightScale` and `scene.lights: GltfSceneLights` (render light records), `scene.cameras`, `applyGltfCamera(camera, c, aspect)`, and the cosmetic path `scene.setVariant(name | null)` / `scene.variant` swapping materials in place. `parseKtx2(bytes)` → `Ktx2Texture` (`isKtx2`, `ktx2ToWebGpuFormat`, `Ktx2Level`, `Ktx2DataFormat`, `Ktx2Supercompression`, `Ktx2Error`) reads the container; upload waits on the render module. `createAssetCache({ budgetBytes, onEvict })` → `AssetCache` (`acquire` deduplicated and reference-counted, `release`, `pin`, `unpin`, `has`, `refs`, `dispose`, `stats(): AssetCacheStats`), LRU under the budget. Fix: a normalized position accessor's raw-integer `min` / `max` no longer become the primitive's bounds. `harness/gltf3.html` (`?quant`, `?meshopt`, `?lights`, `?variants`, `?cache`, `?still`) with `e2e/gltf3.spec.ts` on the matrix.
86
+ - Render 4 (ws/render-4): camera jitter and history, aux targets, index ranges, lazy pipelines and the standard material's per-map settings. `Camera.setJitter(x, y)` / `clearJitter()` / `jitter` offset the projection (applied only while `renderer.post.wantsJitter`, never through `ortho2d`); `camera.previousViewProj` / `previousView` hold last frame's unjittered matrices (`recordHistory`, called by the renderer at the start of each view), in WGSL as `camera.prev_view_proj` / `prev_view` / `jitter` (`CAMERA_PREV_VIEW_PROJ_OFFSET` 76, `CAMERA_PREV_VIEW_OFFSET` 92, `CAMERA_JITTER_OFFSET` 108); `PostInputs.view: PostView` (`viewProj`, `invViewProj`, `previousViewProj`, `previousView`, `jitter`). `SceneTargetDesc.aux: SceneTargetAux` (`{ roughMetal?, velocity? }`, `RendererOptions.aux` for the default target) adds `rg8unorm` (`ROUGH_METAL_FORMAT`) and `rg16float` (`VELOCITY_FORMAT`) attachments, `target.roughMetal` / `velocity` / `auxKey`, `PostInputs.roughMetal` / `velocity`; materials with `MaterialDesc.aux` write them through the prelude's `engine_write_aux` and `engine_velocity` (the standard material does), every other material has them masked off and records the same bytes as before. `createInstanceList(mesh, material, capacity, { velocity: true })` (`InstanceListOptions`) keeps a previous-matrix lane (`prevMatrices`, `prevBuffer`; `prevLaneLocations` / `prevLaneBufferLayout` / `PreludeVariant` / `PLAIN_VARIANT` for a `Drawable`) and `SkinPaletteDesc.history` a previous pose (`engine_skin_previous`, `previousOffsetOf`). `InstanceParams.range: MeshRange` draws an index sub-range (batched per mesh, material, range), `MeshDesc.ranges` / `mesh.ranges` store named ones. Materials build every pipeline on first use (the 2D one too); the prelude's `engine_local_tangent(v)` returns the file tangent under `VertexLayout.wgslTangent` (`VERTEX_LAYOUT_3D_TANGENT`), which the standard material's normal mapping uses, derivatives otherwise. `VERTEX_LAYOUT_3D_UV2` (`VERTEX3D_UV2_FLOATS` 14, `VERTEX3D_UV2_STRIDE` 56, `VSOut.uv1`, `wgslUv1`); `StandardMaterialDesc.maps` (`StandardMapSlot` → `StandardMapDesc`: per-map `uvTransform`, `texCoord: 0 | 1`), `occlusionStrength`, `name`, `depthTest` (`STANDARD_UNIFORM_FLOATS` is 60), wired from glTF by `gltf-load`. `MaterialDesc.name` / `depthTest` / `aux`, `Material.name` / `depthTest`. `LoadTextureArrayOptions.filter` replaces `mipFilter` (deprecated, still read). The instance flags table says which bits are reserved. Page `harness/render4.html` (`?jitter`, `?aux`, `?ranges`, `?depth-test-off`) with `e2e/render4.spec.ts`, `harness/pbr.html?tangents` with its row in `e2e/pbr.spec.ts`. The post stack does not read the new inputs yet (ws/post-4).
87
+ - Post 4 (ws/post-4): the post stack reads the frame from `PostInputs` only (no `renderer.camera` in src/post). TAA jitters the camera's projection: `PostStack.wantsJitter` (true while `taa.enabled` and `quality.taa`), `addJitterCamera(camera)` / `removeJitterCamera(camera)` register the cameras the stack steps through `taa.jitter(i)` (Halton (2, 3)) and clears when TAA stops; the reprojection comes from `view.previousViewProj` / `invViewProj` and the resolve un-jitters by `view.jitter`. The sample-UV jitter is removed. SSR reads per-surface roughness and metalness from `PostInputs.roughMetal` when present (`ssrSurfaceWeight(metalness, cosView)`, Schlick F0 0.04). `MotionBlurSettings.mode: MotionBlurMode` (`'object'` default, `'camera'`): object mode blurs along `PostInputs.velocity` and falls back to the camera reprojection where it is zero. AO and SSR handle ortho projections (`PostDepthInfo.ortho`, `aoProjectedRadius(radius, projScale, z, ortho)`). `clonePostSettings(s)` is exported; blend snap keys are qualified by section and include `lut.texture` and `motionBlur.mode`. New uniform lanes `quality.z`, `taa2.xy`, `ssr2.w`, `motionBlur.w` (`POST_UNIFORM_FLOATS` unchanged). Page `harness/post4.html` (`?taa`, `?ssr`, `?blur`, `?ortho`, `?still`) with `e2e/post4.spec.ts`.
88
+ - Templates (ws/template-sweep): the four templates track the engine. `templates/platformer` drops `input/touch.ts`, `world/follow.ts` and their tests for `createTouchZones` (three zones in `ctx.ts`: the left-half stick, a jump and a dash region, bound to the same `left`/`right`/`jump`/`dash` actions, so the level scene reads `input.axis('left', 'right')` and `input.pressed`) and `createFollow2D` (the same 180×240 dead zone, 260 px look-ahead and world clamp), and its `world/parallax.ts` builds the three patterned tile layers for the engine's `createParallax` (same factors, periods and draw order); 2917 → 2608 lines of TypeScript (tests included). `templates/third-person` draws the character with `createStandardMaterial({ skin: palette })` (its `SKINNED_PBR_WGSL` is gone), makes its `Look` in `mode: 'orbit'` (the page's own drag listeners are gone) and drops the depth/cull lines after `setPerspective` (it sets them); the runtime stones fallback stays. `templates/first-person` makes its `Look` with `lockOnFirstClick: true` (its click handler is gone) and drops the same depth/cull lines. `templates/puzzle`: no drift. Each `main.ts` publishes `window.__template = { scene, frames, ...state }` after every rendered frame, and the new `e2e/templates.spec.ts` boots every template's `index.html` on its own Vite dev server (`TEMPLATE_PORTS` in `e2e/ports.ts`, four `webServer` entries in `playwright.config.ts`) across the device matrix: no console errors for three seconds, `frames > 10`, one key press changes the scene or a state field. Each template's `CLAUDE.md` names the engine call.
89
+ - Particles, third pass (ws/fx-3): the extended kernel (world space, or 2D with `extended: true`; the pixel-pinned 2D kernel is untouched). Sprite sheets: `ParticleSystemOptions.texture` + `atlas`, `EmitParams.tile` (one tile or `[from, to]`), `tileRate`, `rotation`, `spin`, `alignToVelocity`. Curves: `sizeCurve` / `colorCurve` (2..`MAX_CURVE_STOPS` stops, `evaluateCurve` as the CPU reference). Forces: the live `ParticleSystem.forces` array of `ParticleForce` (`attract`, `vortex`, `turbulence`, `drag`; at most `MAX_FORCES`), `applyForces` as the CPU reference. Depth collision: `collide: { bounce, friction, thickness? }` with `readsDepth` (`PARTICLE_COLLIDE_WGSL`), `countBelowY` → `below`. Sub-emission: `onDeath { params, count }`, `onLife { params, rate }`, `inherit`, one level, budgeted by `maxSpawnsPerFrame`, `maxSubEmitters`. Mesh emission: `EmitShape { kind: 'mesh' }`, `buildEmitSurface(geometry)` → `EmitSurface`, `sampleEmitSurface`. Effects: `EffectDesc` JSON through `validateEffectDesc` / `createEffect` → `Effect` (`play`, `attach`, `stop`, `update`, `draw`, pooled `EffectInstance`s), with `HIT_SPARK_EFFECT`, `SMOKE_PUFF_EFFECT`, `MAGIC_AURA_EFFECT` bundled. Kernel constants and sources are exported (`FX_PARTICLE_FLOATS`, `FX_SPAWN_FLOATS`, `FX_SIM_FLOATS`, `PARTICLE_EXT_*_WGSL`, `PARTICLE_WORLD_*_WGSL`, `PARTICLE_COUNT_WGSL`). World particles are now 160 bytes; `ParticleSystem.alive` counting dispatches advance + update + count per frame. Harness `fx3.html` (`?sheet`, `?forces`, `?collide`, `?sub`, `?effect`, `?still`), `e2e/fx3.spec.ts`.
90
+ - core, second pass (ws/core-2): `createRandom(seed)` (xoshiro128**, number or string `Seed`, `int` / `float` / `bool` / `pick` / `weighted` / `shuffle` / `gaussian` / `unitVec2` / `unitVec3`, `fork(name)` keyed by name, `state` for saves), `createNoise(seed)` (`perlin2/3`, `simplex2/3`, `value2/3` in [-1, 1], `fbm(fn, x, y, z?, FbmOptions)` normalised), `createScheduler()` (`after`, `every` with `count`, `cancel`, `remaining`, `pause`, `clear`, named `cooldown(name, seconds)` with `use` / `ready` / `progress`; heap over reused slots, no steady-state allocation), `createPool({ create, reset, initial, max })` (`acquire`, `release`, `forEach`, `drain`). `Scene.preload?(ctx)`: `SceneStack.push` / `replace` now return `Promise<void>` (resolving after enter), keep the old top running and queue later ops while a preload is pending, leave the stack unchanged on rejection; `stack.transitioning` and `stack.onTransition(fn)` (`TransitionPhase` `'preload' | 'enter' | 'exit'`). Additive: callers that ignore the promise behave as before. Harness `core2.html` (`?random`, `?noise`, `?timer`, `?preload`, `?still`) and e2e/core2.spec.ts.
91
+ - Input, fifth pass (ws/input-5). Action contexts: `input.pushContext(name, { bindings?, pass? })` → remover, `popContext(name)`, `context`, `contextStack()`, `onContextChange(fn)` and the `'context'` event (`ContextOptions`, `ContextPass` `'none' | 'unbound' | 'all'`); only actions whose effective bindings change are rebuilt, and an input held across the change is latched until it lifts. `bindings(context?)` and `rebind(action, list, context?)` act on the top context by default (`null` the base map); glyphs follow the stack. Input buffer: `input.buffered(action, windowMs)` and `input.consume(action)`, counted in ticks (`ceil(windowMs / tickMs)`). Sticks: `input.stick(negX, posX, negY, posY, out, opts?)`, `input.stickAngle(out)` and `shapeStick(x, y, out, opts?)` (`StickOptions` `deadzone`, `shape`, `curve`, `clamp`; `StickShape` `'radial' | 'axial' | 'scaledRadial'`); `Look`'s touch and pad sticks use it (same numbers). Gyro aim: `createGyroSource({ sensitivity, invertY, smoothing, env })` (`GyroSource`, `GyroSourceOptions`, `GyroEnv`, `GyroMotionEvent`, `GyroPermission`), `LookOptions.gyro` / `look.gyro`. `look.onTap(fn)` (`LookTapListener`, `LookOptions.tap: LookTapOptions` `maxMove` 10, `maxMs` 450), in the pointer transform's units. Recordings are version 2 (version 1 still parses and plays): `InputTickEvents.ptr` (`InputPointerEntry`, `InputPointerPhase`) records every pointer the dispatcher delivered and playback dispatches them on the pointer target (`input.replayPointer`), so touch zones and a second thumb replay; `cx` and `cs` note context changes and consumes. `InputFeedHook` gains optional `pointer`, `cancelPointers`, `context` and `consume`; `InputEvents.context`. `harness/input5.html` (`?contexts`, `?buffer`, `?stick`, `?gyro`, `?tap`, `?replay`, `&still`) and `e2e/input5.spec.ts`.
92
+ - nav (ws/nav-4): the mesh grows for runtime games. Area types: every `NavMesh` polygon carries an area byte (`mesh.area`), stamped at bake (`NavBakeAreaOptions.areas`, `AreaStamp` of an `AreaBox` or convex `AreaPolygon`) or at edit time (`markArea`); `findMeshPath`, `createFlowField3` and queued requests take `AreaQueryOptions` (`areaCost` multipliers from `createAreaCosts`, an `areaMask` from `createAreaMask`; a leg is priced by the polygon it crosses), `areaAllowed`, `OBSTACLE_AREA` (255, never walked). Dynamic obstacles: `createObstacleSet(mesh | grid, { maxObstacles, layer? })` keeps discs and boxes (`add`, `move`, `remove`, `get`) and `apply()` stamps what moved onto the polygons they overlap (areas restored when they leave, `mesh.version` bumped only on change) or onto a grid cost `layer` for an `edgeCost` hook. Path queue: `createPathQueue(mesh | grid, { maxQueries, iterationsPerUpdate })` turns requests into tickets and `update()` spends a fixed pop budget per frame, first in first out with resumption, restarting a ticket whose target changed version (`status`, `cancel`, `pending`, `finished`, `onDone`; `smooth` for a grid); `MeshSearch` is the resumable mesh A* behind it and `findMeshPath`. Corridors and the crowd on the mesh: `createCorridor(mesh)` keeps the polygon list a path crossed (`NavPath3.polys`, `reservePolys3`, `pullString`) and `advance(x, z, out)` funnels what remains each frame; `Crowd.attach(mesh, { queue?, arriveRadius?, slowRadius?, maxWaypoints? })`, `setTarget(index, x, y, z)` (sync or through the queue), `obstacles(set)` (static ORCA lines), agents gain `y`, `poly`, `corridor`, `targetReached`, the crowd `replans`. Mesh flow field: `createFlowField3(mesh, goals, opts?)` runs one Dijkstra over the polygons (`direction3`, `distanceAt`, `distances`, `next`, `portal`, `reached`, `stale`, `update()`). From nav-3's wishes: `FlowField.stepCosts` and a `FlowDirection` out with the step's `cost`; `hasLineOfWalkable` and `smoothPath` honour the grid's `edgeCost` hook (`LineOfWalkableOptions`), so a one-way ledge is never cut across. The navmesh serialisation is format 2 (the area bytes); format 1 still imports. Page `harness/nav4.html` (`?crowd` with `&carve`, `?queue`, `?areas`, `?flow3`, `?still`) with `e2e/nav4.spec.ts` on every row.
93
+ - Animation, fourth pass (ws/anim-4). `createAnimGraph` blend states take `AnimGraphBlend2D { params: [x, y]; points: AnimGraphBlendPoint2D[] }` (gradient-band weights, the query clamped to the samples' hull; 1D is `AnimGraphBlend1D`, unchanged) and `AnimGraphDesc.match: AnimGraphMatchDesc` with `AnimGraphTransition.matchPose` / `go(state, fade, { matchPose })` starting the target clip at its nearest frame (`buildPoseFeatures`, `findBestFrame`, `rootSpacePositions`, `PoseFeatures`, `PoseMatch`). `createAimOffset` / `applyAimOffset` (`AimOffset`, `AimOffsetDesc`, `AimOffsetOptions`) spread a clamped yaw and pitch over a joint chain; `createFootIk` / `applyFootIk` (`FootIk`, `FootIkDesc`, `FootIkFootDesc`, `FootIkProbe`) plant feet on a probed ground and sink the pelvis; `createSpringChain` / `updateSpringChain` (`SpringChain`, `SpringChainDesc`, `SpringSphere`, `SpringCapsule`, `SpringCollider`) simulate tails and capes with limits, colliders and teleport handling; `compressClip` (`CompressClipOptions`, `CompressedClip`, `clipBytes`) drops keys a linear fit reproduces and `quantizeClip` (`QuantizeClipOptions`, `packQuat`, `unpackQuat`) packs rotations smallest-three into `AnimationChannel.packed`, which `sampleClip` reads directly. `harness/anim4.html` (`?blend2d`, `?aim`, `?foot`, `?spring`, `?match`) with `e2e/anim4.spec.ts` on the matrix.
94
+ - audio (ws/audio-4): occlusion (`audio.occlusion = { probe, interval?, openCutoff?, closedCutoff?, closedGain?, budget? }`; a pooled lowpass per probed spatial voice, round-robin under a budget of 8 probes per `update`, `SpatialOptions.occlude: false` opts out, `Voice.occlusion` reads the answer), Doppler (`SpatialOptions.doppler` / `maxDopplerSpeed`, velocities differenced from `setPosition` / `follow` and listener calls per `update(dt)`, teleports reset, `SPEED_OF_SOUND`), `Voice.filter(kind | null, { cutoff, q, ramp })` with a pooled biquad, sprites (`audio.defineSprites(name, { buffer | source, sprites, loopPoints? })`, `play('name#clip')` / `loop`, `SpriteSidecar` + `spriteSheetFrom` through bundle `json` + `audio` entries), streamed music (`music.play(name, { stream: true })` on a `Stem.url` through `MediaElementAudioSourceNode`, the grid following the element clock, buffer fallback), `Voice.sendCount`, `createVoiceAdapter(audio, { source, bus, onEnd })`, `audio.background(hidden)` / `inBackground` (called by `attach({ pauseWhenHidden })` and `createGame`'s host lifecycle), `Transform.changed` with `Voice.follow` skipping unchanged targets, `harness/audio4.html` + `e2e/audio4.spec.ts`.
95
+ - Bake script (ws/scripts-bake): `pnpm bake <config>` (`scripts/bake.mjs`, no runtime dependency, the engine's TypeScript run through `module.registerHooks`) reads a `bake.config.json` (`out`, `nav[]`, `colliders[]`, each `{ name, sources, options }`), loads `.glb`, `.gltf` and geometry JSON sources through `parseGltf`, runs `bakeNavMesh` / `createStaticCollider` and writes `<out>/<name>.navm`, `<out>/<name>.coll` and `bake-manifest.json` (`version`, `bakedAt`, `entries[{ name, kind, sources, sha256, bytes, ms }]`); `--check` exits 1 on a stale entry, `--watch` rebakes on change, `--verbose` prints sizes and times; the output is byte-identical between runs. `scripts/bake.test.mjs` (`pnpm check:bake`, in `pnpm check`) bakes `scripts/fixtures/`. In `assets`: `readBakeManifest(json)` with `BakeManifest`, `BakeManifestEntry`, `BakeKind`, `BAKE_MANIFEST_VERSION`. In `geom`: `raycastReg(c, ox, oy, oz, dx, dy, dz, maxDist, out, layerMask?)`, the register form of `raycast` (which now calls it, as do `raycast2` and `slide`'s ground probe); the README marks `transformGeometry`, `flipWinding`, `projectUv`, `fitBounds` and `recenter` as in place with a copy-first recipe. `templates/third-person` gains `pnpm bake` with `bake.config.json`, `assets/stones.json` and `world/stones.ts`: the stones' collider is loaded from `public/baked/stones.coll` when present (built at runtime otherwise) and blocks a swing through a stone. `templates/first-person` has nothing file-based to bake (its terrain is generated in a Worker) and is unchanged.
96
+ - UI, third pass (ws/ui-3): `ui.tooltip(target, content, { delay, placement, arrow, interactive })` shares one `role="tooltip"` element per overlay on the `toasts` layer between element targets (hover after the delay, focus, `aria-describedby`; leave, blur and Escape hide) and world points or `AnchorHandle`s (riding `ui.anchors`, re-placed by `ui.updateAnchors`), placed by the pure `placeTooltip` (flips at an edge, clamps, arrow offset); `ui.tooltips` (`element`, `active`). `ui.cursor({ mode: 'grid' | 'targets', step, at, targets, onSelect, onMove, repeat })` is a pad and keyboard world cursor on the `hud` layer: grid mode steps with the `PadNav` latch and hold-to-repeat, targets mode snaps by `pickTarget` (angle-weighted), A/Enter selects, and it yields to a modal panel; `padnav.ts` exports `readPad(pads, threshold, out?)`, `defaultPads`, `PadControl`, `PadDir` (`PadNav.read` now calls `readPad`). `ui.loading({ title, tip, progress })` is a non-dismissible modal panel with a `bar` and rotating tips, root `aria-busy`, keys swallowed, `set`, `tip`, `done()` resolving after the fade. `ui.notify({ title, body, icon, tone, actions, timeout })` stacks cards top-right in a polite live region (`ui.notifications`: `count`, `dismissAll`, `focus`; pad through `menuNav`). `h(tag, { style: 'cssText' })` takes a string. Fix: a native `select` on a frame layer no longer draws its theme tokens at `--tc-dpx` squared (`BASE_CSS` divides them by the zoom). `harness/ui.html?tooltip|cursor|loading|notify` and `e2e/ui.spec.ts` rows on the matrix.
97
+ - `src/voxel` (`@thunderclap-engine/engine/voxel`, ws/voxel-render): block grids into chunk meshes and onto the screen. `createVoxelGrid` (block ids, per-chunk dirty flags, `serialize` for a Worker, `subscribe`), `meshChunk` (greedy quads per face with baked four-neighbour ambient occlusion and the darker-diagonal split, `VERTEX_LAYOUT_3D` with the texture layer in colour alpha and AO in rgb, uv in block units), `createVoxelMesher` and `installVoxelMesherWorker` (the Worker bridge: a grid mirror, transferred buffers straight into `mesh.update`, coalesced requests; in-thread without a Worker), `createVoxelChunks` (one `Mesh` per non-empty chunk sized with slack on its first upload so edits rewrite it in place and `growths` stays 0, frustum-culled by sphere, `stats`), `raycastVoxels` (cell DDA picking: hit cell, face normal, place cell) and the `VOXEL_WGSL` prelude (`voxel_layer`, `voxel_ao`, `voxel_uv_wrap`). With it: the `'nearest-mip-repeat'` sampler kind (`render`), `WaterOptions.origin` (the plane and its height query placed in the world, `water-wgsl` reads the origin from `misc.w`) and `SkyOptions.ambientFloor` (the night floor of the dome, live, in `stars.w`). Page `harness/voxel.html` (a 4×2×4-chunk hill world meshed in `harness/voxel-worker.ts`, pointer-lock look, a click breaks, the secondary button or `E` places, `?still`) with `e2e/voxel.spec.ts` on every row.
98
+ - Input, fourth pass (ws/input-4): one pointer path. `createPointerDispatcher(target, { transform, hoverWhileClaimed })` (`PointerDispatcher`, `PointerConsumer`, `PointerRecord`, `PointerRecordDevice`, `POINTER_PRIORITY`) attaches one set of pointer, blur and visibility listeners, parses `pointerType` once, captures once and fans records out to consumers by priority with `claim(id, owner)` / `release(id)`; `Input.attach` builds it (`input.dispatcher`, `InputEvents.attach`/`detach`), and `Gestures`, `Look` and the router's `ScreenPointers` consume from it (`GestureAttachOptions.dispatcher`, `look.attach(el, dispatcher?)`, `router.attach(el, dispatcher?)`, `PointerSource.attach(target, dispatcher?)`) or from a private one when attached alone. An orbit drag claims its pointer; `ScreenPointers.claim` hides a claimed pointer from hover until release (`AttachOptions.hoverWhileClaimed` keeps it). `input.onUpdated(fn)` runs after gestures, look, router and triggers each tick; router events `moved` and `released`; `input.addActionSource(src)` (`ActionSource`) and `input.axis(negative, positive)`. `createTouchZones(input, { zones, bindings, devices })` (`TouchZones`, `StickZone`, `ButtonsZone`, `SwipeZone`, `TouchZoneBinding`, `ZoneRect` and their options) claims thumbs in `stick`, `buttons` and `swipe` zones and feeds actions through bindings beside the map. `harness/input.html?zones` and `?claim`; `e2e/input.spec.ts` rows for two thumbs, the claimed hover and `onUpdated` ordering.
99
+ - nav (ws/nav-3): the grid grows for turn-based and tactical games. `createNavGrid({ edgeCost })` takes an `EdgeCost` hook `(from, to, grid) => number` added to each step (`Infinity` blocks the edge one way: a cliff), and `neighbours: 4 | 8` (default 8) fixes the neighbourhood that `findPath`, `smoothPath`, `createFlowField` and `reachable` follow. New `reachable(grid, start, budget, out?)` / `createReach(grid)` is a budget-bounded Dijkstra into a reusable `Reach` (`count`, `cells`, `distances`, `parents`, `has`, `pathTo`, `forEach`, `stale`; zero allocation after the first call). `grid.setCost(index, cost)` / `setWalkable(index, walkable)` / `touch()` edit by index; `FlowField.stale` and `update()` recompute in place; `createFlowField(grid, { goals })` and `setGoals` seed from several goals (`goals`, `goalCount`). `findPath` gains `maxCost` and `heuristicWeight`. `harness/nav.html?reach` and `?multi` with `e2e/nav.spec.ts` rows; existing numbers are unchanged.
100
+ - Input, third pass (ws/input-3). `Look` orbit mode: `LookOptions.mode: LookMode` (`'locked' | 'orbit'`), `setMode`, `mode`, `orbiting`, `claimsPointer(device)` and the feeds `orbitDown`/`orbitMove`/`orbitUp`; a mouse or pen drag turns the view at `sensitivity` with no pointer lock, `request()` resolves false and `lockOnFirstClick` warns once; `Gestures.claim` (the hook `Input` installs from the linked look) keeps the drag from panning. Triggers: `Binding = BindingSource & TriggerOptions` with `trigger: Trigger` (`'press' | 'hold' | 'doubleTap'`), `holdMs` (`DEFAULT_HOLD_MS` 400), `doubleTapMs` (`DEFAULT_DOUBLE_TAP_MS` 300) and chords (`{ chord: Binding[] }`), counted in ticks of `InputOptions.tickMs`; `ActionState.holdProgress`. Rebinding: `input.rebind(action, bindings)` (a source held across it releases the old action), `input.bindings()`, `input.listen(action, { timeoutMs? }): Promise<Binding | null>` (`ListenOptions`; Escape, the timeout and a newer listen resolve null; `listeningFor()`), `serializeBindings`, `parseBindings(text, defaults)`, `findConflicts` (`BindingConflict`), `isBinding`. Rumble: `input.rumble({ strong, weak, ms, pad })` (`RumbleOptions`), `rumbleSupported(pad?)`, `GamepadLike.vibrationActuator` (`GamepadActuatorLike`), the `'rumble'` event and `InputFeedHook.rumble`; a recording lists rumbles per tick (`InputTickEvents.rumble`), playback does not re-issue them. Prompt glyphs: `InputDevice` gains `'xr'`, `input.on('device', fn)` (`InputEvents`), `input.glyphFor(action, device?)`, `glyphForBinding`, `bindingMatchesDevice`, `keyLabel`, `padButtonLabel`, `padAxisLabel`, `guessPadLayout(gamepad.id)` and `InputOptions.padLayout` (`PadLayout`, `Glyph`, `GlyphKind`). `Gestures` gains `pan: 'prefer'` (a press that moved a quarter of the pan threshold never long-presses, so a slow drag under a stalled frame pans) and `onPanCandidate`. `harness/input.html` (`?orbit`; rebinding panel, hold bar, chord counter, device glyph) and `e2e/input.spec.ts` on the matrix.
101
+ - `templates/platformer` (`@thunderclap-engine/template-platformer`, ws/template-platformer): a copyable 2D side-scrolling platformer: string levels into `createTileLayer`, a flipbook player with coyote time and jump buffering, one-way and moving platforms, three-layer parallax, a look-ahead camera, a `ui.meter` HUD, a pause panel, synthesised sounds and a save with a v1 migration. New root scripts `template:platformer` and `check:template-platformer`; `pnpm check` runs the latter.
102
+ - World panels (ws/ui-world-panel): `createWorldPanel(renderer, { width, height, resolution?, theme?, background?, billboard?, headLocked? })` paints a widget set (`worldLabel`, `worldButton`, `worldToggle`, `worldSlider`, `worldList`, `worldProgress`) with Canvas2D in the theme's tokens onto one blended quad in the world, or head-locked in front of the camera (`update(camera)`, no depth test). Widgets `set` only what changed; the panel repaints the dirty union and uploads it once per frame at most (`stats { repaints, uploads, bytes }`). `worldPanelTarget(panel)` is a router `HitTester`, so mouse, touch, pad-cursor and XR rays press the same buttons; `panel.poll(router.pointers)` drives hover, press and release; `focusNext/Prev`, `activate` and `key(event)` drive it without a ray. Harness `world-panel.html`, spec `e2e/world-panel.spec.ts`. A Worker `OffscreenCanvas` is out of scope.
103
+ - 2D ergonomics 2 (ws/ergo-2d-2): in `@thunderclap-engine/engine/geom`, `createIso({ tileW, tileH, elevH?, originX?, originY? })` → `Iso` (`toScreen(tx, ty, elev, out)`, `toTile(sx, sy, elev, out)`, `depth(tx, ty, elev)` = `tx + ty + elev × ISO_ELEV_DEPTH`; `originX`, `originY` live), `isoInverseProbe(iso, sx, sy, heightAt, maxElev, out)` (the pointer-to-tile rule: elevations high to low, the first tile whose height matches wins; `(-1, -1)` and false otherwise) and `isoHiddenBy(iso, tx, ty, heightAt)` (a top covered by a front column at least `tileH` above it). In `@thunderclap-engine/engine/render`, `createTileLayer` gains `order: 'painter'` (`TileLayerOrder`: one mesh per diagonal, `meshes`, drawn back to front at `base + col + row`), `elevation(col, row)` (lifts a quad `elevH × steps`; `elevationAt`, `refresh(col?, row?)`), `diamond` with `iso` (quads on the lattice) and `depthOf(tx, ty, elev?)`, the key a sprite drawn between tiles takes as `order`; `'grid'` is unchanged. `createParallax(renderer, layers)` → `Parallax` (`update(cameraX, cameraY)` offsets each layer's origin by `parallaxOffset(camera, factor, wrap?)`, `draw`, `layers`, `destroy`). `createFollow2D(camera, opts)` → `Follow2D` (dead zone, look-ahead with its own smoothing, exponential smoothing, bounds clamp, `snapOnStart`, `teleport`; writes `camera.x` / `camera.y` only). Harness `tiles.html` gains `?iso` (a 12 × 12 diamond ramp, a walker drawn between tiles, hover picking) and `?parallax` (three wrapped layers under a follow camera with the dead zone drawn); `e2e/tiles.spec.ts` covers both on the matrix.
104
+ - `templates/third-person` (`@thunderclap-engine/template-third-person`, ws/template-third-person): a copyable third-person action game: a procedurally generated skinned glb parsed at startup, `createAnimGraph`, `Follow` with zoom, two-bone foot IK on a `geom` heightfield, root motion, `lookAt`, three cosmetic sockets via `attachTransform`, anchored enemy plates, PBR with shadows and fog. New root scripts `template:third-person` and `check:template-third-person`; `pnpm check` runs the latter.
105
+
106
+ ### Deprecated (ws/api-core, E-036)
107
+ Each keeps working and is removed next minor.
108
+ - input: `Hit` → `PickHit` (geom exports another `Hit`, renamed `RayHit` there), `Aabb` → `RayBox`, `Rect` → `Edges`, `Grid` → `SnapGrid` (ui exports a `Grid` widget), `FollowTarget` → `FollowSubject`; `Input.getGestures()`, `getPointers()`, `getLook()`, `listeningFor()`, `contextStack()` → the `gestures`, `pointers`, `look`, `listening`, `contexts` getters; `getBindings()` → `bindings()`.
109
+ - ui: `Cooldown`/`CooldownOptions` → `WidgetCooldown`/`WidgetCooldownOptions` (core exports a scheduler `Cooldown`), `Cell` → `GridCell` (nav exports an x/y `Cell`), `prefersReducedMotion(win?)` → `prefersReducedMotionIn` (platform exports one without the argument); `WorldPanelInput.getPointers()` → `pointers`. The stylesheet exports (`BASE_CSS`, `HUD_CSS`, `TOOLTIP_CSS`, `CURSOR_CSS`, `NOTIFY_CSS`, `ORB_CSS`, `INVENTORY_CSS`, `FLOATS_CSS`, `CANVAS_MINIMAP_CSS`, `LOADING_CSS`, `WIDGETS2_CSS`, `WIDGETS3_CSS`) and `WORLD_PANEL_WGSL` leave the `ui` entry: the `ensure*Styles` functions and the panel use them.
110
+ - core: `SceneStack.list()` → `scenes`.
111
+
112
+ ### Tests
113
+ - Fake GPU (ws/render-9): buffers hold bytes (`FakeBuffer.data`), submitted `copyBufferToBuffer` / `clearBuffer` / `resolveQuerySet` move them, and `MAP_READ` buffers map them (`mapAsync`, `getMappedRange`, `unmap` detaching the range; `mapState`); a missing `COPY_SRC`, `COPY_DST`, `MAP_READ` or `QUERY_RESOLVE` throws `FakeGpuValidationError`. New on `FakeDevice`: `bufferCopies`, `querySets`, `submits`, `runPass`; `createQuerySet` and `GPUMapMode` are faked. `writes[i].bytes` is now bytes for a typed-array `dataSize` (it was elements). The `GpuTimer`, particle readback and `ParticleSystem.alive` tests drop their stubbed mapping. A LOD `InstanceList` no longer writes its main `buffer`, which nothing draws: a flush of N changed instances writes N × `INSTANCE_STRIDE` fewer bytes (`src/render/instance-list.test.ts`).
114
+ - createGame mid-session playback (ws/core-5): `game.test.ts` runs a game live for 30 ticks, installs `createInputPlayback(ctx.input, rec, { clock: ctx.clock, speed })` and asserts at speed 1 and 4 that a recorded long-press fires on its recorded tick index and game time, that only the playback advances `ctx.clock` while it drives it (the loop's advance resumes after it ends), and the fixed-update order `playback.step()` → `clock.advance` → `input.update()`. Fix: the loop now reads `ctx.input.playback` each fixed step; before, only a playback built from `input.replay` was stepped, so one installed later never fed and the clock ran on the loop's step.
115
+ - e2e under load (ws/e2e-harden): `audio4` `?sprites` counts the clip's voice inside `playClip` (`voicesAtPlay`, `clipsPlayed`, `clipsEnded` on `__harnessAudio4.state`) instead of racing a 200 ms clip; `demos/voxel` sends a tap's or click's press and release back to back over CDP so a starved runner cannot turn it into a long-press (as do `voxel` and `demos/tactics` taps); `decals`, `post2` take clipped screenshots, `skin` keeps a screenshot only when reads either side of it agree, the decal expiry test runs a 10 s life; single reads of moving values poll and fixed sleeps poll page state.
116
+ - e2e under load (ws/e2e-harden-2): `fx3` `?forces` samples a clipped 5×5 window every 50 ms, because the motes swing through the attractor (lit about 0.3 s of each pass) rather than settle on it; `?collide` allows 12% of `alive` past the slab (measured worst 6.2%, no collision about 75%); `ui4` `?minimap` records each distinct rim count on the frame it happens (`onRimSeen`) and reads the state and the DOM in one evaluate; `demos/voxel` publishes `terrainTops` and `spawn` and asserts tops in 16..44 and the spawn open over solid land above the sea.
117
+ - e2e under load (ws/e2e-harden-3, E-033): the `demos/tactics` d-pad test presses, polls until the demo's new `padPresses` count shows a sim step read the down, releases, and polls `padReleases` the same way; a release and the next press of a button between two pad polls read as one hold, which lost the second press of up. Its desktop `clickAt` sends the press and release back to back over CDP (a 485 ms `mouse.down()` under load had made a click a long-press).
118
+
119
+ ### Templates and demos (ws/adopt-sweep)
120
+
121
+ - Engine features replace hand-rolled copies. `createRandom` replaces mulberry32 in `demos/dungeon` and `demos/tactics` (the AI rolls on `fork('ai')`), `demos/arpg` and `templates/puzzle` (`rules/random.ts` removed). A `?seed` still fixes a map, but the sequences differ. `createNoise` (`fbm` over `value2`, `value3` for caves) replaces the value noise in `demos/voxel` and `templates/first-person`. Both keep their height range and a spawn on solid ground: the template's terrain test asserts it, and the voxel demo checks `worldProblem` at boot.
122
+ - `createScheduler` replaces the dungeon hit cooldown, the platformer dash cooldown (`player.dash`, a `Cooldown`) and running-dust cadence, and the puzzle mismatch settle (`after` / `cancel`).
123
+ - In the platformer, `input.buffered('jump', JUMP_BUFFER * 1000)` / `input.consume('jump')` replace the player's own jump buffer (`Player.jumpBuffer` removed). The collision step (`moveBody`) runs on the engine's `slideTiles`.
124
+ - `shapeStick` / `input.stick` replace the hand-written deadzone and clamp in `demos/arpg` (keys plus the touch stick), `demos/dungeon` and `demos/voxel`.
125
+ - The `physics/` (platformer), `rules/` (puzzle) and `world/` (first-person) house rules now allow these pure engine helpers.
126
+
127
+ ### Repository
128
+ - `packages/host-pwa` (`@thunderclap-engine/host-pwa`, ws/host-pwa): the installable web shell. `createPwaHost()` wraps `createWebHost()` (`kind` stays `'web'`) and adds `host.pwa`: `installable`/`install()` (the deferred `beforeinstallprompt`), `updateReady`/`applyUpdate()` (skip-waiting message, then reload), `offline`/`onOffline`, `standalone`, `cacheStatus()`, `clearCache()`; every method is a no-op or `'unavailable'` without worker support. `pwaPlugin(opts)` (`@thunderclap-engine/host-pwa/vite`, no dependencies, build only) emits `manifest.webmanifest`, `icon.svg` (plus 192/512 px PNGs when `OffscreenCanvas` exists at build time), and `sw.js` (precache of the bundle's hashed assets cache-first, `index.html` network-first with cache fallback, `runtimeCache` prefixes stale-while-revalidate capped at `maxRuntimeEntries`, a per-build version stamp from the precache list) and injects the manifest link, theme colour and registration snippet into the page. The worker is assembled from `sw-logic.ts` through `Function.prototype.toString()`, so the routing is unit-tested as plain imports. Harness, `e2e/pwa.spec.ts` on a built preview (offline reload, cache fill and trim), root `check:host-pwa` in `pnpm check`, `pwa:dev`, `pwa:build`; `templates/puzzle` gains a `vite.config.ts` with the commented plugin line and an *Installable* paragraph in its `CLAUDE.md`.
129
+ - The engine has its own repo, `Mandalorian007/thunderclap-engine`, with the `packages/engine` history from The Last Rune. It adds a standalone pnpm setup, CI (`pnpm check`), a README and a runnable example (`examples/hello`, `pnpm example`). The Last Rune keeps its in-repo copy for now.
130
+
131
+ - `packages/host-electron` (`@thunderclap-engine/host-electron`) joins this repo, and the repo becomes a pnpm workspace (`pnpm-workspace.yaml`, `packages/*`) with the engine at the root. New root scripts: `desktop:dev`, `desktop:build`, `test:e2e:desktop`, `check:host-electron`; `pnpm check` runs the last one.
132
+ - e2e (ws/flake-client): `e2e/client.spec.ts` "the full mode reads storage through the host" polls `updates > 0` instead of reading it once, since in fixed-step mode frames can render before the first update.
133
+
134
+ ### Fixed
135
+ - `Gestures` (ws/gesture-longpress): `pointerMove` checks the pan threshold before the long-press timer, and a contact's travel is cumulative from the down point (`Contact.travel`, every move's distance summed), so a slow drag whose steps are each under 10 px, or whose next move arrives after a long frame, is a pan and never a long-press; a pending long-press is dropped when the travel crosses the threshold, a fired one stays. Root cause of the mesh drag flake (E-019); the spec's 12 px steps stay but are no longer needed.
136
+ - e2e under load (ws/flake-input, E-017): `mesh.spec` samples the fog with the page opened on `?still` (`harness/mesh.html` parks the circling lamp behind the far block, away from the sampled ground, and stops the side blocks spinning; `HarnessMeshState.still`), the drag and `look.spec` touch-drag assertions poll yaw and pitch with floors that hold when slow frames coalesce pointer events, and `ui.spec` menuNav polls each focus step; no engine change.
137
+ - The standard PBR material ignored spot lights and per-light layer masks (ws/pbr-lights): `WGSL_PBR` carried its own sun-and-points loop, written before ws/spot-lights. It now runs the prelude's light loop, so `createStandardMaterial` surfaces are lit by `lights.spots` and a lamp with `layers` reaches only the instances whose `InstanceParams.layers` share a bit (`harness/pbr.html` gains a spot on the floor and a masked lamp between two spheres; `e2e/pbr.spec.ts` checks both).
138
+
139
+ ### Docs
140
+ - Docs for v1 (ws/docs-v1): `pnpm check:snippets` now fails a checked snippet that uses an `@deprecated` name, member or overload (TS6385/TS6387, which tsc only reports as suggestions): a second pass asks the checker for them through `typescript/unstable/sync`. `findDeprecatedUses(tsconfig, files?)`, `deprecatedApiNames(apiDir?)` and `migrationRows(text)` are exported from `scripts/check-snippets.mjs`. The new guide `docs/guide/migrating-to-v1.md` has one row per `@deprecated` export in `api/*.api.txt` (77), and a unit test fails when the two drift. The root README is reordered (what it is, quick start with a screenshot of the puzzle template, first game, guides, modules, testing, status), every *Features* bullet links its guide or reference, and the harness list is complete at 70 pages (`host.html`, `input.html` and `bench.html` were missing). Each guide opens with *What you'll build*; the testing guide names the third-person tests by path and links the testing reference. `PlayOptions.at` → `AudioPlayOptions.at` (README, OFFERING), `lights.fog: Fog` → `DistanceFog` (OFFERING), post *fog* → *mist* in the README; ARCHITECTURE's input *Today* covers ws/input-10, ws/input-11 and the ws/api-core renames.
141
+ - Guides (ws/docs-guide): nine task-shaped guides in `docs/guide/` (getting started, 2D, 3D, characters, input, UI and HUD, style and post, VR, testing your game); the root README is rewritten for a first-time user (features as on `main` with their entry points, all four templates, a *Guides* section, a five-line status) and `docs/OFFERING.md` lists every capability on `main`. New `pnpm check:snippets` (`scripts/check-snippets.mjs`, in `pnpm check`) typechecks every `ts` block in `docs/guide/` and the root README, and in a module README each block marked `<!-- snippet:check -->`, against the public entry points; `ts no-check` opts a fragment out.
142
+ - Module references (ws/docs-modules): every `src/<module>/README.md` opens with a purpose paragraph, a *Quick use* block marked `<!-- snippet:check -->` that compiles against the public entry points, and an *Entry points* table; the nine over 300 lines (render, ui, fx, nav, assets, input, geom, post, audio) gain a *Contents* list. Claims false on main fixed: voxel cell order (x, y, z), `GltfWrap` values, `loadGltf` with `material` still makes textures, `lit: 'lit'` uploads emissive maps, the standard material's `label` default, vertex location 14, the post-6 jitter migration (done), SSR roughness per pixel, motion blur's `'object'` default, decals' depth hook (landed), and tween's `update` with NaN.
143
+ - Doc sweep 3 (ws/doc-sweep-3): after wave 4, every README claim matches the code (fill-width APIs dropped from the platform README; the render README's uniform buffer size, instance flag bit, shadow placeholder, sampler kinds and uniform upload timing; the ui README's inert layers, backdrop default, caption `aria-hidden` and stylesheet scope; XR no longer "later" in input and core), ARCHITECTURE principle 4 says XR is *Today* behind `createGame({ xr })`, its *Surface*, *Transforms*, *Renderer*, *Lighting* and *Scene and Ctx* paragraphs match wave 4 (1184-byte uniform buffer, `viewDimension` on `MaterialDesc`, `ctx.xr`, the XR render path, device-loss order, the harness tree), the root README's features, module table, frame description, harness page list, templates, design rule 5 and roadmap are current, and `docs/OFFERING.md` lists what the engine offers.
144
+ - Doc sweep (ws/doc-sweep): `src/fx/README.md` and `src/platform/README.md` are new; the analytics, audio, host, post, save and ui READMEs name every export of their module; the root README's module table, harness page list, frame description and roadmap match the code; ARCHITECTURE *Surface*, *Host*, *Layout*, *Analytics* and the module paragraph drop landed *Planned* lines. New script `pnpm check:docs` (`scripts/check-docs.mjs`, not part of `pnpm check`) lists exported names a module README does not mention.
145
+ - Doc sweep 2 (ws/doc-sweep-2): `src/core/README.md` is new and the core, input, render and testing exports are all named in their READMEs; ARCHITECTURE principle 4 now scopes out only XR, and landed *Planned* lines (fitFreeRect, beacon sink, frame-loop inputs) are *Today*. `pnpm check` now runs `check:docs` after `check:boundaries`.
146
+
147
+ ### Demos
148
+ - `demos/arpg` (ws/adoption-6): the orbs are built once with `MeterOptions.place` maps (`left` / `right` on phone-landscape, else `bottom`) and a `FormSize` (96 theme px on phone-landscape, 48 CSS px, so the left rail drops to 56 px and `worldArea` rises about 0.006 on every phone; desktop, tablet and phone-portrait unchanged), replacing the orb rebuild on a form-factor change, and the minimap's `min-width` pin is gone (ws/ui-11); a "watch replay" action (P or the top button) replays the run from a rewound boot through `ctx.replay(rec, { onEnd })`, ground clicks moved to a `PointerConsumer` on `input.dispatcher` and the wheel to `input.pointer.wheelY` so they replay, the gear and minimap taps on a per-tick side lane (`__demo.replays`, `replayEnds`, `replayFrom`, `replayTo`); `?blur` stays on camera motion, since an `aux: { velocity: true }` target fails the particle pipelines' validation
149
+ - `demos/arpg` (ws/adoption-5): on phone-landscape the minimap and mana orb share a content `right` rail and the health orb the `left` rail (ws/ui-9 corner rule), so the bottom bar holds only the ability row (64 → 52 px) and `worldView` rises per row (phone-se 0.638 → 0.675, iphone 0.659 → 0.694, promax 0.695 → 0.726, android 0.678 → 0.711); desktop, tablet and phone-portrait are unchanged. The ghoul `InstanceList` is keyed by ghoul id (`setKey`, ws/render-7), with `velocity: true` under `?blur` (object motion blur on): a mid-pack kill at 32 m moves no survivor's LOD level (`__demo.deathLodShifts` 0; unkeyed it moved 1–2). A quarter tile fills the 0.5 × 0.5 m corner inside the near walls, so the grout bed no longer shows (+1 draw call). `__demo` adds `worldArea`, `orbRegions`, `ghoulLevels`, `deathFlushes`, `deathLodShifts`, `ghoulVelocity`.
150
+ - `demos/arpg` (ws/adoption-4): the HUD is on content bars (ws/ui-8): the orbs and ability slots size the `bottom` bar (max 160 CSS px, 120 on phone-portrait, where the bars leave the world 86% of the height) and the gear row the `top` bar, so `[layout] … overflows` no longer fires on any row (it did for the gear row on desktop and tablet); the floor tiling is offset a quarter tile on x and z, with a half-width strip along the near walls, so the spawn column misses both the tile gap and the texture's mid-tile grout line (`centreSeam` −22 → −0.03 on desktop-1280; `arpg.spec.ts` asserts |value| < 10, zero overflows and layout warnings on every row, and a phone-portrait run); `__demo` adds `formFactor`, `overflows`, `bottomBar`, `worldView`. `geom.ts` was already on `hash2f` (ws/demo-arpg-2), so no pixels moved for it.
151
+ - `demos/arpg` (ws/adoption-3): walls, broken walls, pillars, broken pillars, drums and the live ghouls are one LOD `InstanceList` each (`createInstanceList(meshLod, …)`) in place of a `frame.mesh` per instance (draw calls unchanged: the scene already merged same-mesh runs); the floor tiles centre on even coordinates, so the grout gap at x = 0 under the spawn is gone (`?still` publishes `__demo.centreSeam`, 69 → −22 on desktop-1280; `arpg.spec.ts` asserts it below 10); ghouls show on the minimap as 12 px `'arrow'` markers on their `heading`.
152
+ - `demos/arpg` (ws/demo-arpg-2): ghoul deaths burn away on `StandardMaterialDesc.dissolve` (0 → 1 over 0.8 s, ember edge, shadow dissolving too) from a pool of four materials instead of shrinking (`__demo.dissolving`, `dissolvePeak`); walls, pillars, drums and ghouls are `MeshLod` chains drawn through `frame.mesh` (`__demo.lodCounts` from `stats.lodCounts`, wheel or `__arpgApi.zoom` pulls the camera back); `geom.ts` and the courtyard layout roll with core `hash2f` (the hand-rolled hash is gone, so tile shades, broken walls and snapped pillars moved); `__demo.triangles` is `stats.triangles`. `arpg.spec.ts` adds the dissolve and zoomed-out LOD tests.
153
+ - `demos/arpg` (ws/demo-arpg): an isometric ARPG slice in a 60×60 m `geom` courtyard at dusk (brazier lights and flames, sun shadow, bloom, AO, grade; 18 draw calls): click/tap/hold-to-move over a baked navmesh with turn-rate steering, a skinned hero on an anim graph with a sword `Trail`, a spring-chain cape and three socketed gear sets swapped by key or button, twelve crowd-separated ghouls with anchored health bars and floating damage numbers that die in three hits and respawn, health/mana orbs and a cooldown ability row. `e2e/demos/arpg.spec.ts` covers boot, path-to-click (tap on touch rows), gear swap, the kill and desktop fps.
154
+ - `demos/voxel` (ws/demo-voxel-2): the demo is on `@thunderclap-engine/engine/voxel`: `world.ts` writes its generation into `createVoxelGrid` and names the block rules, `mesher.worker.ts` is the engine's `installVoxelMesherWorker` recipe (9 lines), `createVoxelMesher` and `createVoxelChunks` mesh, rewrite in place and cull the chunks, `raycastVoxels` picks, the material uses `nearest-mip-repeat` and `VOXEL_WGSL`, `Water` takes `origin` at sea level over an origin-cornered world and `Sky` `ambientFloor`; 1619 lines to 1071. `voxel.spec.ts` also asserts `remeshMs` < 50 and `growths` 0 after a place. The +z riser triangle stays: the mesher is cleared cell by cell (`demos/voxel/riser.test.ts`, skipped), the draw side is the suspect (wish for ws/voxel-2).
155
+ - `demos/arena` (ws/demo-polish-2): the body is `createStandardMaterial({ skin })` (the demo's skinned `WGSL_PBR` copy removed); a swing whose tip reaches a pillar shaft or an orb stamps a scorch `Decals` mark (one per target per swing, `__demo.hits`/`decals`); a widgets `bar` stamina HUD in the bottom region.
156
+ - `demos/dungeon` (ws/demo-polish-2): the pause menu's ArrowUp/ArrowDown keydown handler is gone, the `PanelStack`'s own arrow walking and the `PadNav` drive the stack; the health bar stays `ui.meter` (the HUD primitive).
157
+ - `demos/range` (ws/demo-polish-2): the hinge is a `tween` sequence (fall and flash in parallel, delay, rise); a shot into the backstop stamps a bullet-hole `Decals` mark where the tracer ends (`__demo.state.decals`, asserted by `range.spec.ts`); the score is a `createBillboards` name plate in place of the DOM anchor.
158
+ - `demos/showroom` (ws/demo-polish-2): GPU ms is published after `renderer.timing.ready()` instead of polling `timing.samples`; the five looks are a neutral base on `defaultPostSettings()` plus `applyPostPatch` diffs.
159
+ - `demos/tactics` (ws/demo-tactics): an isometric turn-based skirmish on a 24×24 diamond map with two elevation steps painted into a canvas atlas and drawn as one depth-sorted sprite list (one draw call); four units against six raiders with move and attack overlays, a terrain-and-climb-cost Dijkstra move range whose parent pointers are the path preview and the tweened walk (facing flipbook), a hit burst with an anchored damage caption, a nav flow-field enemy turn, a turn bar, drag pan, wheel and pinch zoom, a d-pad cursor and a win/lose panel; `?still&seed=N` fixes the map and the AI. `e2e/demos/tactics.spec.ts` checks the iso inverse under every viewport and DPR, select/move/unreachable, the enemy turn, the d-pad and the seed.
160
+ - Demo polish (ws/demo-polish): the four demos use the landed engine features in place of copied code: `geom` builders for the arena's ground, pillars, orbs and sword, the showroom's sphere, floor and strip and the range's cube (`demos/range/geometry.ts` removed); `Look.zoom` (wheel, pinch, pad shoulders) drives the arena's `Follow` and the showroom's orbit distance in place of wheel bindings and hand-rolled pinch; the range's reticle is `ui.reticle`; the dungeon gains a `ui.meter` health bar. The arena sword swing sub-steps the mixer and publishes `trailPeak`, so a stalled frame cannot leave the trail at one point; the dungeon's fake pad latches a press until its `PadNav` has seen it and `dungeon.spec` polls the focused item per press (E-027); arena and dungeon gain `?still`; every demo probe reads after a `requestAnimationFrame` settle; each demo README has *What to look at* and *Known limits* and points at `pnpm create-game`.
161
+ - `demos/arena` (ws/demo-arena, E-021): a third-person skinned procedural humanoid (idle and run clips blended by speed in an `AnimationMixer`, a sword on a hand socket leaving a `Trail`), a `Follow` camera on `Look`, PBR ground and pillars under a shadowing sun and two coloured point lights, world-space dust particles, fog and toasts; `window.__demo` exposes `fps`, `speed`, `swinging`, `drawCalls`, `shadowDraws`, `triangles` for `e2e/demos/arena.spec.ts`. Demo infrastructure: `pnpm demo <name>` (`scripts/demo.mjs`), the harness server serving `demos/` at `/demos/<name>/` (`vite.harness.config.ts`), `demos` in `check-boundaries`' consumer dirs, `demos/README.md`.
162
+ - `demos/dungeon` (ws/demo-dungeon, E-021): a 2D top-down dungeon crawl at game scale validating the 2D path: a 40×30 tile map from a canvas-drawn atlas, 60 flipbook bats, a player square moved by keys, pad or touch drag with wall collision, flickering emissive torches feeding bloom, a fog-of-war layer, a pause menu walked by the d-pad, a coin counter in `save` that survives reloads and synthesised pickup/hit sounds on the `sfx` bus; the whole room is one material and one draw call. `e2e/demos/dungeon.spec.ts` drives it through the harness Vite server's `/demos/dungeon/` route; `demos/README.md` lists the demos.
163
+ - Showroom (ws/demo-showroom): `demos/showroom` draws a 7×7 metalness × roughness sphere grid under a shadowing sun, three orbiting point lights, an emissive strip and a canvas-textured floor, with an orbit camera, a `ui` readout of draw calls and GPU ms, keys 1–5 blending five post looks through `post.blendTo`, and `?still` for stable measurements; `e2e/demos/showroom.spec.ts` asserts 3 draw calls, a lit middle sphere, look switching and parked lights on the matrix.
164
+ - `demos/range` (ws/demo-range): a first-person target range validating `Look` + `meshTester` picks + `fx` (sparks, tracer) + spatial audio + a `ui` anchor together; `e2e/demos/range.spec.ts` aims through `__demo.aimAt`, fires with a click or tap and checks the hit, the score, the 3 s reset and a miss on the sky, on every matrix row.
165
+ - `demos/voxel` (ws/demo-voxel): a voxel world validating the voxel path end to end with the mesher in the game: 256 16³ chunks of noise terrain with caves, trees and glowstone, greedy-meshed with baked AO in a Web Worker and uploaded as transferred buffers through `Mesh.update`, one `texture_2d_array` material of canvas-painted blocks, per-chunk `seesSphere` culling, a day cycle driving the sun, `Sky`, fog and shadow, `Water` at sea level in a second view, glowstone flicker into bloom, a first-person controller (`Look`, touch zones, pad) with DDA block break/place re-meshing the touched chunks, a `ui.slots` hotbar and reticle; `e2e/demos/voxel.spec.ts` checks the boot, the draw-call budget, break, place, walking and `?still&time=` on every matrix row.
166
+
167
+ ### Added
168
+ - Testing kit (ws/testing-kit): `@thunderclap-engine/engine/testing` now points at `src/testing/index.ts`, which re-exports the fake GPU unchanged and adds two helpers. `createFakeHost(opts?: FakeHostOptions): FakeHost` is a full `Host` with in-memory storage (`memory`, or `storage: null` for none), a lifecycle moved by `setState`, recorded haptics (`hapticCalls: FakeHapticCall[]`), a settable safe area (`setSafeArea`), fullscreen and wake-lock stubs that record their calls (`fullscreenCalls`, `wakeLock: FakeWakeLock`), recorded `opened` links and `copied` text, and frames that run only on `runFrame(timeMs?)`. `createFixedClock(stepMs = 1000 / 60, start = 0): FixedClock` is a `TickClock` with `step(n)`, `ticks` and `stepMs`; N steps read exactly N × step. No `createTestGame` yet: `createGame` mounts a canvas and boots WebGPU and takes no GPU or clock.
169
+ - glTF materials (ws/gltf-materials, VISION pillar 4): a glTF loads as a fully lit PBR asset. `parseGltf` reads the whole metallic-roughness model into `GltfMaterial` (`metallicFactor`, `roughnessFactor`, `metallicRoughness`, `normal` + `normalScale`, `occlusion` + `occlusionStrength`, `emissive` + `emissiveFactor` × `KHR_materials_emissive_strength`), every slot as a `TextureRef { index, texCoord, transform }` with `KHR_texture_transform` (`transformUv` applies one on the CPU; the old `baseColorTexture` / `texCoord` stay one release), `GltfTexture.sampler: GltfSampler` (filters, mip mode, wraps), `GltfPrimitive.uv1` (`TEXCOORD_1`) and `tangent` (`TANGENT`); the two extensions are handled (no warning, allowed in `extensionsRequired`). `loadGltf` makes one `createStandardMaterial` per material (`lit: 'standard'`, the default; `lit: 'lit'` is the older base-colour material) with one engine `Texture` per (image, colour space): base colour and emissive sRGB, the rest linear; the file's sampler through `gltfSamplerKind`; alpha mode, cutoff, double-sided; the transform as the new `StandardMaterialDesc.uvTransform: [sx, sy, ox, oy, rot]` (`STANDARD_UNIFORM_FLOATS` 12 → 20; `sampler` is a desc field too). `TANGENT` primitives build in `VERTEX_LAYOUT_3D_TANGENT` (`interleave3dTangent`); skinned ones in `VERTEX_LAYOUT_SKINNED` with the material's `skin` bound when `opts.palette` (a `SkinPalette`) is given; `GltfScene.materialNames` and `palette`. `skeletonFromSkin` returns `names`. `harness/gltf.html?pbr` (sphere grid, normal-mapped tile, emissive strip, transformed quad, animated skinned cylinder) and `e2e/gltf.spec.ts`.
170
+ - Post 3 (ws/post-3): `PostSettings` gains five sections, all off by default and free while off (the composite's output and the 2D goldens unchanged): `taa: TaaSettings` (`enabled`, `feedback` 0.9, `sharpen` 0.2, `clampMode` `'aabb' | 'variance'`; an `rgba16float` history pair at scene res reprojected by the previous view-projection against the depth, neighbourhood-clamped and blended; `PostStack.taa.jitter(frame)` is the Halton (2, 3) sub-pixel offset the stack samples the raw colour at until a camera jitter exists; FXAA is skipped while TAA runs; first frame and resize reset the history, `PostStack.historyResets`), `ssr: SsrSettings` (`enabled`, `maxSteps` 32, `thickness`, `maxDistance`, `roughnessCutoff` 0.6, `intensity`, `fadeEdges`; a half-res view-space march from normals-from-depth, faded by roughness and edge, added before bloom; roughness is one global value until a material target exists), `exposure: ExposureSettings` (`mode` `'manual' | 'auto'`, `minEv`, `maxEv`, `speedUp`, `speedDown`, `keyValue` 0.18, `histogramLow` 0.4, `histogramHigh` 0.95; two compute passes build a 64-bin log-luminance histogram and its percentile average, read back without a stall, eased with `PostInputs.dt`; `PostStack.exposure` is the EV), `whiteBalance: WhiteBalanceSettings` (`temperature` 6500, `tint` 0; a Bradford 3×3 before the grade, `whiteBalanceMatrix`, `planckianXy`) and `motionBlur: MotionBlurSettings` (`enabled`, `intensity`, `maxSamples` 8; camera velocity from depth and the previous view-projection, `motionVelocity`). `BloomSettings.dirt?: LensDirtSettings` multiplies the bloom by a lens-dirt map (`createLensDirtTexture(device, size, seed)`, `lensDirtTexels`). `PostQuality` gains `taa`, `ssr`, `autoExposure`, `motionBlur`; `lowPostQuality()` keeps TAA and drops SSR and motion blur; `postQualityFor(tier)` for `'phone' | 'tablet' | 'desktop' | 'xr'` (XR: TAA, SSR and motion blur off, single-pass-per-eye history). Pure helpers exported for tests: `halton`, `Taa`, `TAA_MAX_FEEDBACK`, `taaBlendWeight`, `taaReprojection`, `ssrStep`, `ssrEdgeFade`, `ssrRoughnessFade`, `EXPOSURE_BINS`, `EXPOSURE_LOG_MIN`, `EXPOSURE_LOG_MAX`, `exposureBin`, `exposureBinLog`, `exposureHistogram`, `exposureAverage`, `exposureTarget`, `adaptExposure`, `PostFrameState`. `harness/post3.html` (`?taa ?ssr ?autoexp ?wb=3200 ?mblur ?dirt ?still`) and `e2e/post3.spec.ts`. Renderer wishes: `Camera.setJitter`, `PostInputs.view`, a roughness target, a velocity target.
171
+ - Collision (ws/collide): `createStaticCollider(sources, opts?)` in `@thunderclap-engine/engine/geom` builds a `StaticCollider`, a BVH over the triangles of placed `Geometry` sources (3×4 or 4×4 transforms), heightfields and 2D tile grids (`GridSource`, shared faces left out so a run of tiles is one wall; `gridSolid`, `gridTriangles`), each source its own subtree with a `layer` bit, `refit` (moves a geometry source without rebuilding) and `remove`. Queries, all allocation-free after warm-up: `raycast` / `raycast2` (`Hit`), `overlap` / `overlap2` (`Contact[]`), `sweepShape` / `sweep2` (`SweepHit`; the shape sweep is `sweepShape` at the geom index, `sweep` stays the profile sweep) over `Sphere`, `Capsule`, `Aabb` and the lifted 2D `Circle` and `Box2`; `slide` / `slide2` (`SlideOptions`, `SlideResult`), the move-and-slide loop with ground within `maxSlope`, walls slid along, a `stepHeight` step-up (a rounded bottom landing on a step's lip counts when ground lies under its centre), a `grounded` probe and `snapDown`. The shape-vs-triangle tests (`sphereTriangle`, `capsuleTriangle`, `aabbTriangle`, `shapeTriangle`, `closestPointTriangle`, `closestSegmentSegment`, `closestSegmentTriangle`, `triangleNormal`), `exportCollider` / `importCollider` (identical bytes round trip, `userData` reattached by index), `colliderWireframe` / `colliderWireframeLength` for `createDebugDraw`. Harness `collide.html` (a level from the builders, a capsule on WASD or the stick, the wireframe; `?grid` a 2D corridor with a circle; `?still`) and `e2e/collide.spec.ts`.
172
+ - Perf overlay (ws/perf-overlay): in `@thunderclap-engine/engine/ui`, `createPerfMonitor(opts?: { window? = 120, gpuEvery? = 1, now? })` returns a `PerfMonitor` (`begin(nowMs?)`, `end(nowMs?)`, `sample(source: PerfSource): PerfSample`, `history: Float32Array`, `head`, `reset()`) that is also its `PerfSample`: `frame { last, avg, p95, max, fps }` over a ring of frame times (the `begin`/`end` span, or the interval between `sample` calls), `gpu { total, passes } | null` from `renderer.timing` (it sets `renderer.timingEvery` to `gpuEvery` when it finds it off), `counts` (every numeric field of `renderer.stats` read by key, plus `draws` as an alias of `drawCalls`), `resolutionScale`, `memory` (`performance.memory.usedJSHeapSize` where present), `samples`, `filled`, `window`; allocation-free in steady state and JSON-safe. `createPerfOverlay(monitor, opts?: { corner?: PerfCorner, graph?, parent?, updateEvery? = 10 })` returns a `PerfOverlay` (`el`, `update()`, `show()`, `hide()`, `toggle()`, `visible`, `destroy()`): an `aria-hidden`, `pointer-events: none` `.tc-perf` box in a corner, styled from the theme tokens with default fallbacks, with the numbers and a `<canvas>` sparkline of `history`; no key binding (games bind one). `harness/mesh.html`, `lights.html` and `pbr.html` sample every frame into `state.perf` and show the overlay with `?perf`; `e2e/perf.spec.ts` checks it on the matrix. `docs/BENCH.md` says how to read the overlay against the bench numbers.
173
+ - Morph targets (ws/morph, E-015): `parseGltf` reads `primitive.targets` into `GltfPrimitive.targets: GltfMorphTarget[]` (`{ position, normal }` deltas, `vertexCount × 3` each, unwelded with generated flat normals), lists other target attributes in `GltfPrimitive.ignoredTargetAttributes` and reads `mesh.weights` into `GltfMesh.weights` (null when absent); sparse accessors (`GltfJsonSparse`) are honoured for target attributes only (indices + values over zeros or a dense base), with `GltfError` for a wrong target count, a non-`VEC3` target or weights that do not match. `@thunderclap-engine/engine/anim` gains `morph.ts`: `createMorphBlender(base, targets, layout, weights?)` → `MorphBlender { weights; targetCount; vertexCount; apply(out) }` writes `base + Σ weight × delta` into an interleaved buffer's position and normal lanes (normals renormalised), returning false when no weight changed since the last apply, allocation-free (`MorphTarget`, `MorphLayout`); `sampleWeights(clip, node, t, out, cursor?)` samples a `'weights'` channel with `sampleClip`'s interpolation rules (`sampleClip` unchanged). `loadGltf` keeps the base vertex buffer only for primitives with targets, builds one blender per primitive over a per-mesh weights array (`GltfMorph`, `scene.morphs`, `scene.morph(nodeIndex)`), and `GltfScene.draw` uploads changed primitives through `Mesh.update`. `harness/morph.html` (a generated `.gltf` sphere, squash and bulge targets, a `weights` clip) and `e2e/morph.spec.ts`; `GltfJsonMesh` and `GltfJsonPrimitive.targets` typed.
174
+ - Inventory UI (ws/ui-inventory): in `@thunderclap-engine/engine/ui`, `ui.grid(opts)` → `Grid` (`role="grid"` of `gridcell`s with a roving tabindex, items `{ id, icon, label?, count?, span?, tone?, locked? }`, spans, `.tc-cell-count`, arrows clamped, Home/End, Enter/Space activate, a held Enter/Space or pad `select` picks up with `aria-grabbed`, Escape cancels, `onContext` on the secondary button, Shift+F10, ContextMenu and pad `secondary`; `act(action: PadAction)` for the pad), `createDragContext(host)` / `ui.drag` → `DragContext` (`register`, `unregister`, `dragging`, `cancel`, `targets`; mouse and pen drags, touch after a 160 ms long press, a ghost on the `hud` layer, `data-over` / `data-reject`, `onMove` on both ends, a refused drop snaps back over `--tc-duration-fast` honouring reduced motion; pad carries cross grids in reading order at the edge), `slotsTarget(slots, onAssign)` for `ui.slots` strips, `ui.card(opts)` → `Card` (`role="tooltip"`, `aria-describedby`, shown on focus and hover after 250 ms, compare arrows in CSS) with the pure `placeCard`, `ui.radial(opts)` → `Radial` (4–8 sectors, `aim(x, y)`, `pick`, arrows, Enter, Escape, `data-modal` under a modal) and `ui.contextList(items, opts)` → `ContextList` (`role="menu"`, wrapping arrows, Escape or outside click cancels, focus returned to the opener). `INVENTORY_CSS` is injected once as `#tc-ui-inventory`. Harness `inventory.html` (`?radial`) and `e2e/inventory.spec.ts` (mouse and touch drags, a refused drop, the pad walk, the card's placement, the radial, the context list).
175
+ - 3D ergonomics (ws/ergo-3d), every item additive: `RenderStats.triangles` (scene triangles × instances per frame); `createStandardMaterial({ skin })` binds a `SkinPalette` or its buffer as `buffers[0]` so a skinned PBR character needs no WGSL; `SKIN_PARAM` (3) names the `InstanceParams.params` palette slot; `VertexLayout.positionOffset` (0 on every shipped layout) for `meshTester` over custom layouts; `Mesh.update` takes a Worker's `MeshReply` (`{ buffer, vertexCount, indexCount?, indexOffset?, bounds? }`) as-is; `renderer.timing.ready()` resolves on the first GPU sample (at once when sampled, destroyed or unsupported); `textureFromSource(src, label | options, { linear?, mips?, label? })` generates mips on request; external `SceneTarget`s of one size share one `discard`-stored emissive texture (`sharedEmissiveCount(device)`), so two XR eyes allocate one; `mergeGeometry` and `transformGeometry` accept `VERTEX_LAYOUT_3D_TANGENT` (tangents rotated, w kept, flipped under a mirror); `rayHeightfield(hf, origin, dir, out?)` casts from two arrays; `quatFromMat3(m, out, o?, mo?, stride?)` reads a row-major 3×3 or the 3×3 of a 3×4 affine; `Look` option `lockOnFirstClick`. Render README gains the *Vertex attribute locations* table (0..2 vertex, 3..9 instance, 10 normal, 11/12 skinning, 13 tangent) and the empty-indexed-mesh rule (skipped, no draw call). `sampleHeight` is not added: `heightAt(hf, x, z)` already is it.
176
+ - Navmesh (ws/nav-2): in `@thunderclap-engine/engine/nav`, `bakeNavMesh(input: NavBakeSource | NavBakeSource[], opts: NavBakeOptions): NavMesh` voxelises world-space triangles (any interleaved vertex buffer, so a `geom` `Geometry` goes in as it is) into a heightfield, filters by `agentHeight`, `agentRadius`, `agentMaxClimb` and `agentMaxSlope`, erodes, builds regions and contours and merges convex polygons (`maxVertsPerPoly` ≤ 6) into a `NavMesh { verts, polys, adjacency, bounds, version, findNearestPoly, findRandomPoint, raycast, height, … }`. `findMeshPath(mesh, from, to, out: NavPath3, opts?)` is polygon A* (per-mesh scratch, portal midpoints, binary heap) with funnel string pulling into 3D waypoints and `path.links: { index, id }[]` naming the off-mesh links taken; `followPath3`, `createNavPath3`, `reservePath3`, `clearPath3`, `pathPoint3`, `pathDistance3`. `addOffMeshLink` / `removeOffMeshLink` / `getOffMeshLink` (`OffMeshLinkOptions`: ends snapped within `radius`, `bidirectional`, `cost`, `userData`) bump `mesh.version`. `createCrowd(opts: CrowdOptions): Crowd` is ORCA (RVO2) local avoidance over a uniform grid with no allocation per `update` after the first (`add`, `remove`, `setPrefVelocity`, `setPosition`, `agents`). `exportNavMesh` / `importNavMesh` round-trip the mesh and its links byte-for-byte (bake at build time, ship bytes). New harness `navmesh.html` (a level of `geom` boxes and a ramp baked on the page, drawn with `createDebugDraw`, a click sets the goal, a "jump down" link, `?crowd=24`) and `e2e/navmesh.spec.ts`.
177
+ - Depth hooks (ws/render-hooks): a `Drawable` with `readsDepth: true` (optionally `afterOpaque(encoder, view: ViewInfo)`) gets this frame's opaque depth. For each depth view that sees one, the renderer encodes the scene as an opaque pass (`scene`), a `copyTextureToTexture` of the depth attachment into `SceneTarget.depthCopy` (`depth24plus`, `TEXTURE_BINDING`), the hooked drawables' `afterOpaque` (once each, `ViewInfo { camera, target, depthCopy, depthView, viewport }`), then a blended pass (`scene-blend`) that loads all attachments; `renderer.stats.passes` counts scene passes. Frames without a hooked drawable, `ortho2d` views and adopted XR depth without `COPY_SRC` encode exactly today's passes (2D goldens unchanged). `SceneTarget` gains `depthCopy`, `canCopyDepth`, `copyDepth(encoder)`, `depthCopyTexture()` and `blendPassDesc`; the depth attachment is created with `COPY_SRC`. The camera block gains `inv_view_proj` (float `CAMERA_INV_VIEW_PROJ_OFFSET` = 60; `CAMERA_BLOCK_FLOATS` 64 → 128, so the lights block moves to byte 768 and the per-view buffer is 2032 bytes). `BlendMode` gains `'multiply'` (`dst × rgb`, destination alpha kept). `Water` and `ParticleSystem` take `readsDepth` and then skip their own previous-frame copy, flipping their depth flag in `afterOpaque`; `harness/water.html?hook=1` renders the water in one view through the hook (`HarnessWaterState.depthHook`, `stats.passes`) and `e2e/water.spec.ts` checks its shoreline and foam. The fake GPU records `copyTextureToTexture` in `textureCopies` and validates usages and formats.
178
+ - Finishing post passes (ws/post-2): `PostSettings` gains six sections, all off by default and free while off (the composite's output and the 2D goldens unchanged): `fxaa: FxaaSettings` (`enabled`, `subpixel` 0.75, `edgeThreshold` 0.125; FXAA 3.11 console as one extra full-res pass after the composite, recorded and its target allocated only while enabled), `lut: LutSettings` (`texture: GPUTexture | null`, `size: LutSize` 16 | 32, `intensity`; a trilinear 3D LUT after the grade, with `createLutTexture(device, size, fn)`, `identityLut(device, size)` and `lutTexels(size, fn)`), `grain: GrainSettings` (`intensity`, `size` px, `luminanceResponse`, `animated`; hash noise seeded by frame time), `aberration: AberrationSettings` (`intensity` px, `radialOnly`), `sharpen: SharpenSettings` (`amount`; 5-tap unsharp mask) and `outline: OutlineSettings` (`color`, `thickness` px, `depthThreshold`, `normalThreshold`, `intensity`; a Roberts cross over linear depth and normals-from-depth at scene res, mixed in before fog; off with a one-time warning on a frame without depth). `PostQuality.fxaa` and `.outline` cap the two passes off; `lowPostQuality()` is the low tier. `harness/post2.html` (`?pass=fxaa|lut|grain|aberration|sharpen|outline`) and `e2e/post2.spec.ts`.
179
+ - Audio zones (ws/audio-zones, E-013, E-015): `audio.zones: AudioZones` with `add(desc: AudioZoneDesc): AudioZone`, `remove(zone)`, `update(dt, listenerPosition?)` (defaults to `audio.listener.position`), `weights(): ReadonlyArray<number>` and `size`. `AudioZoneDesc { shape: ZoneShape ({ sphere: { center, radius } } | { box: { center, halfExtents } }); feather? (world units of smoothstep 1 → 0 outside the shape, default 1); ambient?: { sound, bus?, gain? }; reverbSend?: Partial<Record<OutputBus, number>>; fade? (default 0.5) }`. Per zone one ambient `Voice` starts lazily, ramps linearly to `weight × gain` over `fade` and stops after `fade` of silence; per bus the largest `weight × send` is blended with the game's base send as `max(base, zones)` and written only when it moves by more than 1e-3 (`audio.reverbSendLevels` stays the base; new `audio.effectiveReverbSendLevels`). `zoneWeight(desc, position)` is exported and pure; `AudioZoneHost` is the engine slice the class takes. `Voice.follow(target: FollowTarget | null)` makes a spatial voice read its position from a 3×4 row-major world matrix (a `Transform`) on `audio.update(dt)`; `Voice.position` reads it back. New `AudioEngine.update(dt)` runs the zones and the followers; `createGame`'s loop calls it after input and pad navigation, before the hooks and scenes, in both the fixed-step and `sim: false` paths. Allocation-free per update; safe before unlock and when audio is unavailable. Harness `zones.html` orbits the camera through a sphere room and a box corridor with a voice following an orbiting cube; `e2e/zones.spec.ts` polls the weights, the max rule and the follow position on the matrix.
180
+ - Water surface (ws/water): `Water(renderer, opts?: WaterOptions)` in `@thunderclap-engine/engine/fx`, a `Drawable` plane (`size`, `segments`, `level`) shaded as water: `shallowColor` → `deepColor` over `depthFade` world units of water below the surface read from a per-frame copy of the scene depth (soft-particle style; the copy is taken in `prepare`, so draw the water in a second `renderer.render` view for this frame's geometry), `fresnel` towards a sky tint from the lights block's fog and sun, a GGX sun highlight of `roughness` also written to emissive, `foam` (`WaterFoam { width, color }` or null) along the shoreline, `opacity`, up to `WATER_MAX_WAVES` analytic `waves` (`WaterWave { scale, speed, amplitude, direction }`) for the normal and, times `displace`, the vertex height; depth written; every field live; `update(dt)`. `waterHeight(water, x, z, t?)` is the surface height in TypeScript. `WATER_WGSL`, `WATER_DEPTH_COPY_WGSL`, `WATER_PARAMS_FLOATS`. Harness `water.html` (a basin and ramp under a `Sky`, `?still`) and `e2e/water.spec.ts`.
181
+ - Asset bundles and atlas (ws/assets-2): in `@thunderclap-engine/engine/assets`, `defineManifest` types a map of entries (`texture`, `texture-array`, `gltf`, `audio`, `json`, `text`, `bytes`, `image`, `atlas`; `AssetOf<Entry>` gives each key's type) and `createBundle(manifest, { read, renderer?, audio?, concurrency = 4, retries = 0, priority?, strict?, decodeImage? })` loads it from the game's `read(uri)` (the engine never fetches): shared uris read once, bytes counted from `{ data, total }`, `progress`/`asset`/`error`/`done` events, per-key `errors` that still bring `fraction` to 1, `cancel` (keeps cancelled keys in `total`; the next `load` resumes), `release`, `dispose`. `packAtlas(images, opts?)` shelf-packs named images into a power-of-two `AtlasLayout` (padding, extrusion, `maxSize`, deterministic) with `draw(ctx)`; `createAtlasTexture(device, layout, images)` uploads it through an `OffscreenCanvas`. Harness `assets.html` (`?slow`, `?fail=one`, `?cancel`) and `e2e/assets.spec.ts`.
182
+ - Tweens (ws/tween): `@thunderclap-engine/engine/tween`, a pure module (no GPU, no per-update allocation). `createTweens()` → `Tweens { update(dt), to(target, props, seconds, opts?), value(from, to, seconds, onUpdate, opts?), delay(seconds), sequence(...tweens, opts?), parallel(...tweens, opts?), kill(target), killAll(), active }`; `TweenOptions { ease, delay, loops (-1 forever), yoyo, onStart, onUpdate, onComplete, timeScale }`; `Tween { progress, elapsed, duration, complete, killed, paused, timeScale, pause(), resume(), kill(), restart(), then(fn), promise() }`; property paths are dotted (`'position.0'` indexes a `Float32Array`). Easing table `ease` (`linear`, `smoothstep`, `quad`/`cubic`/`quart`/`quint`/`sine`/`expo`/`circ`/`back`/`elastic`/`bounce` × `In`/`Out`/`InOut`, `steps(n)`, `cubicBezier(x1, y1, x2, y2)`, `spring(stiffness, damping)`: a `SpringEase` the tween integrates until it settles, no duration). `Ease` now lives here; `@thunderclap-engine/engine/post` keeps `Ease`, `easeLinear`, `easeIn`, `easeOut`, `easeInOut` as aliases of `ease.linear`, `ease.quadIn`, `ease.quadOut`, `ease.smoothstep` (behaviour identical). New harness `tween.html` (a grid of easing curves with a sprite per ease) and `e2e/tween.spec.ts` (every ease reaches 1, `active` returns to 0, `restart()`).
183
+ - Mobile host (ws/host-mobile). `Host` gains three optional facets: `lifecycle: HostLifecycle` (`state(): 'active' | 'background'`, `watch(fn)`), `haptics: HostHaptics` (`available()`, `impact('light' | 'medium' | 'heavy')`, `notification('success' | 'warning' | 'error')`, `vibrate(ms)`) and `safeArea: HostSafeArea` (`insets(): HostInsets` in CSS px, `watch(fn)`); `HostKind` gains `'capacitor'`. `createWebHost` fills all three (`createWebLifecycle` from `visibilitychange`, `pagehide`/`pageshow`, `freeze`/`resume`; `createWebHaptics` over `navigator.vibrate`; `createWebSafeArea` from `env(safe-area-inset-*)` through a probe element, cached until `resize`), and `createXrHost` delegates them to its base. `createGame` watches `host.lifecycle`: `background` stops the loop and flushes the save (once: the page's own hide path runs first on the web), `active` restarts it unless halted, and either way calls the new `GameHooks.lifecycle(state)` for a game's autosave; it also calls the new `followSafeArea(ui.layout, host.safeArea)` (`ui/layout`, with `SafeAreaSource`) so the layout's regions keep clear of a notch and a home indicator. New package `packages/host-capacitor` (`@thunderclap-engine/host-capacitor`): `createCapacitorHost({ plugins?, base?, win? })` → `CapacitorHost` (`kind: 'capacitor'`, frames from the WebView, `openExternal` via `@capacitor/browser`, `copyText` via `@capacitor/clipboard`, `fullscreen` unavailable but `toggle({ lockLandscape })` locks through `@capacitor/screen-orientation`, `storage` `kind: 'preferences'` as a synchronous `StorageLike` mirror over `@capacitor/preferences` usable after `await host.ready`, `lifecycle` from `@capacitor/app`, `haptics` from `@capacitor/haptics`, `safeArea` from the page, no `window`, and `back?.watch(fn)` for Android's back button), with `createAppLifecycle`, `createCapacitorHaptics`, `createPreferencesStorage` and hand-written plugin types (`CapacitorPlugins` and friends; the `@capacitor/*` packages are the app's own and are never imported). New root script `check:host-capacitor`, in `pnpm check` after `check:host-electron`. `harness/host.html` (`?safe=top,right,bottom,left`) and two `e2e/host.spec.ts` tests: the safe area pads the top and bottom regions, a hidden document stops frames within 200 ms and visible resumes them.
184
+ - Billboards (ws/billboards, E-015). In `@thunderclap-engine/engine/fx`: `createBillboards(renderer, { capacity?, atlas?: { size?, font? } })` returns a `Billboards` `Drawable`: `add(desc: BillboardDesc): BillboardHandle`, `remove`, `clear`, `update(dt, camera?)`, `text(text, style?: TextStyle): AtlasRegion`, `bar(fraction, size, fg, bg)`, `count`, `atlasUsed`, `draws`, `atlasTexture`. A `BillboardDesc` has `position` (a `Float32Array` followed live, or a tuple), `offset`, `size` (`[w, h]` world units or `{ px: [w, h] }`), `region`, `color`, `facing` (`'camera' | 'cameraY'`), `depth` (`'test' | 'always'`), `life` (fades over the second half, then removes itself) and `velocity`; the handle has setters for `position`, `offset`, `color`, `size`, `region` and `fraction`. One storage buffer of 80-byte instances (`BILLBOARD_WGSL`, `BILLBOARD_FLOATS`, `BILLBOARD_BYTES`, `BB_FLAG_*`), grown by doubling; back-to-front insertion sort per `update`, alpha blended, depth tested without writing, one draw per depth mode. `TextAtlas` (`TextAtlasOptions`, `TextStyle`, `AtlasRegion`, `AtlasCanvas`, `AtlasContext2D`): canvas text rasterised once, shelf packed, cached by text and style, LRU shelf eviction when full, uploaded by `copyExternalImageToTexture` when dirty. `pxToWorld` is the CPU side of the px sizing. `harness/billboards.html` and `e2e/billboards.spec.ts` show six capsules with plates, bars and rising damage numbers on the matrix.
185
+ - Save 2 (ws/save-2): `createSlotManager({ game, version, migrations?, defaults, schema, slots?, host?, storage?, meta? })` → `SlotManager<T>` (`list(): SlotInfo[]` from the `<key>:meta` sidecar the store now writes in the same flush as the data when `SaveStoreOptions.meta` is set; `open(slot)`, `copy`, `remove`, `rename`, `mostRecent()`; legacy slots without a sidecar list from the envelope); `createSettingsStore(opts)` (slot `settings`, version 1, `debounceMs` 0) with `bind(input, key)` for menu controls; `createJournal(store, { limit? })` → `Journal` (`push`, `undo`, `redo`, `canUndo`, `canRedo`, `length`, `undoLabel`, `redoLabel`, `clear`; structural snapshots restored through `store.set`, identical pushes collapse); `s.bytes({ maxLength? })` and `s.typed(kind, { maxLength? })` with typed arrays stored as `{ $b: base64url, $t? }` in the JSON and the export string through `saveReplacer` / `saveReviver` (a player key matching `/^\$+b$/` is escaped with one more `$`). `listSlots` skips `:meta` keys. Harness `save.html` + `e2e/save.spec.ts` on the whole matrix (the itch-iframe row through `?page=save.html`).
186
+ - Debug draw (ws/debug-draw): `createDebugDraw(renderer, opts?: DebugDrawOptions)` in `@thunderclap-engine/engine/fx` returns a `DebugDraw` (`Drawable`) that collects world-space line segments and draws them in one `'line-list'` call: `line(a, b, color)`, `ray(origin, dir, length, color)`, `arrow(a, b, color, headSize?)`, `box(center, halfExtents, color, rotation?)`, `wireSphere(center, radius, color, rings = 12)`, `axes(transform | matrix3x4, size)`, `grid(center, size, step, color)`, `circle(center, normal, radius, color, segments = 32)`, `path(points, color, stride = 3, offset = 0, count?)`, `frustum(invViewProj, color)`, `clear()`, `draw(pass, frame)`, `destroy()`, with `count`, `capacity` and `uploads`. Options `capacity` (segments, default 16384, grows 1.5×), `depth: 'test' | 'always'` (tested and never written, or neither), `retain` (keep segments after `draw`), `label`. Colours (`DebugColor`) are `[r, g, b, a]` or packed `0xRRGGBBAA`; points (`DebugVec3`) arrays or `{ x, y, z }`; rotations (`DebugQuat`) arrays or `{ x, y, z, w }`. Vertices are 7 floats (`DEBUG_VERTEX_FLOATS`), uploaded once per draw over the used range only, drawn 1 px wide with a pipeline per `frame.depthFormat` (`DEBUG_DRAW_WGSL`). New harness `debug.html` (axes, grid, spinning wire box and sphere, an arrow on a sine path, a `path` from a static array; `?still` parks motion) and `e2e/debug.spec.ts`.
187
+ - Retargeting (ws/retarget, E-015): in `@thunderclap-engine/engine/anim`, `createRetargetMap(source, target, opts?: RetargetOptions): RetargetMap` pairs the joints of two skeletons (`pairs: [source, target][]` by name or index, `match(sourceName)`, else exact `Skeleton.names`, else `normalizeJointName` compare: `mixamorig:LeftArm` ↔ `left_arm`; nameless skeletons pair by index) and precomputes rest frames (`pre`/`post` quaternions per target node, `root`, `translationScale` from the hip-height ratio unless `scaleTranslation: false`, `unmatched: string[]`). `retargetPose(map, sourcePose, targetPose)` transfers rotations as rest-relative deltas in each joint's parent frame so differing rest orientations work, keeps target translations and scales except the root's translation, allocation-free; `retargetClip(map, clip, opts?: RetargetClipOptions)` bakes a clip to the target at its key times or a `sampleRate`. `harness/retarget.html` and `e2e/retarget.spec.ts` on the matrix.
188
+ - Navigation (ws/nav): `@thunderclap-engine/engine/nav`, pure CPU grid navigation over world XZ with no import from other modules. `createNavGrid` (`NavGrid`: `costs`, `setWalkable` / `setCost` bump `version`, `toCell` / `toWorld`, `hasLineOfWalkable`), `findPath` (A* with a binary heap and per-grid scratch, octile heuristic, costs, no corner cutting, `maxExpand`) into a `NavPath { points, length, cursor, version }` (`createNavPath`, `reservePath`, `clearPath`, `pathPoint`, `pathDistance`), `smoothPath` (string pulling), `followPath` (`'moving' | 'arrived'`), `createFlowField` (`FlowField.build(goals)` Dijkstra, `directions`, `distances`, `directionAt`) and `steer` (`separate`, `arrive`, `avoidDisc`). New harness `nav.html` (click sets the goal, 32 agents flow around toggled walls, fixed-step `advance`) and `e2e/nav.spec.ts`.
189
+ - Scaffolder (ws/scaffold, VISION pillar 6): `pnpm create-game <dir> [--template <name>] [--name <pkg>] [--engine <spec>] [--force] [--dry-run]` and `pnpm create-game --list` (`scripts/create-game.mjs`, Node built-ins only) copy `templates/<name>` to `<dir>` minus `node_modules`, `dist`, `.turbo` and lockfiles as a standalone project: `package.json` rewritten in key order (`name`, `private: true`, `@thunderclap-engine/engine` from `workspace:*` to `--engine` or `^<root version>`; a directory given to `--engine` becomes `link:<path>`; scripts using pnpm workspace flags dropped), the repo's `biome.json`, a `.gitignore` and a `README.md` written, the template's `CLAUDE.md` kept minus its references to the repo's root scripts (a per-template rewrite table in the script). A non-empty `<dir>` needs `--force`; misuse exits 1 with usage. `scripts/create-game.test.mjs` (`node --test`, `pnpm check:create-game`, in `pnpm check`) scaffolds every template into a temp dir, asserts the file set and manifest, then installs offline and runs `tsc --noEmit` against `link:<repo>` (skipped with a message when the pnpm store cannot satisfy the install).
190
+ - Animation graph (ws/anim-graph, E-015): `createAnimGraph(mixer, desc: AnimGraphDesc): AnimGraph` in `@thunderclap-engine/engine/anim`. States (`AnimGraphStateDesc`) hold one clip (`AnimGraphClipState { clip; speed?; loop? }`) or a 1D blend space (`AnimGraphBlendState { blend: { param; points: AnimGraphBlendPoint[]; syncPhase? } }`: the two points around the parameter share the weight, beyond the ends the nearest takes it all; `syncPhase`, default true, runs every clip at one normalised phase at the blended cycle's rate so walk→run keeps its feet in step). Transitions (`AnimGraphTransition { from: string | '*'; to; when: (params) => boolean; fade; exitTime? }`) are evaluated in order, first match wins, `'*'` from any state but `to`, `exitTime` gating on the state's normalised time. Parameters (`AnimGraphParams`, numbers and booleans) are copied once and the same object reaches every condition. Every clip gets a mixer action at construction; a transition lerps every state's weight toward the target over `fade`, so weights sum to 1 even when a fade interrupts another. `AnimGraph`: `params`, `state`, `previous`, `set`, `get`, `update(dt)` (transitions, fades, then the mixer), `onEnter` / `onExit` (`AnimGraphListener = (from, to) => void`), `weight`, `actions`, `phase`, `go`. No allocation in `update`. Harness `anim-graph.html` (a three-joint figure of boxes on an idle/walk/run blend space plus a jump) and `e2e/anim-graph.spec.ts`.
191
+ - Depth post passes (ws/post-depth, VISION pillar 4): `PostSettings.ao: AoSettings` (`enabled` false, `radius` 0.5, `intensity` 1, `bias` 0.02, `power` 1.5) is a half-res screen-space ambient occlusion pass over the scene depth (normals from depth derivatives, 12 spiral samples with interleaved-gradient rotation, a 4×4 depth-aware blur) multiplied into the scene colour before bloom; `PostSettings.dof: DofSettings` (`enabled` false, `focusDistance` 10, `focusRange` 6, `maxBlur` 8 px at output res, `nearBlur` true) computes a circle of confusion from linear depth, gathers 24 CoC-weighted taps at half res and blends sharp and blurred by CoC in the composite. Both blend through `blendTo`; `PostQuality.aoSamples` (12) and `dofTaps` (24) cap them. The renderer fills `PostInputs.depth` and `PostInputs.projection` (`PostProjection`) on frames a depth camera rendered, `null` otherwise, and a frame without depth skips both passes with the composite unchanged (goldens delta 0). `harness/depth-post.html` (`?ao=0`, `?dof=0`) and `e2e/depth-post.spec.ts`.
192
+ - Camera helpers (ws/camera-ergo): `Camera.viewZ(x, y, z)` (view-space depth, positive in front), `Camera.invViewProj` (cached `viewProjection⁻¹`, column-major, lazily recomputed after any view or projection setter), `Camera.projectMany(points, out, count?, stride = 3)` (screen x, y and clip depth per point, 3 floats each, returns the count inside the viewport; zero allocations) and `Camera.frustumCorners(out)` (the eight world-space corners, near then far). `setPerspective(fovY, aspect, near, far)` now turns `depthTest`, `depthWrite` and `cull` on, as `Camera.perspective` does; `setProjection` stays raw. `harness/follow.html` publishes `projected` and `nearCornerZ`.
193
+ - Procedural geometry kit, second half (ws/geom-2): in `@thunderclap-engine/engine/geom`, `extrude(profile, opts?)` (`ExtrudeOptions`: `depth`, `segments`, `caps`, `bevel`), `lathe(profile, opts?)` (`LatheOptions`: `segments`, `angle`), `sweep(profile, path, opts?)` (`SweepOptions`: `closed`, `frames: 'rmf' | 'frenet'`, rotation-minimising frames by double reflection), `tube(path, opts?)` (`TubeOptions`), `polygon(points, opts?)` (`PolygonOptions`), `stairs(opts?)` (`StairsOptions`) and `arch(opts?)` (`ArchOptions`) return `VERTEX_LAYOUT_3D` geometry; `triangulate(profile)` (ear clipping, concave outlines, reflex-vertex tracking) and `profileArea2`; `computeTangents(g)` (`GeometryTangent`, `TANGENT_FLOATS` 16: per-triangle Lengyel tangents accumulated MikkTSpace-style, Gram-Schmidt against the normal, handedness in `w`) returns the new `VERTEX_LAYOUT_3D_TANGENT` from `render/mesh.ts` (the 3D layout plus `tangent: vec4f` at `@location(13)`, 64 bytes); operations `flatShade`, `weld(g, epsilon?)`, `flipWinding`, `projectUv(g, 'box' | 'planar' | 'cylindrical' | 'spherical', opts?)` (`UvProjection`, `ProjectUvOptions`), `fitBounds(g, size)` and `recenter(g)` on either layout. `harness/geom.html` gains a second row (extruded star, lathed vase, helix tube, stairs, arch) behind the first and a tangent-space probe (`tangentW` = `tangentVertices` on the star); `e2e/geom.spec.ts` checks both.
194
+ - Mixer books, per-voice sends and pause (ws/audio-3): `audio.voices(bus?): readonly Voice[]` lists the live voices on a bus (`master` or none for all; one array per call) and `audio.forEachVoice(bus, fn)` walks them without allocating; `voiceCount` is `voices().length`, and a voice leaves the list the frame `stop` is called or `onended` fires. `Voice.sendTo(input: AudioNode | Send, amount, ramp?)` routes a parallel copy of the voice (after its gain and panner) through a per-voice gain into a `Send`'s input or any node; 0 disconnects, up to `MAX_VOICE_SENDS` (4) per voice, torn down with the voice, a no-op on the inert voice and on variations' `SILENT`. `audio.register(voiceLike, bus): () => void` puts an external `Voice` on the books, and `createMusic` registers each playing stem through a `Voice` adapter (`stop(fade)`, `setGain`, `setRate`, `sendTo`, `playing`; spatial calls are no-ops), so stems show under `voices('music')` and **`audio.stopAll()` now stops music too**: a game that stopped sound effects on a scene change while the score ran on calls `audio.stopAll({ except: ['music'] })` (`StopAllOptions { fade?, bus?, except? }`; the positional `stopAll(fade?, bus?)` still works). `audio.pause(opts?: PauseOptions)` ramps every bus but `keep` (default `['ui']`; `master` is never touched) to silence over 50 ms and sets the affected voices' and stems' `playbackRate` to 0 where the context allows it (`audio.pausedRate`; otherwise they are only muted), `audio.unpause()` ramps the buses back to their settings and restores the rates; `audio.paused` and `audio.pausedBuses` report the state, and neither touches the context. `harness/music.html` gains probe voices, `?pause` and per-bus voice counts; `e2e/music.spec.ts` polls them on the matrix.
195
+ - Game audio toolkit, part 2 (ws/audio-2): in `@thunderclap-engine/engine/audio`, `createMusic(mixer, { bpm, beatsPerBar?, bus?, intensityFadeBeats? })` → `Music` (`add(stem)`, `play(stem, { layer?, fadeBeats?, at: 'now' | 'beat' | 'bar', gain? })` with `AudioBufferSourceNode.start(when, offset)` keeping every stem in phase with a grid computed from the context clock, `stop(layer?, fadeBeats?)`, `setIntensity(level)` crossfading a layer's stems by their `intensity: [lo, hi]` ranges with gains summing to 1 (`intensityGains`), `beat`, `bar`, `totalBeats`, `nextBeatTime()`, `nextBarTime()`, `onBeat(cb)`, `gain(name)`, `playing`, `update(dt)`, `dispose()`; plays before unlock queue); `createVariations(mixer, buffers, { bus?, pitchJitter?, gainJitter?, noRepeat?, random? })` → `Variations { play(opts?) → Voice, last, count }` (uniform over the buffers except the last); `createSend(mixer, { kind: 'reverb' | 'lowpass', bus?, amount?, seconds?, decay?, seed?, cutoff?, q? })` → `Send { amount, cutoff, node, input, dispose }`, a parallel bus → gain → `ConvolverNode` | `BiquadFilterNode` → master path, with `impulseResponse(ctx, { seconds, decay, seed, channels })` in `dsp.ts` building the convolver's buffer procedurally; `createDucker(mixer, { trigger, target, amount?, attack?, release?, threshold? })` → `Ducker { watch(voice), update(dt), active, level, gain, dispose }` rewriting the target bus gain from the mixer settings. `harness/music.html` (synth stems, intensity and reverb sliders, footstep and speak buttons) and `e2e/music.spec.ts` on the matrix.
196
+ - Widgets 2 (ws/ui-widgets): `reticle`, `bar`, `segmented`, `select`, `keyPrompt`, `stepper` and `cooldown` in `@thunderclap-engine/engine/ui` (`src/ui/widgets-2.ts`): plain DOM with keyboard behaviour and ARIA (`progressbar`, `radiogroup` with a roving tabindex, `spinbutton`), `tc-ui-*` classes, `WIDGETS2_CSS` in design px from the existing theme tokens, injected once by `createOverlay` (`ensureWidgets2Styles`). `segmented` and `stepper` consume only the arrow keys they use. `harness/widgets.html` and `e2e/widgets.spec.ts` on the matrix.
197
+ - Input recording and replay (ws/input-replay): `createInputRecorder(input, opts?)` → `InputRecorder { tick(), stop(): InputRecording, recording, ticks }` writes down every feed call and the pad snapshot per `update()` through the new `Input.onFeed(hook: InputFeedHook)` (the hook sees `keyDown`, `keyUp`, `buttonDown`, `buttonUp`, `wheel`, `pointerAt`, `releaseAll` and the pads `update()` reads, and may substitute the pads); `createInputPlayback(input, recording, opts?)` → `InputPlayback { step(): boolean, done, tick, length, seek(tick), speed, detach() }` feeds them back so an identical game sees identical action states per tick; `InputRecording` is plain JSON (`{ version: 1, ticks }`, deltas only: `InputTickEvents { t, k?, b?, p?, w?, r?, g? }` with `PadSnapshot[]`, idle stretches as `InputTickRun { t, n }`), `serializeRecording`, `parseRecording` (throws `InputRecordingError`), `recordingLength`; `Input.recorder` and `Input.playback` name the installed ones. `createGame({ input: { record: true } })` records from the first tick, `createGame({ input: { replay } })` attaches no DOM listener and steps the playback before each fixed step. `harness/replay.html` records a scripted 3 s session and replays it; `e2e/replay.spec.ts` checks every tick matches on the matrix.
198
+ - Localisation (ws/i18n): `@thunderclap-engine/engine/i18n`, a dependency-free string module. `createI18n(desc: I18nDesc)` (`locale`, `fallback`, `tables: Record<locale, Table | () => Promise<Table>>`, `missing: 'key' | 'throw' | fn`, `onMissing`) returns an `I18n` with `locale`, `locales`, `direction`, `ready`, `t(key, args?)`, `has`, `setLocale(locale): Promise<void>`, `subscribe(fn)`, `formatNumber`, `formatDate`, `formatDuration`, `formatList`. Messages are ICU-style, parsed once per dotted key into an AST (`parseMessage`, `formatMessage`, `MessageSyntaxError` naming key and column): `{name}`, `{n, number[, integer | percent]}`, `{d, date | time[, style]}`, `{count, plural, offset:N =N {…} one {…} other {…}}` with `#` through `Intl.PluralRules`, `selectordinal`, `{g, select, …}`, apostrophe quoting. Lookup walks the locale, its language and each fallback; lazy tables load once with a pending promise per locale and the latest `setLocale` wins. `negotiateLocale(available, requested = navigator.languages, fallback?)`, `requestedLocales`, `directionOf`, `languageOf`, `RTL_LANGUAGES`, `compileTable`, `createIntlCache`. `harness/i18n.html` (locale switcher, plural counter, RTL sample) and `e2e/i18n.spec.ts` on the matrix.
199
+ - Batch submit (ws/batch-submit, VISION pillar 3: many instances for a few ms of CPU). In `@thunderclap-engine/engine/render`: `frame.meshes(mesh, material, batch: InstanceBatch)` draws `batch.count` instances from the game's typed arrays, `matrices` (3×4 row-major, `MATRIX3X4_FLOATS` each) and optional `params` (`INSTANCE_PARAM_FLOATS` = 16 floats each, the instance format from `uvRect` on; `writeInstanceParams(dst, offset, params, sizeX?, sizeY?)` fills one from `InstanceParams`; null for the defaults), with optional `bounds` (a world sphere culled once for the batch; without it each instance is culled through the mesh sphere over the array, allocating nothing), `layerBits`, `order` and `castShadow`; a batch is one sort key, one draw (merged with same-state neighbours) and one slice of the frame's upload, and the shadow pass draws it. `renderer.createInstanceList(mesh, material, capacity): InstanceList` is a retained list in its own vertex buffer: `set(i, Transform | matrix, params?, offset?)` (i = `count` appends), `remove(i)` (swap-remove, returns the moved index or -1), `clear()`, `touch(i)`, `matrices`, `params`, `count`, `capacity`, `dirty`, `bounds` (automatic from the mesh sphere through every instance, or `setBounds(sphere | null)`), `layerBits`, `order`, `castShadow`, `destroy()`; `frame.list(list)` uploads only the dirty index range (nothing when unchanged) and draws it culled as one unit. `RenderStats.batches` counts batches and lists drawn; `instances` includes theirs. `frame.mesh` is unchanged (bench before/after in `docs/BENCH.md`'s `instances-*` rungs). `harness/bench.html` gains `?mode=mesh|batch|list`, `?cull=0|1` and `?still`, the ladder `batch-10k[-cull]`, `batch-50k[-cull]`, `list-10k`, `list-50k`, and `e2e/bench.spec.ts` asserts the three modes draw the same instance count and the same pixels.
200
+ - Tile layers (ws/ergo-2d): `createTileLayer(renderer, { material, tileW, tileH, columns, rows, atlas: { cols, rows }, fill?, label? })` → `TileLayer { set(col, row, tileIndex | -1), get, fill(tiles), origin, scale, draw(frame, params?), count, mesh, destroy() }` in `@thunderclap-engine/engine/render`: one `Mesh` of `columns × rows` quads in logical px with per-tile atlas UVs, dirty-range `writeBuffer` on draw, one `frame.mesh` call placed by `origin`/`scale`, no per-frame allocation; tile -1 collapses its quad. `harness/tiles.html` and `e2e/tiles.spec.ts`.
201
+ - Flipbooks (ws/ergo-2d): `createFlipbook({ frames: readonly UvRect[], fps?, loop? })` → `Flipbook { update(dt), uv, frame, done, reset(), play(frames?), fps, loop, frames }`, `UvRect` (`[u0, v0, u1, v1]`) and `atlasFrames({ cols, rows }, indices?)` in `@thunderclap-engine/engine/render`; pure timing, zero allocations in `update`.
202
+ - Panel arrow navigation (ws/ergo-2d): `PanelStack` moves focus through the top panel's focusables on ArrowUp/ArrowDown, wrapping; `PanelOptions.arrowNav: ArrowNav = 'vertical' | 'both' | 'none'` (default `'vertical'`) adds ArrowLeft/Right or turns it off. Arrows a focused text input, select, textarea, range or `role=slider` uses itself are left alone (`consumesArrows` in `dom.ts`).
203
+ - `createGame({ input: { getGamepads } })` (ws/ergo-2d) reaches both `Input` and `PadNav`, so a test drives `ctx.padNav` with a fake pad; typed `() => readonly (GamepadLike | null)[]`.
204
+ - Texture registry (ws/texture-registry, E-015; the PBR material's plumbing). In `@thunderclap-engine/engine/render`: `loadTexture(device, source: TextureLayerSource, opts?: LoadTextureOptions)` (`{ mips? = true, filter?: MipFilter, format? = 'rgba8unorm-srgb', label? }`) creates one 2D texture, uploads an image or `PixelSource` as level 0 and fills the full `mipLevelCount` chain with `generateMipmaps`, odd sizes included. `defaultTextures(device): DefaultTextures`, cached per device in a `WeakMap`: `white` (1×1 255 sRGB), `black`, `flatNormal` (128,128,255 linear), `metalRough` (255,255,0 linear: glTF G roughness 1, B metallic 0) and `checker` (8×8 magenta/black, mipmapped). `BindingSet`'s `defaultView` is now optional (`GPUTextureView | null | undefined`): unset 2D slots bind the given view or `defaultTextures(device).white`, so a material with every slot unset draws; the renderer still passes its own `white`, so nothing changes on screen. `loadTextureArray` shares the per-layer upload with `loadTexture`.
205
+ - Spot lights and per-light layer masks (ws/spot-lights, VISION pillar 4): `SpotLight { position; direction; color; radius; innerAngle; outerAngle; layers? }` with `Lights.spots` (the first `MAX_SPOT_LIGHTS` = 8 uploaded), and `layers?: number` on `DirectionalLight`, `PointLight` and `SpotLight` (default `ALL_LIGHT_LAYERS`), matched against the instance's `InstanceParams.layers`. `EngineLights` grows after fog by `spot_count`, `sun_layers`, `point_layers: array<vec4u, 4>` and `spots: array<EngineSpotLight, 8>` (`LIGHTS_BLOCK_FLOATS` 168 → 316, one uniform buffer per view 1776 bytes; `LAYERS_OFFSET_FLOATS`, `POINT_LAYERS_OFFSET_FLOATS`, `SPOT_LIGHTS_OFFSET_FLOATS`, `SPOT_LIGHT_FLOATS`; every earlier offset unchanged). The instance flags float now carries the low 23 layer bits above the shadow bit (`packInstanceFlags(receiveShadow, layers)`, `INSTANCE_LAYER_SHIFT`, `INSTANCE_LAYER_BITS`), so the default instance's flags read 2, not 0. New WGSL: `engine_light_accum(world_pos, normal, layer_mask)` (sun × shadow + points + spot cones of the lights sharing a bit with the mask), `engine_lambert_layers(normal, world_pos, layer_mask)`, `engine_instance_layers(flags)` and `engine_spot_cone(cos_theta, cos_outer, cos_inner)` (`spotCone` on the CPU); `engine_lambert(normal, world_pos)` keeps its signature and sums every layer. `harness/lights.html` and `e2e/lights.spec.ts` show a sweeping spot and a point light masked to one of two cubes, on the matrix.
206
+ - `templates/first-person` (`@thunderclap-engine/template-first-person`, ws/template-first-person): a copyable first-person 3D game wired the way the next game should be: `createGame({ sim: false })` with no save (a client), one `Look` linked to `ctx.input` for pointer-locked mouse, two touch zones, pad sticks and the move keys, `ctx.camera.setPerspective` aimed with `lookAt`, a pure `world/` (seeded height field, chunk mesher writing `VERTEX_LAYOUT_3D` with the texture-array layer in vertex colour alpha, unit tests) run in a Worker whose transferred buffers feed `mesh.update({ bounds })`, `loadTextureArray` block faces, `lights.sun.shadow` and `lights.fog` through `engine_lambert`/`engine_fog`, world-space snow (`ParticleSystem space: 'world'`), landmark labels through `ui.anchors`, hit boxes on `ctx.pointers` shared with the crosshair ray (`screenRay` + `aabbTester`), a HUD through `ui.layout` and `ui.layers.hud`, and a `CLAUDE.md` naming the concepts. Root scripts `check:template-first-person` (in `pnpm check`) and `template:first-person`.
207
+ - GPU skinning (ws/skinning, E-015): `VERTEX_LAYOUT_SKINNED` (`VERTEX_SKINNED_FLOATS` 14, `VERTEX_SKINNED_STRIDE` 56, `VERTEX_SKINNED_BUFFER_LAYOUT`: the 3D layout plus `uint8x4` joints at `SKIN_JOINTS_BYTE_OFFSET` 48, `@location(11)`, and `unorm8x4` weights at `SKIN_WEIGHTS_BYTE_OFFSET` 52, `@location(12)`; `VertexLayout.skinned`), `setVertexSkin(bytes, vertex, joints, weights)`, and `SkinPalette(device, { joints, instances?, label? })` (`src/render/skin.ts`; `SkinPaletteDesc`, `JOINT_FLOATS` 12, `SKIN_PALETTE_HEADER_FLOATS` 4): a storage buffer of `instances × joints` row-major 3×4 joint matrices (the layout `anim`'s `skinMatrices` writes) with `set(slot, matrices, offset?)`, `setIdentity(slot)`, `offsetOf(slot)`, `isDirty`, one allocation-free `flush()` per frame and `destroy()`. `BindingDesc.buffers: GPUBuffer[]` binds read-only storage buffers at `@group(1) @binding(BUFFER_BINDING_BASE + i)` (`BUFFER_BINDING_BASE` 16; `BindingSet.setBuffer(slot, buffer)`), so `createMaterial({ buffers: [palette.buffer] })` is the palette binding (`SKIN_PALETTE_BINDING` 16). A draw names its palette slot in `params.w`. `materialPrelude` emits `WGSL_SKIN` (`engine_skin_palette`, `engine_skin(v, position, normal)`) for a skinned layout only; the 2D and 3D preludes are byte-identical to before, and the shadow pass's pipeline for a skinned caster skins through the same `vs_main` (its layout binds the material group as group 1, so the shadow follows the pose). New harness `skin.html` (two tubes, 24 rings, 3 joints, hand-written sway) and `e2e/skin.spec.ts`.
208
+ - Standard PBR material (ws/pbr, VISION pillar 4): `createStandardMaterial(renderer, desc)` in `@thunderclap-engine/engine/render` builds a glTF-style metallic-roughness material (GGX, Smith, Schlick, Lambert) from `StandardMaterialDesc` (base colour, metallic-roughness, normal, occlusion and emissive maps with factors, `alphaMode` opaque/mask/blend, `alphaCutoff`, `doubleSided`) lit by the frame's `Lights` with shadow and fog, tinted per instance; `WGSL_PBR` exports `engine_pbr` and `engine_perturb_normal` (derivative tangent frame, no tangent attribute) for custom materials; `STANDARD_WGSL`, `STANDARD_TEXTURE_SLOTS`, `STANDARD_UNIFORM_FLOATS`, `packStandardUniforms`. `harness/pbr.html` (5×5 metal/rough sphere grid, normal-mapped tile) and `e2e/pbr.spec.ts`.
209
+ - Decals (ws/decals, E-015): `Decals(renderer, opts: DecalsOptions)` in `@thunderclap-engine/engine/fx`, a `Drawable` that stamps an atlas (`texture`, `atlas: { columns, rows }`) onto scenery: `add({ x, y, z, nx, ny, nz, size, rotation?, tile?, color?, lifetime? })` returns a handle, `remove(handle)`, `has`, `ageOf`, `clear()`, `count`, `update(dt)` ages and compacts in place; `frame.drawable(decals, order)` draws every live decal in one instanced draw from a preallocated `Float32Array` (`DECAL_INSTANCE_FLOATS` 24: world → local 3×4, atlas rect, colour, age). Two implementations behind one API (`mode: 'auto' | 'box' | 'quad'`): under a depth camera a unit box whose fragment reconstructs the world position from a copy of the previous frame's depth (`WGSL_DECAL_BOX`; depth test and write off; `normalBias`, `fadeEdge`; a decal placed this frame appears next frame), else a surface quad lifted by `offset` (`WGSL_DECAL_QUAD`; depth test on, write off). `blend: 'alpha' | 'multiply' | 'additive'`, `fade: [a0, a1]` over normalised age, the oldest decal is overwritten when `capacity` (default 256) is full. `harness/decals.html` (`?mode=box|quad`, `?capacity=n`) and `e2e/decals.spec.ts`.
210
+ - Sky (ws/sky): `Sky` in `@thunderclap-engine/engine/fx`, a `Drawable` procedural sky drawn as one fullscreen triangle at the far plane (depth test on, write off; `frame.drawable(sky)`): gradient `zenith` → `horizon` → `ground`, sun disc (`sunSize`) and glow (`sunGlow`) at `lights.sun` tinted by its colour, horizon haze towards `lights.fog.color`, night darkening and stars (`stars: true | { density, seed }`, `SkyStars`) once the sun sets, `exposure`. Every field is live; the sun and fog come from the lights block and the sky uploads one 64-byte params block (`SKY_PARAMS_FLOATS`) per frame. The sun disc and stars also go to the emissive target. `skyRay(camera, nx, ny, out)` is the shader's view-ray reconstruction (`SKY_WGSL`) in TypeScript. `harness/sky.html` (`?still` parks the sun at noon) and `e2e/sky.spec.ts`.
211
+ - Bench (ws/bench, STATUS row 5): `harness/bench.html` runs a fixed scene ladder (instances with and without culling, point lights, the sun shadow, sprites, particles; `harness/bench-state.ts` lists the rungs) and publishes per-rung medians of CPU frame time and, with `timestamp-query`, GPU frame time on `window.__harnessBench`; `pnpm bench` (`scripts/bench.mjs`) runs it headless at the `desktop-1280` size and rewrites `docs/BENCH.md`; `e2e/bench.spec.ts` runs the tiny rung on `desktop-1280`. Nothing under `src/` changed.
212
+ - `Gestures.onGesture(listener: GestureListener): () => void` (`GestureListener = (kind, phase, g) => void`): an observer called synchronously at recognition time for every gesture (tap, doubleTap and longPress with phase `'end'`; pan, drag and pinch `start`/`move`/`end`/`cancel`), not per tick; returns the unsubscribe. For tests, replays and logging; the per-tick fields stay the way games read gestures.
213
+ - glTF skins and animations (ws/gltf-anim, E-015): `GltfDocument.skins: GltfSkin[]` (`joints`, `skeleton`, `inverseBind` as 3×4 rows, 12 floats per joint, converted from the file's column-major `MAT4`), `GltfDocument.animations: GltfAnimation[]` in the `anim` module's `AnimationClip` shape (`name`, `duration`, `channels: GltfAnimationChannel[]` with `node`, `path`, `interpolation`, `times`, `values`; cubic-spline tangent triplets kept, quaternions untouched), `GltfNode.skin`, `GltfPrimitive.joints` (`JOINTS_0` of any unsigned type → `Uint8Array` when every index fits, else `Uint16Array`) and `weights` (floats). New `gltf-skin.ts`: `interleaveSkinned(primitive)` packs the skinned vertex layout (`SKINNED_VERTEX_STRIDE` 56: 3D floats + joints `uint8x4` + weights `unorm8x4` summing to 255; `SKINNED_JOINTS_OFFSET`, `SKINNED_WEIGHTS_OFFSET`, type `SkinnedVertices`) and `skeletonFromSkin(doc, i)` builds a `GltfSkeleton` (`parents`, `rest` from node TRS, `inverseBind`, `joints`) in `anim`'s `Skeleton` shape. `GltfScene` gains `skins`, `animations` and `GltfSceneNode.skin`; skinned primitives are built in `VERTEX_LAYOUT_SKINNED` once it lands, `VERTEX_LAYOUT_3D` until then. Types `GltfAnimationPath`, `GltfInterpolation`, `GltfJsonSkin`, `GltfJsonAnimation`, `GltfJsonAnimationChannel`, `GltfJsonAnimationSampler`, `GltfJsonInterpolation`.
214
+ - Snapshot gate (ws/snap-gate, E-008): `pnpm snap` captures the 2D harness pages (`smoke` = index.html, `game`, `ui`, `layout`, `gestures`) on the `desktop-1280` row at a fixed frame into committed goldens `e2e/golden/<page>.png`; `pnpm snap:check` recaptures, compares channel by channel (fails above 1/255, the E-008 tolerance), prints the max channel delta and the differing pixel counts per page and writes `screens/diff/<page>.png`. `e2e/golden.spec.ts` runs that comparison inside `pnpm test:e2e` (skipped on other rows) under Playwright's fake clock, installed and paused before navigation with `performance.now` quantised to the frame grid, so every capture is at frame 12 regardless of load. `scripts/snap.mjs` holds the comparison on Playwright's bundled PNG codec (no engine dependency) and compares files offline (`node scripts/snap.mjs compare a.png b.png [diff.png]`). The integrate brief runs `snap:check` after every merge; 3D pages (`views` included) stay on their own pixel probes.
215
+ - glTF loading (ws/gltf, VISION pillars 2 and 3): `@thunderclap-engine/engine/assets`. `parseGltf(bytes | { json, buffers })` turns a `.glb` or `.gltf` (GLB container, `data:` URIs, strided and normalised accessors, `matrix` or TRS nodes with parent chains, flat normals and generated indices where the file has none) into an engine-free `GltfDocument` (`nodes`, `meshes`, `materials`, `textures`, `images`, `externalUris`, `warnings`, world `bounds`); `loadGltf(renderer, doc, opts?)` builds one `Mesh` per primitive in `VERTEX_LAYOUT_3D`, one `Transform` per node, one sRGB mipmapped `Texture` per image and one lit `Material` per glTF material (`GLTF_LIT_WGSL`; base colour factor as tint, `MASK` cutoff, `BLEND` as alpha), returning a `GltfScene` with `draw(frame)` (allocation-free) and `destroy()`. `LoadGltfOptions`: `resources` for external URIs, `material`, `label`, `mips`, `decodeImage`. Also exported: `GltfError`, `composeTrs`, `decodeDataUri`, `interleave3d` and the document and JSON types. Skins, animation and `KHR_*` extensions are out (`extensionsRequired` throws, `extensionsUsed` warns). `harness/gltf.html` draws a GLB built in the page (`harness/gltf-writer.ts`); `e2e/gltf.spec.ts` checks counts and pixels on the matrix.
216
+ - Attachment sockets (ws/sockets, E-015) in `@thunderclap-engine/engine/anim`: `Socket { joint; offset }` and `SocketLocal { position?; rotation?; scale? }`; `createSocket(skeleton, joint: number | string, local?)` (string joints resolve through the new optional `Skeleton.names: string[]`, checked by `validateSkeleton`; throws when names are missing or unknown), `socketWorld(socket, worldPose, instanceWorld | null, out)` (`instance · world[joint] · offset`, no allocation) and `attachTransform(socket, worldPose, instanceWorld | null, transform)`. `Transform.setMatrix(m, offset?)` in `@thunderclap-engine/engine/render` sets a local 3×4 directly and decomposes position, quaternion and scale from it (2D paths unchanged). `harness/sockets.html` animates a three-joint arm with two attached cubes, one by index and one by name; `e2e/sockets.spec.ts` checks the socket keeps its offset length from the joint while moving, and that `setPaused` parks it (E-017).
217
+ - Animation (ws/anim, E-015): `@thunderclap-engine/engine/anim`, a pure module (no GPU, no `render/` import, no per-frame allocation). `AnimationClip` / `AnimationChannel` with glTF sampler semantics (`STEP`, `LINEAR`, `CUBICSPLINE` with in/out tangents; paths `translation`, `rotation`, `scale`, `weights`) and `sampleClip(clip, t, pose, weight?, blend?, cursor?)` with override and additive blending and a per-channel keyframe cursor (`ClipCursor`, `createClipCursor`, `findKeyframe`, `sampleChannel`, `channelComponents`, `clipDuration`). `Pose` (10 floats per node, `POSE_NODE_FLOATS`) and `Skeleton { parents; rest; inverseBind?; joints? }` with `createPose`, `copyPose`, `blendPose`, `validateSkeleton`, `nodeCount`, `jointCount`; `poseToWorld` → 3×4 row-major world matrices (`WORLD_NODE_FLOATS` = 12 per node) and `skinMatrices` → `world · inverseBind` per joint in the same 12-float layout `render/skin.ts` consumes (16-float glTF inverse bind matrices accepted), plus `inverseBindFromRest`, `writeNodeMatrix`, `multiplyAffine`, `invertAffine`. `AnimationMixer(skeleton, { maxActions })` with `play(clip, PlayOptions)` (layer, weight, loop, speed, fadeIn, blend, startTime), `crossFade(from, to, seconds)` (weights sum to 1), `stop(action, fade?)`, `stopAll`, `update(dt)` → `mixer.pose`, `onEvent(cb)` for `clip.events` crossings; `AnimationAction` exposes settable `weight`, `speed`, `loop`, `time`, `paused` and read-only `active`, `finished`, `fade`, `effectiveWeight`. Quaternion helpers on flat arrays: `quatIdentity`, `quatNormalize`, `quatDot`, `quatMultiply`, `quatSlerp`, `quatNlerp`, `quatFromAxisAngle`, `quatFromEuler`. Tests include a step/linear/cubic-spline golden table, a slerp constant-speed check, crossfade weights summing to 1 and `skinMatrices` identity at rest.
218
+ - Ribbon trails (ws/trails, E-015): `Trail` in `@thunderclap-engine/engine/fx` (`TrailOptions`: `space: '2d' | 'world'`, `maxPoints`, `width` | `widthCurve`, `color` | `colorCurve`, `fade`, `lifetime`, `minDistance`, `facing: 'camera' | 'up' | [x, y, z]`, `blend`, `texture`, `uvMode: 'stretch' | 'tile'`, `softness`) with `push(x, y, z?)`, `update(dt, camera?)`, `draw(frame)`, `clear()`, `count`, `pointAt`. A ring buffer of points becomes two vertices per point in one preallocated array, uploaded with one `Mesh.update` range per frame over a static index buffer (zero allocations after construction); the unlit `TRAIL_WGSL` / `TRAIL_TEXTURED_WGSL` materials fade toward the ribbon's edges. `TrailBatch` draws many ribbons from one mesh. Harness `trails.html` + `e2e/trails.spec.ts`.
219
+ - IK and root motion (ws/ik, E-015) in `@thunderclap-engine/engine/anim`: `createIkChain(skeleton, { root, mid, tip })`, `twoBoneIk(pose, skeleton, worldPose, chain, targetWorld, poleWorld, instanceWorld, { weight?, maxIterations? })` (analytic two-bone solve with a pole vector; unreachable targets straighten the chain; weight blends against the input pose; intermediate twist nodes allowed), `lookAt(pose, skeleton, worldPose, joint, targetWorld, instanceWorld, { weight?, forward?, up?, maxAngle? })`, `extractRootMotion(clip, rootNode, { planar? })` → `{ clip, motion }` (in-place clip keeps the first-frame root translation and yaw; motion carries translation and yaw about +Y relative to the first key; channels copied) and `applyRootMotionDelta(motionClip, t0, t1, outDelta)` (`[dx, dy, dz, yaw]` in the heading frame at `t0`, loop wrap when `t1 < t0`). Both solvers take the `poseToWorld` output of the sampled pose and allocate nothing per call. Harness `ik.html` (a procedural arm following an orbiting target, a head looking at it, 2D canvas, no WebGPU) with `e2e/ik.spec.ts` polling `tipError` while the target moves.
220
+ - Spatial audio (ws/spatial-audio, E-013): `audio.listener` (`SpatialListener`) with `set(position, forward, up?)` and `fromCamera(camera)` (any `CameraLike`: a column-major `view` and a `position`, so the renderer's `Camera` fits), written to the context's `AudioListener` through its AudioParams or the deprecated setters, kept from before unlock. `PlayOptions.at: [x, y, z]` routes a voice gain → `PannerNode` → bus, with `PlayOptions.spatial: SpatialOptions` (`refDistance`, `maxDistance`, `rolloff: Rolloff` = `'linear' | 'inverse' | 'exponential'`, `rolloffFactor`, `panningModel` default `'equalpower'`, `cone: ConeOptions`, `direction`). `Voice.setPosition(x, y, z)` and `Voice.setDirection(x, y, z)` move a playing (or still-pending) spatial voice in place. Panners are pooled: an ended voice returns its panner, the next spatial voice reuses it (`audio.pooledPanners`). `PlayOptions.pan` stays the 2D stereo shorthand; `at` wins over it. Where `createPanner` is missing, a spatial voice plays dry.
221
+ - Look controls (ws/look, VISION pillar 2): `Look` in `@thunderclap-engine/engine/input` reports `look: { dx, dy }` (radians per tick; +dx turns right, +dy looks up) and `move: { x, y }` (-1..1, +y forward, length ≤ 1) from pointer-locked mouse, a two-zone touch layout (a floating move stick in the left half by default, look drags elsewhere), the pad's left and right sticks (`padSensitivity` radians per second × dt) and named move keys. `request(): Promise<boolean>` / `release()` / `locked` / `onLockChange(fn)` manage the pointer lock. Options `LookOptions` (`sensitivity`, `padSensitivity`, `invertY`, `touch: LookTouchOptions`, `padDeadzone`, `pad`, `getGamepads`, `keys: LookKeys`, `blocked`, `now`); types `LookStick`, `LookEvents`, `MoveZone`. `Input.setLook(look | null)`, `getLook()` and `InputOptions.look` link it (shared `blocked()`, attach, advanced in `update()`); `createGame` builds none. `harness/look.html` walks the cube field in first person; `e2e/look.spec.ts` runs it on the matrix.
222
+ - Look zoom axis (ws/look-zoom, E-015): `Look.zoom` (per tick; wheel notches, pinch distance over `stepPx` or stick units; positive is closer) from the mouse wheel (passive listener, `deltaMode` normalised), a two-finger pinch beside a live look drag (never stealing the move stick) and, when `LookOptions.zoom.pad` opts in, the right stick's vertical (`'rightStickY'`, `PAD_ZOOM_PER_S` units per second; the stick then no longer pitches) or the shoulder buttons (`'shoulders'`, ±1 per tick). `LookOptions.zoom: LookZoomOptions` (`wheel`, `pinch` default on, `pad` default `'none'`, `stepPx` 100); `look.wheel(units)` feeds it. `Follow.update` calls `zoom(-look.zoom)` when a `Look` is linked; `harness/follow.html` drops its wheel bindings.
223
+ - Mesh picking (ws/pick, E-015): three transformed hit testers in `@thunderclap-engine/engine/input`, each reading a `RayTransform` (`Pick<Transform, 'world'>`, the 3×4 world matrix) on every call so moving objects pick as drawn, allocation-free after construction: `sphereTester(transform, bounds: RayBounds, target?)` (`render`'s `MeshBounds` in object space; an ellipsoid under non-uniform scale), `obbTester(transform, halfExtents, target?)` and `meshTester(transform, positions: Float32Array, indices: Uint16Array | Uint32Array | null, target?, opts?: MeshTesterOptions)` (`bounds` rejected first, default computed from the positions; `cullBack`; `stride`/`offset` in floats, default `VERTEX_LAYOUT_3D`'s 12 and 0, so a game passes the `Float32Array` it uploaded). Hits carry the world `point`, a unit `normal` facing the ray (`null` when the ray starts inside a sphere or box) and `distance` in world units. Helpers `transformRayInto(ray, worldMatrix3x4, outRay): number` (world units per object unit; 0 when singular) and `rayTriangle(ray, a, b, c, cullBack, outNormal): number` (Möller–Trumbore; -1 for a miss). `harness/pick.html` spins six meshes (cubes, icospheres) each on a `meshTester`; a click or tap tints the hit one; `e2e/pick.spec.ts` picks a mesh at its screen centre and nothing on the sky, by mouse and by touch, on the matrix.
224
+ - Follow camera (ws/follow, E-015): `Follow` in `@thunderclap-engine/engine/input` drives a `Camera` (any `FollowCamera`, `Pick<Camera, 'lookAt'>`) as a third-person camera over `Look`: `target: FollowTarget` (`position`, optional `yaw`), `yaw`, `pitch` (clamped to `pitchMin..pitchMax`), `distance` (`zoom(steps)` by `zoomStep` within `minDistance..maxDistance`), `shoulder: [right, up]`, `eyeHeight`, `smoothing` (time constant of the pivot and of the distance easing back out), `obstacle: FollowObstacle` (`(from, to, outHit) => boolean`, the game's line of sight; the camera moves to the hit minus `margin`), `realign`, `mode: FollowMode` (`'first'` puts the eye at `target + eyeHeight` with the same yaw and pitch). `setLook(look | null)` reads the look deltas; `update(dt)` places the camera through `lookAt`, allocation-free; `position`, `pivot`, `forward` and `currentDistance` report what it did; `snap()` drops the lag. Options `FollowOptions`. `harness/follow.html` moves a capsule between walls with `V` toggling the view; `e2e/follow.spec.ts` checks boot, the toggle and the swing-in on the matrix. `Look` has no zoom axis, so zoom is bound through `Input` (wheel, pad buttons).
225
+ - HUD widgets (ws/ui-hud, E-015): `ui.reticle({ style, size, gap, color })` → `Reticle { el, visible, style, show, hide, pulse, setStyle, remove }` on the `hud` layer at frame centre, hidden (`data-modal`) while a modal is open; `ui.meter({ label, max, value, region, order, tone, width, format })` → `Meter { el, value, max, set(value, max?), setTone, remove }` with `role="meter"` and `aria-valuenow/min/max`; `ui.slots({ count, region, order, hints, label, selected, size })` → `Slots { el, count, selected, select(i) (wraps), setContent, setHint, remove }` as a `listbox` of `option`s with hint badges; `ui.heading({ region, order, width, span, tickEvery, labels })` → `Heading { el, strip, yaw, offset, setYaw(radians), remove }`, a two-turn cardinal strip. Standalone `createReticle/createMeter/createSlots/createHeading(host: HudHost, opts)`, `themePx`, `wrapDegrees`, `headingOffset`, `HUD_CSS`, `ensureHudStyles`. Sizes are theme px (`n * --tc-space-1 / 4`), so they follow `ui.units`. Harness `hud.html` + `e2e/hud.spec.ts`.
226
+ - World-anchored DOM (ws/anchors, VISION pillar 5): `Anchors` in `@thunderclap-engine/engine/ui` (`ui/anchor.ts`) with `add(el, world, opts?: AnchorOptions): AnchorHandle`, `update()`, `setCamera`, `clear`, `size`. Options `offset`, `align`, `clamp` + `margin`, `scale: 'none' | 'distance'` + `distance`/`min`/`max`, `hideBehind` (default true), `layer`; handles expose `el`, `visible`, `clamped`, `x`, `y`, `scale`, `set(world)`, `remove()`. Types `AnchorCamera`, `AnchorSurface`, `AnchorRect`, `AnchorsOptions`. `Overlay.anchors(camera?)` builds the overlay's one `Anchors` on the `hud` layer and `Overlay.updateAnchors()` updates it; `createGame` calls the latter after `hooks.afterRender`. `harness/anchors.html` and `e2e/anchors.spec.ts` check label placement against the camera's projection, hiding behind the camera and the clamped marker, on the matrix.
227
+ - Texture arrays and GPU mipmaps (ws/texture-array, E-013; VISION pillar 3, Forgewilds' block faces), in `@thunderclap-engine/engine/render`: `TextureDesc.layers` (a `'2d-array'` view) and `TextureDesc.mips` (full chain), `Texture.layers` / `mipLevels` / `viewDimension`, `mipLevelCount(width, height)`; `createTextureArray(device, desc: TextureArrayDesc)` and `loadTextureArray(device, sources: TextureLayerSource[], opts?: LoadTextureArrayOptions)` (`{ mips? = true, mipFilter?, format? = 'rgba8unorm-srgb', label? }`; one image or `PixelSource` per layer, mismatched sizes throw); `generateMipmaps(device, texture, { filter?: MipFilter })` (`render/mipmap.ts`, a blit pipeline cached per device, format and filter, `'linear'` or `'nearest'`, also for 2D textures made with `mips: true`; types `GenerateMipmapsOptions`, `MipFilter`); `SamplerKind` gains `'linear-mip-repeat'` and `'nearest-mip-clamp'`; `viewDimension?: GPUTextureViewDimension | GPUTextureViewDimension[]` on `MaterialDesc` and `FullscreenPassDesc` makes `'2d-array'` slots `texture_2d_array<f32>` (default a shared 1×1 white array). `harness/stream.html` textures its chunks from a 4-layer generated array, the layer in each vertex colour's alpha; `e2e/stream.spec.ts` checks two neighbouring chunks show two layer colours.
228
+ - World-space particles (ws/particles-3d, E-013): `ParticleSystemOptions.space: '2d' | 'world'` (default `'2d'`; 2D output is unchanged, its shaders pinned by a test). World systems simulate in the same compute kernels with three components (a 112-byte particle) and draw quads facing the camera, built from the `camera.view` rows and stretched along the projected velocity, depth-tested without depth writes. `EmitParams` gains `z`, `vz`, `wind` (target velocity `drag` pulls towards) and 3-component `gravity`; `EmitShape` gains `box { width, height, depth }` and `sphere { radius }` (in 2D they act as `rect` and `circle`) and `line.dz`. New options: `blend: 'additive' | 'alpha'`, and for world space `lit` (sphere-normal `engine_lambert` through `WGSL_LIGHTS`), `fade: { near, far }` (distance to the camera), `softness` (soft particles against scene depth; default 0.25) and `depthTarget`; `countAlive` reads the live count back into `ParticleSystem.alive`. Emissive still goes to the emissive target. `SceneTarget`'s depth texture gains `TEXTURE_BINDING` so `depth` can be sampled outside its pass. `harness/weather.html` shows rain, snow and dust motes near a point light over the mesh scene; `e2e/weather.spec.ts` checks it boots, both systems are alive and the rain column differs from the clear sky, on the whole matrix.
229
+ - Sun shadows and fog (ws/shadows, VISION pillar 4): `Lights.sun.shadow: SunShadow | null` (`extent`, `size` 1024/2048/4096, `near`, `far`, `bias`) makes the renderer draw one depth-only shadow pass before each view's scene pass, through the new `ShadowMap` (`src/render/shadow.ts`; `SHADOW_FORMAT`, `DEFAULT_SHADOW_SIZE`, `DEFAULT_SHADOW_BIAS`, `ShadowUniforms`), over the opaque draws with `InstanceParams.castShadow` (default true for 3D vertex layouts, false for `VERTEX_LAYOUT_2D`, so 2D scenes are untouched); `receiveShadow: false` sets instance flag bit 0, read in WGSL as `VSOut.flags`. `Lights.fog: Fog` (`color`, `density`, `near`, `far`) is linear or exponential distance fog. `EngineLights` gains `sun_view_proj`, `shadow_params`, `fog`, `fog_range` (`LIGHTS_BLOCK_FLOATS` 140 → 168; `SUN_VIEW_PROJ_OFFSET_FLOATS`, `SHADOW_PARAMS_OFFSET_FLOATS`, `FOG_OFFSET_FLOATS`; one uniform buffer per view is now 1184 bytes), `renderer.globalsLayout` gains `engine_shadow_map` (`texture_depth_2d`, binding 3) and `engine_shadow_sampler` (comparison, binding 4), and the prelude gains `engine_shadow(world_pos)` (3×3 PCF) and `engine_fog(color, world_pos)`; `engine_lambert` multiplies the sun by `engine_shadow`. `Lights.writeBlock` takes an optional fourth `ShadowUniforms` argument. The mesh demo casts shadows and fogs its ground; `e2e/mesh.spec.ts` checks both.
230
+ - Post stack in VR (ws/xr-post): the XR render step renders each eye into the surface's eye-sized HDR `scene` target and resolves it through the post stack into the eye's part of the session's layer, so a headset shows the same bloom, tone map and grade as the screen. Non-XR frames are byte-identical.
231
+ - `@thunderclap-engine/engine/render`: `renderer.resolve(target: SceneTarget, out: GPUTextureView, viewport?: SceneTargetViewport)` runs `renderer.post` (or the built-in present) from any target into any view inside the frame, confined to `viewport` (loading the rest) when given, and consumes the target so its next `render` this frame clears again; `PostProcessor.encode(encoder, inputs, output, viewport?)` gains the optional viewport and processors size themselves to their inputs; `FullscreenEncodeOptions.viewport` sets viewport and scissor on a fullscreen pass.
232
+ - `@thunderclap-engine/engine/post`: `PostStack.encode` honours the viewport (no clear, viewport + scissor on the composite or upscale; `compositeAtScene` compares with the viewport) and resizes itself once when the inputs change size.
233
+ - `@thunderclap-engine/engine/platform`: `XrSurfaceOptions.scene?: boolean` and `XrSurface.scene: SceneTarget | null` (eye-sized HDR target, allocated on the first posed frame, resized on eye-size change, freed by `destroy`); in that mode eye cameras cover the whole target, the layer takes `colorFormat` and `depthStencilFormat` defaults to null. `createGame` passes `{ scene: true, colorFormat: renderer.format }`.
234
+ - Tests: `renderer.resolve` on the fake GPU (viewports, load ops, target reuse, zero steady-state allocations), `src/post/post-stack.test.ts`, `XrSurface` scene mode and the fake-XR game test (two composite passes at the eye viewports; the fake GPU now records `setViewport`/`setScissorRect` arguments).
235
+ - WebXR (ws/xr, E-013, VISION: VR): `createGame({ xr?: XrOptions })` (`XrOptions { referenceSpace?: 'local-floor' | 'local' | 'viewer'; onEnter?(session); onExit?() }`) and `ctx.xr: XrControl { supported(): Promise<boolean>; enter(): Promise<void>; exit(): Promise<void>; active; session; surface }`. `enter()` requests an `immersive-vr` session with `requiredFeatures: ['webgpu', referenceSpace]` (`hand-tracking` optional), builds the XR host and surface, swaps the loop onto the session's frames, records the scenes once through the head camera and renders each eye with `renderer.render(frame, eye.camera, eye.target)` into the session's projection layer, then `renderer.endFrame({ present: false })` (the post stack per eye followed in ws/xr-post); `exit()` or the session ending restores the page host, removes the XR pointers and destroys the surface; `supported()` is false without `navigator.xr` or the WebGPU binding, and `enter()` rejects then; non-XR frames are byte-identical. New: `createXrHost(session, base): XrHost` (`kind: 'xr'`, `frame` the current `XRFrame`; `HostKind` gains `'xr'`) in `./host`; `XrSurface(session, device, opts: XrSurfaceOptions)` (`XRGPUBinding` projection layer in `SCENE_FORMAT`/`depth24plus`, `'texture'` or `'texture-array'`; `views(frame): XrEyeView[]` of `index`, `eye`, `camera`, `target`, `viewport`; `head`, `supported()`, `destroy()`) and the ambient WebXR types (`src/platform/xr-types.d.ts`) in `./platform`; `SceneTarget.external(desc: SceneTargetExternal)` (adopted colour and optional depth, `isExternal`, `adoptedColor`, `viewport: SceneTargetViewport | null`, `adopt(desc)`), `Camera.setViewMatrix(m)` and `renderer.endFrame(opts?: { present?: boolean })` in `./render`; `XrPointerSource(session, referenceSpace, frame, opts?: XrPointerSourceOptions)` (one ray per `XRInputSource`, `select = trigger || pinch`, `secondary = squeeze`, pinch hysteresis in metres) in `./input`; `GameLoop.setFrames(src)` in `./core`. `harness/xr.html` ("Enter VR") and `e2e/xr.spec.ts` prove the page path on the matrix; unit tests run over an in-repo fake XR stack (`src/platform/xr-fake.ts`).
236
+ - Lighting block (ws/lights, VISION pillar 4): `Lights` in `@thunderclap-engine/engine/render` (ambient rgb, `sun { dir, color }`, `points: PointLight[]` with `position`, `color`, `radius`; `MAX_POINT_LIGHTS` = 16; `writeBlock(f32, u32, offset)`, `reset()`), carried by `frame.lights` (default unlit: ambient 1, no sun, no points, so existing materials render identically). The renderer uploads it as `EngineLights` at `@group(0) @binding(2)` beside the globals and camera (one buffer per view, now 1072 bytes; `renderer.globalsLayout` gains binding 2). `materialPrelude` declares the block (`lights.ambient`, `point_count`, `sun_dir`, `sun_color`, `points[i].position / radius / color`) and the helpers `engine_lambert(normal, world_pos)` and `engine_point_lights(normal, world_pos)`; `WGSL_LIGHTS` exports it for drawables. Also `DirectionalLight`, `PointLight`, `LIGHTS_BLOCK_FLOATS`, `POINT_LIGHT_FLOATS`, `POINT_LIGHTS_OFFSET_FLOATS`. The mesh demo (`harness/mesh.html`) now lights its scene through the block: a sun and a circling warm point light; `e2e/mesh.spec.ts` checks them.
237
+ - `templates/puzzle` (`@thunderclap-engine/template-puzzle`, ws/template-puzzle): a copyable 2D puzzle game (pairs) wired the way the next game should be: `createGame` with a save and a layout spec, one scene drawing with `frame.sprite` and `Transform` and fitting `renderer.camera` to `layout.freeRect`, pure `rules/` with unit tests, a versioned save schema with a v1 → v2 migration, a HUD placed through `ctx.ui.layout`, and a `CLAUDE.md` naming the engine concepts. Workspace gains `templates/*`; root scripts `check:template-puzzle` (in `pnpm check`) and `template:puzzle`.
238
+ - `@thunderclap-engine/engine/analytics` (E-002, ws/analytics): `createAnalytics<E>({ sink, session?, clock?, context?, onError?, maxBatch? })` with typed `emit`, batched `flush`, built-in `game_start`, `session_end` and `device_lost`, and `attach(ctx)`, which wraps `ctx.hooks.deviceLost` and flushes on hidden/pagehide/freeze. No network sink ships; the game supplies one.
239
+ - Client mode (ws/client-mode, VISION pillar 6): `createGame` serves a pure client of a server-authoritative game.
240
+ - `CreateGameOptions.save` is optional. Without it, `S` defaults to `null`, `ctx.save` is `null` (typed `null`), hide and device loss flush nothing, and `host.storage` is never read.
241
+ - `CreateGameOptions.sim?: SimOption` (`{ step?: number } | false`). The default keeps the fixed 1/60 s step (`step` sets another); `false` runs the loop at render rate: input, pad nav, `hooks.frame(dt)`, particles and the scenes' `update(dt)` run once per rendered frame with the real delta, and `hooks.update` does not run.
242
+ - `GameHooks.frame?(dt)` (client mode only) and `GameHooks.show?(reason: ShowReason)` (after the loop restarts on show).
243
+ - `GameLoopOptions.fixed?: boolean` (default true): `false` calls `update` once per frame with the real clamped delta and renders with `alpha` 1.
244
+ - Types `HideReason`, `ShowReason` and `SimOption` from `@thunderclap-engine/engine`.
245
+ - Harness `game.html?mode=client` and `e2e/client.spec.ts`: client mode renders, updates once per frame, takes input, hides and shows, and never reads `host.storage`.
246
+ - Two views (ws/two-views, E-010 wave 3): `Camera.viewIndex` (u32, default 0) is the `view_index` the camera block carries; the renderer writes it as given instead of the view's position in the frame, so a stereo pair sets 0 and 1 whatever else renders first. `harness/views.html` draws the mesh demo's scene twice per frame through two `Camera.perspective` eyes ±0.032 m apart on the head's right axis, `screen` the left and right halves of one surface (unit-square fractions), `viewIndex` 0/1, in one `beginFrame` / `render()` × 2 / `endFrame`; `e2e/views.spec.ts` samples both halves to prove each draws the scene with depth, that a head-locked near block lands at different places in the two halves (parallax), and that its colour and depth stay in its own half, on the whole matrix. The stereo seam without WebXR.
247
+ - Dynamic meshes (ws/mesh-stream): `MeshDesc.capacity: { vertices?, indices? }` and `indexFormat?`, `vertices` optional (a mesh may start empty); `mesh.capacity: MeshCapacity`; `mesh.update(u: MeshUpdate): boolean` with `{ vertices?: ArrayBufferView, indices?: ArrayBufferView, vertexCount?, indexCount? }`, writing each view in place from its own `ArrayBuffer` (a Worker's transferred buffer uploads with no main-thread copy), reusing the buffers within capacity, growing 1.5× otherwise (returns true; `mesh.growths` counts) and moving the draw range from counts alone. `harness/stream.html` streams 16 chunk meshes from a Worker, one re-mesh per frame, and shows vertices uploaded per frame; `e2e/stream.spec.ts` runs it on the matrix.
248
+ - Shadow casters vs cull (ws/shadow-cull, E-016): the shadow pass draws its own command list, culled against the sun's orthographic box (`ShadowMap.sunCamera.frustum`) instead of the view frustum, so a caster behind or beside the camera still shadows the visible scene; the list is sorted by mesh then material and its instance copies follow the scene's in the view's upload. `renderer.stats.shadowDraws` counts the instances drawn into the map per frame; `stats.culled` and `stats.instances` stay scene-pass counts.
249
+ - Frustum culling (ws/cull): `mesh.bounds: MeshBounds` (`{ center, radius }` in mesh space) from `MeshDesc.bounds` / `MeshUpdate.bounds` or, with `autoBounds` (default on under `AUTO_BOUNDS_MAX_VERTICES` = 65536 vertices of capacity), computed from the uploaded positions; `frame.mesh` carries it through the instance matrix (radius scaled by the largest axis) into a per-draw world sphere, sprites get none. `camera.cull` (on for `perspective`, off for `ortho2d`), `camera.frustum` (six planes of `viewProjection` as a flat `Float32Array` of `FRUSTUM_FLOATS` = 24, cached) and `camera.seesSphere(x, y, z, r)`. The renderer skips culled draws before sorting with no per-frame allocation and reports them in `renderer.stats.culled`, summed over views. `harness/stream.html` passes each chunk's bounds from its Worker, shows `culled` in the HUD and `state.stats`, and `api.look('edge')` aims the camera so about half the chunks leave the frustum; `e2e/stream.spec.ts` asserts they are culled and not drawn.
250
+ - Breaking: the positional `mesh.update(vertices, indices?, vertexCount?, indexCount?)` and `MeshDesc.maxVertices`/`maxIndices` are replaced by the object forms above.
251
+ - Mesh demo (ws/demo-mesh, E-001): `harness/mesh.html` draws a lit `VERTEX_LAYOUT_3D` cube and ground through `Transform`s under `Camera.perspective` with the depth buffer on, orbiting by pointer drag or right stick and zooming by wheel, pinch or triggers; `e2e/mesh.spec.ts` samples the presented pixels to prove a near block occludes a later-drawn far one, on the whole matrix.
252
+ - `HostWindow.setTitle?(title)`: optional, for hosts with a window of their own. The Electron host (`@thunderclap-engine/host-electron`, `packages/host-electron`) implements it.
253
+ - Layout regions (D-041 Phase 2, ws/layout): `overlay.layout` (built by `createOverlay({ layout })`), with `LayoutSpec`, `FormFactor`, `RegionName`, `classifyFormFactor`, `resolveLayout`, `resolveTracks`, `parseAreas`, `effectiveInsets`, `readSafeArea` and `defaultForms`.
254
+ - Four form factors (`phone-landscape`, `phone-portrait`, `tablet`, `desktop`), each with px/fraction/fill tracks and grid areas for `top`, `left`, `right`, `bottom`, `cardLane`, `captionLane` and `fill`, plus a `modal` rect. All of them sit inside the safe area.
255
+ - Widgets register once with a region per form factor and move when it changes. The escape hatch `{ region: 'free', rect, reason }` is logged and listed in `layout.freeUses`.
256
+ - `layout.freeRect` (the `fill` region, CSS px) and `onChange` are there for the camera. Tracks with no widgets collapse.
257
+ - `layout.dockCaption()` places the caption card in `captionLane`. `caption.dock(host)` is new.
258
+ - One overlay in CSS px on every form factor (ws/one-overlay, D-044): `overlay.frame`, `overlay.setFrame(rect)`, `overlay.onResize(fn)`, `overlay.units` (`Units`: `'frame' | 'css'`, also `data-units`), `overlay.layout`, the `layout` layer, `FormSpec.units`, `LayoutState.units`, `LayoutHost`, `toFrameUnits(css)` and `UNIT_VAR` (`--tc-unit`). The root carries `--tc-frame-x/y/w/h`, `--tc-dpx` and `--tc-zoom`. See ARCHITECTURE.md, "Layout".
259
+ - `CreateGameOptions.layout` (the overlay's `LayoutSpec`); a game reads the layout as `ctx.ui.layout`.
260
+ - `caption.onPlace(fn)` and `caption.relayout()`: the game steers the undocked card (with `overlapArea` and `cornerOrder`).
261
+ - Engine e2e: "one overlay: a phone and a desktop share one root, and nothing moves between layers" in `e2e/layout.spec.ts` (also: a HUD z-index stays under an open panel); the UI harness puts its probes on the frame through `toFrameUnits` and checks units per row.
262
+ - In `css` units, buttons, toggles and sliders honour `--tc-tap-min` (44 on touch forms), and panels and toasts honour the safe area and the modal region. `textScaleTokens` takes a base size table (`DESIGN_FONT_PX`, `CSS_FONT_PX`).
263
+ - The engine e2e has a layout harness page (`harness/layout.html`) and `e2e/layout.spec.ts`. It checks every matrix row plus two new upright rows (`phone-port` 390×844 and `tablet-port` 820×1180) for region placement, no overlap, the safe area (insets emulated through CDP), 44 px taps on touch, rotation and the modal at 150% text.
264
+
265
+ - `Surface.setFrame(rect | null)`: the canvas letterboxes inside a part of the container (CSS px), so a scene can frame the world into `layout.freeRect`; `null` restores the whole container. `clientToLogical` follows it.
266
+ - Height fill (ws/chrome-polish-3): `FillWidthOptions.minVisibleWidth` and `focusX`. Where a frame is too narrow to show `minVisibleWidth` of the design at full height, the fit fills the frame's height and crops the sides about `focusX`, the same way `minVisible`/`focusY` crops top and bottom. `computeFillWidth` covers both; `surface.setFill` compares the new fields. Tests are in `platform/letterbox.test.ts`.
267
+ - Engine e2e: "panel-stack spam leaves at most one panel" in `e2e/layout.spec.ts` (real taps on the harness ⚙, then 20 `stack.replace` opens in one tick).
268
+ - Matrix camera (Phase 3, ws/camera, E-003): `Camera` in `@thunderclap-engine/engine/render` with cached `view`, `projection` and `viewProjection` (column-major `Float32Array(16)`), `position`, `worldToScreen(x, y, z?, out?)`, `screenToWorld(sx, sy, depth?, out?)`, `fitRect(rect, viewport?)`, `depthTest`/`depthWrite` flags, a `layers` membership mask (u32, default `ALL_LAYERS`; `sees(mask)`), `screen` (the rect clip space spans, in screen units), `setView`, `setProjection`, `setPerspective`, `lookAt` and `setOrtho2d`. Presets `Camera.ortho2d(designW, designH)` (1 unit = 1 design px, origin top-left, y down, depth off) and `Camera.perspective(fovY, aspect, near, far)` (depth on). Types `CameraRect`, `CameraPoint`; constant `CAMERA_BLOCK_FLOATS`.
269
+ - `mat4` and `Mat4` in `@thunderclap-engine/engine/core` (`core/math.ts`): `create`, `identity`, `copy`, `multiply`, `ortho`, `perspective` (finite or infinite far), `lookAt`, `view2d`, `invert`, `transformPoint`. Right-handed, WebGPU 0..1 depth, no allocation when the caller keeps its matrices.
270
+ - The camera block, `@group(0) @binding(1) var<uniform> camera: EngineCamera` in every material and in `WGSL_GLOBALS`: `view`, `proj`, `view_proj`, `camera_pos`, `viewport` (scene target px) and `view_index` (0 today).
271
+ - Procedural geometry (ws/geom, VISION pillar 3): `@thunderclap-engine/engine/geom`, a pure module (no GPU, only type imports from `render/`). `Geometry { vertices; indices; bounds; layout }` is a `MeshDesc`, so `renderer.createMesh(geometry)` takes it unchanged; indices are `Uint16Array` up to `UINT16_MAX_VERTICES` and `Uint32Array` past it (`createIndices`). Builders `box(BoxOptions)`, `plane(PlaneOptions)`, `sphere(SphereOptions)` (UV), `icosphere(IcosphereOptions)`, `capsule(CapsuleOptions)` (`height` is the total), `cylinder(CylinderOptions)` (`radius` number or `[top, bottom]`, a zero end is a cone, `caps`), `torus(TorusOptions)`, each with `GeometryOptions` (`color: Rgba`, `uvScale`), counter-clockwise from outside, unit normals flat or smooth per surface. `Heightfield { width; depth; heights; scale; origin }` with `createHeightfield(width, depth, HeightfieldOptions)`, `heightAt(hf, x, z)` (bilinear, clamped), `normalAt(hf, x, z, out)`, `raycastHeightfield(hf, ray, out?)` (DDA over cells with each bilinear patch solved exactly; distance or -1; `HeightfieldRay`, `HeightfieldVec3`), `heightfieldGeometry(hf, HeightfieldGeometryOptions)` (smooth, or `flat: true`). `mergeGeometry(...parts)` (offsets indices, promotes to 32-bit), `transformGeometry(g, world3x4)` (positions, inverse-transpose normals, bounds, in place), `GeometryWriter`, `computeBounds`, `GEOMETRY_FLOATS`. `harness/geom.html` draws every builder turning and lit over a heightfield from a sine bump map; `e2e/geom.spec.ts` checks the shape count, a lit probe pixel per shape and `heightAt` under the camera on the matrix.
272
+
273
+ ### Game plumbing (ws/plumbing, E-010)
274
+ - `createGame` builds the `Gestures` and links them to `input` (`EngineCtx.gestures`); `CreateGameOptions.input.magnify?: MagnifyOptions` is their `magnify`. A game no longer calls `input.setGestures` itself.
275
+ - `EngineCtx.camera`: the renderer's default `Camera`, the one the scenes draw through. `Scene.render(frame, camera, alpha)` and `SceneStack.render(frame, camera, alpha)` take it (`Scene<Ctx, Frame, Cam = Camera>`); the loop calls `stack.render(frame, ctx.camera, alpha)` then `renderer.render(frame, ctx.camera)` before `endFrame()`, so no scene reads `renderer.camera`.
276
+ - `Camera.fitFreeRect(worldRect, free, surface)`: `fitRect` into a layout's free rect given in CSS px (`ui.layout.freeRect`) through `surface.rect` (any `{ rect }` in the same CSS px); no allocation. `templates/puzzle` uses it instead of converting by hand.
277
+ - `Gestures`: while two or more pointers are down, `pan` and `drag` report nothing (the second pointer cancels them and starts the pinch). The two-finger pan is gone; `PanGesture.pointers` is always `1`.
278
+ - `templates/puzzle`: `src/analytics.ts` with `createBeaconSink(url)` (a `navigator.sendBeacon` sink, built only when `COLLECT_URL` is set) and the events `level_start` / `level_complete`, now the convention documented in `analytics/README.md`; `ctx.analytics`, `Game.dealtAt`. The template takes `gestures` and `camera` from the engine ctx.
279
+ - `ui/README.md`: `top`/`bottom` regions lay widgets out in a row, `left`/`right` (and `cardLane`) in a column.
280
+ - Removed: `Surface.setFill`, `computeFillWidth`, `FillWidthOptions` (no engine caller since ws/render-call; `fitFreeRect` is the replacement). `platform/device-mode.ts` stays.
281
+
282
+ ### Render call (ws/render-call, D-044, E-006, E-009)
283
+ - `renderer.render(frame, camera, target = renderer.target)` draws the frame's recorded draws for one camera into a `SceneTarget`. Call it once per camera between `beginFrame` and `endFrame`: two cameras in one frame (split screen through `camera.screen`, UI over world through layers) share one instance buffer and one pooled uniform buffer and bind group per view, so steady state allocates nothing; the first view into a target clears it, later ones load. `endFrame` renders `renderer.camera` itself when nothing rendered, so single-camera callers are unchanged (same pixels). `camera.screen` inside the unit square is a fraction of the target (viewport and scissor), else logical px.
284
+ - `SceneTarget` (`renderer.target`; `renderer.createSceneTarget({ width, height, label? })` for offscreen views) with `color`, `emissive` (`SCENE_FORMAT`), `depth` (made on first use by a depth camera), `resize`, `destroy`. `renderer.sceneColor` / `sceneEmissive` still read `renderer.target`'s.
285
+ - `Host.requestFrame(cb)` / `cancelFrame(handle)` (`FrameSource` in `@thunderclap-engine/engine/host`), over the window's `requestAnimationFrame` on web and Electron. `GameLoopOptions.frames?: FrameSource` (default `defaultHost()`, read on the first `start()`); `createGame` passes its host. `GameLoop` no longer calls `requestAnimationFrame`.
286
+ - `Camera.ortho2d` spans world z in ±`ORTHO2D_HALF_DEPTH` (1024, exported from `./render`), looking down +z (larger z is farther; z = 0 at clip depth 0.5); x and y output are unchanged. `screenToWorld(sx, sy, 0)` is now on the near plane at z = -1024, so a pointer ray through an ortho camera starts there (`input/rays.ts`).
287
+ - The depth sort key is clip-space depth from the camera passed to `render`, so the sort agrees with the depth buffer for every projection.
288
+ - `ParticleSystem.draw(pass, frame)` uses a pipeline with `depthStencil { format: frame.depthFormat, depthWriteEnabled: false, depthCompare: 'less-equal' }` when the frame has depth (one pipeline per format, built lazily); `Drawable.draw` gets the `Frame` for the same purpose.
289
+ - `EngineCtx.pointers: PointerRouter<unknown>`: `createGame` builds `createPointerRouter({ camera: () => renderer.camera })` and links it with `input.setPointers`, so hit testers and `hover`/`select` work on every game out of the box.
290
+
291
+ ### Pointer rays (ws/input-rays)
292
+ - Rays in `@thunderclap-engine/engine/input` (`input/rays.ts`): `Ray`, `Vec3`, `Hit<T>`, `HitTester<T>`, `RayCamera`, `Aabb<T>`, `createRay()`, `createHit(target)`, `rayAt(ray, t, out)`, `screenRay(camera, sx, sy, out)` (camera `screenToWorld` near and far, normalised, no allocation), `planeTester(z?, target?)` and `aabbTester(boxes)` (slab test over a live array, flat quads included; the nearest box wins, a ray starting inside hits at 0).
293
+ - `createPointerRouter(opts)` / `PointerRouter<T>` (`input/pointer-router.ts`): options `camera` (a camera or a getter), `testers`, `sources`, `selectAction` ('select'), `secondaryAction` ('secondary'), `blocked`. Members `pointers`, `primary`, `hit`, `camera`, `screen`, `on('hit' | 'hitEnd' | 'select' | 'secondary', fn)`, `addTester`, `addSource`, `held`, `fired`, `xrHeld`, `xrFired`, `update()`, `poll()`, `resolve(actions)`, `attach`, `detach`. Types `RoutedPointer`, `PointerHitEvent`, `PointerRouterEvents`, `PointerRouterOptions`, `PointerActions`.
294
+ - Pointer sources (`input/pointer-sources.ts`): the seam `PointerSource { poll(camera); attach?(target); detach?() }` with `PointerSample`, `PointerDevice`, `XrButtons` and `createSample(key, device)`; `ScreenPointers` (mouse, pen, touch, honouring the magnify aim; feed methods for tests) and `PadCursor` (`PadCursorOptions`: `getGamepads`, `pad`, `axes`, `deadzone` 0.2, `speed` 0.012 of the short screen side per tick, `selectButton` 0, `secondaryButton` 2; `moveTo`, `hide`).
295
+ - `Input.setPointers(router)`, `getPointers()` and the `pointers` option; binding kinds `{ pointer: PointerButton }` and `{ xr: XrButton, hand? }` with `PointerButton`, `XrButton`, `Hand`. XR is a seam only: no WebXR calls ship.
296
+ - Engine tests: `rays.test.ts`, `pointer-router.test.ts`, `pointer-router.dom.test.ts`; harness page `harness/rays.html` (2D canvas, ortho quads or `?mode=persp` boxes, hover and select toggles, a fake pad) and `e2e/rays.spec.ts` on the whole matrix (mouse, CDP touch, itch iframe, pad cursor, perspective picking, `blocked()`).
297
+
298
+ ### Removed (ws/prune, E-013)
299
+ Internals that leaked into the public surface; the engine still uses each one inside its module.
300
+ - `@thunderclap-engine/engine/render`: `BindingSet`, `BindingDesc`, `BindingSetOptions` (use `Material` or `FullscreenPass`); `blendState` and `createSamplers` (a hand-built pipeline writes its own `GPUBlendState` and samplers); `CAMERA_BLOCK_FLOATS` (`WGSL_GLOBALS` declares the block; the renderer binds it); `MATRIX3X4_FLOATS`, `multiplyMatrix3x4`, `writeMatrix2d` (use `Transform`, whose `parent` composes); `WGSL_MATERIAL_PRELUDE` (use `materialPrelude(VERTEX_LAYOUT_2D)`).
301
+ - `@thunderclap-engine/engine/input`: `loupeSpot`, `loupeClear`, `magnifySize` (a game drawing its own loupe places it at `aim.at`; the size is its own `MagnifyOptions.size`).
302
+ - Kept, with the reason in the module README: `INSTANCE_BUFFER_LAYOUT` (a drawable's pipeline over the shared instance format), `DepthMode` (`material.pipelineFor`'s argument), `GpuTimer` (the type of `renderer.gpuTimer`, which a custom `PostProcessor` labels passes with), `PointerActions` (`router.resolve`'s argument), `createSample` (a custom `PointerSource`).
303
+ - `src/render/mesh.ts`: the `indexCount` doc comment now says the renderer skips an indexed draw with `indexCount` 0, as it does.
304
+
305
+ ### Removed (ws/render-call, D-044)
306
+ - `EngineCtx.device`, `EngineCtx.setFill(key)` and `CreateGameOptions.fillBands`: a 2D game frames `ui.layout.freeRect` with `renderer.camera.fitRect` instead. `createGame` no longer builds a device mode, masks the canvas or calls `surface.setFill`.
307
+ - `GameLoopOptions.raf` / `caf`: pass `frames: FrameSource`.
308
+
309
+ ### Removed (ws/one-overlay, D-044)
310
+ - The scaled overlay: `createOverlay({ legacyScale })`, `overlay.legacyScale`, `overlay.setRect()` and `overlay.onRect()` (use `setFrame` and `onResize`), and `OverlayOptions.designWidth/Height` as a layout size (they are now only the frame's design size).
311
+ - The second overlay: `createCssChrome`, `CssChrome`, `CssChromeOptions`, `PHONE_FORMS` (`ui/css-chrome.ts`), `EngineCtx.chrome` and `CreateGameOptions.overlay`.
312
+ - `caption.avoid()` and `CaptionOptions.minWidth`: the corner and width search moves to the game, through `caption.onPlace`.
313
+ - `createLayout` from `./ui` (the overlay builds its own layout) and `PHONE_TAP_MIN` / `PHONE_TEXT_MIN` from `./platform` (the layout's `FormSpec` owns the density tokens).
314
+
315
+ - Transforms, layers and instances (ws/transforms), in `./render`:
316
+ - `Transform` (position xyz, `rotation` about Z or a quaternion via `setQuaternion` / `setAxisAngle`, scale xyz, optional `parent`) with a cached 3×4 `world` matrix; `writeMatrix2d`, `multiplyMatrix3x4`, `MATRIX3X4_FLOATS`.
317
+ - `frame.sprite(mat, transform, params?)` and `frame.mesh(mesh, mat, transform, params?)` overloads; `InstanceParams` is `DrawParams` without placement.
318
+ - `layers` (u32 mask, default 1) and `order` (sort key, default 0) on every draw, and `frame.drawable(d, order?, layers?)`. Draws skip when `layers & camera.layers === 0` (a camera without `layers` sees all).
319
+ - Optional depth: `camera.depthTest` (with `depthWrite`; both on for `Camera.perspective`, off for `ortho2d`) allocates a `depth24plus` attachment (`DEPTH_FORMAT`, `frame.depthFormat`) and sorts opaque front-to-back, blended back-to-front, by view-space depth. `engine_vertex` projects the instance's world xyz (`camera.view_proj * vec4(world, 1)`), so a draw's z reaches the depth buffer; under `ortho2d` the visible depth range is world z in ±`ORTHO2D_HALF_DEPTH` (1024), larger z farther (see "Render call").
320
+ - Vertex layouts per mesh: `VertexLayout`, `VERTEX_LAYOUT_2D` (unchanged 8 floats) and `VERTEX_LAYOUT_3D` (xyz, normal, uv, rgba = 12 floats; `VERTEX3D_*`), `MeshDesc.layout`, `mesh.layout`, `mesh.vertexBufferLayout`. `material.pipelineFor(layout, depth)` builds pipelines lazily per (layout, depth mode). `materialPrelude(layout)`, `INSTANCE_OFFSET`, `DepthMode`.
321
+ - WGSL: `VSOut.normal` (@location 7) and `VSOut.worldPos` (@location 8); helpers `engine_local_position`, `engine_local_normal`, `engine_world_position`.
322
+
323
+ ### Changed
324
+ - One shared light loop (ws/pbr-lights): `wgslLightLoop(name, brdf, params?)` and `WGSL_LAMBERT_BRDF` in `@thunderclap-engine/engine/render` generate the WGSL of a sun + points + spots + masks accumulator around any `vec3f` BRDF expression (WGSL has no function pointers, so the BRDF is inlined). `engine_light_accum` is now that generator over the Lambert BRDF (same arithmetic; `mesh.html` and `lights.html` probes unchanged) and `WGSL_PBR` gains `engine_pbr_accum` (the generator over `engine_brdf`) and `engine_pbr_layers(..., layer_mask)`; `engine_pbr` keeps its signature over every layer. `createStandardMaterial`'s missing-map defaults come from `defaultTextures(device)` (`white`, `flatNormal`, `black`) instead of four textures per material, and `Renderer.white` is a getter for `defaultTextures(device).white` (shared per device, not destroyed with the renderer); `renderer.white` reads the same 1×1 sRGB white as before.
325
+ - `renderer.camera` is the matrix `Camera`, `Camera.ortho2d(logicalWidth, logicalHeight)` by default, and can be reassigned. Its `x`, `y`, `zoom` and `rotation` work as before; the ortho2d `viewProjection` uses the old arithmetic, so 2D frames are pixel-identical (checked against `main` with and without a panned, zoomed, rotated camera and the post stack). The renderer uploads globals and the camera block in one 512-byte write per frame.
326
+ - **WGSL:** `globals.viewProj` is gone; use `camera.view_proj`. `EngineGlobals` keeps `logicalSize`, `targetSize`, `time` and `dt` (now 32 bytes). The engine's `engine_vertex` and the particle shader project with `camera.view_proj`; a material that only writes `fs_main` needs no change. `renderer.globalsLayout` has a second entry (binding 1); drawables built on it need no change.
327
+ - The `Camera` type exported from `@thunderclap-engine/engine/render` is now the class (it was a `{ x, y, zoom, rotation }` interface).
328
+ - `GameHooks.hide(reason)` gets the lifecycle reason, `HideReason` (`'hidden' | 'pagehide' | 'freeze'`); hooks that take no argument still fit.
329
+ - `EngineCtx`'s and `CreateGameOptions`'s save parameter is `S extends FlushableSave | null`; `createGame`'s defaults to `null` (no save) and is inferred only from `opts.save`, not from a return annotation. Callers that pass `save` see no change.
330
+ - Analytics `attach(ctx)` wraps `ctx.hooks.hide` and `ctx.hooks.show` instead of watching the page lifecycle itself; `session_end.reason` comes from `hide(reason)`. `detach` restores all three hooks.
331
+ - `createOverlay` builds one root over the whole parent in CSS px, with no transform. `hud` and `captions` sit on the frame (the canvas rect); each layer is its own stacking context (`isolation: isolate`), so a z-index in one never lifts it over a later layer; the base UI is sized in design px of the frame (`frame` units, desktop and tablet) or in CSS px (`css` units, phones) per the layout's form. In `frame` units the focus-ring token rounds up to whole px (`round(up, …, 1px)`): outlines snap down to whole pixels, and the old transform drew the scaled ring at its full width, so a ring under 2 px would have thinned. Native range/select controls on the frame's layers are zoomed by `--tc-zoom`; their focus ring is un-zoomed.
332
+ - `platform/device-mode.ts` no longer writes the density tokens (`--tc-tap-min`, `--tc-text-min`); the layout owns them. It keeps `data-phone`, `data-touch`, `data-fill` and the crop and bar vars until ws/camera.
333
+ - **Breaking for custom vertex shaders:** the per-instance format is 28 floats (112 bytes) instead of 20: a 3×4 world matrix (`m0..m2` at @location 3..5), `uvRect` @6, `tint` @7, `params` @8, `misc` @9 (emissive, flags, size.xy). `INSTANCE_FLOATS`/`INSTANCE_STRIDE`/`INSTANCE_BUFFER_LAYOUT` change with it. Fragment shaders (`fs_main`) and vertex entries that only wrap `engine_vertex(v)` need no change; existing `VSOut` fields keep their locations. The 2D output is unchanged (a pixel probe differs by at most 1/255 on a handful of channels of rotated sprites, from CPU instead of GPU sin/cos).
334
+ - Draws render sorted by `order`, then submission order; with the default `order` this is exactly the old submission order and batching. `renderer.stats.instances` counts only the instances drawn (after layer culling).
335
+ - `WidgetHandle.remove()` returns the element to where it was before `layout.add()` (its old parent and position); an element added detached is removed, as before.
336
+
337
+ ### Magnify (ws/port-altar)
338
+ - `GestureOptions.magnify` (`MagnifyOptions`: `pitch`, `below` 44, `size` 128, `zoom` 2.5, `lift` 56, `when`, `snap`, `over`, `toClient`, `source`, `className`, `parent`) and `Gestures.setMagnify()`. Each tick a touch pointer is down, `gestures.aim` (`Aim`) holds the aim point (lifted while dragging), the snapped `target` (`MagnifyTarget`, with `data` for the game's cell), and whether the loupe shows; the aim the finger lifted at comes once more with `ended` on the drag's `end` tick. With a `source` canvas the gestures draw a `Loupe` (`gestures.loupeElement`); `destroy()` removes it.
339
+ - `snapToGrid(grid, x, y, ok?)` and `loupeSpot(finger, aim, size, view)` in `./input`.
340
+ - `MagnifyOptions.clear(target)` (ws/dense-board-3): areas the loupe must leave in sight (`keep`, for example the target and its neighbours) and should rather not cover (`avoid`), client px (`Rect`). With it the loupe goes where `loupeClear(finger, aim, size, view, keep, avoid?)` puts it: the free spot nearest the aim, clear of `keep` and the fingertip, never below the finger, on screen.
341
+ - `MagnifyOptions.clear` may also return `spare` (ws/polish-9): areas the loupe should leave in sight when it can, for example labels. They rank below `keep` and the fingertip and above `avoid`: the loupe takes the spot covering the fewest `spare` rects, then one covering no `avoid` rect, then the nearest. With `spare` given, `loupeClear(finger, aim, size, view, keep, avoid?, spare?)` also tries a coarse grid over the view, so it finds a clear spot when there is one. Unit-tested in `magnify.test.ts`.
342
+ - Engine tests: `src/input/magnify.test.ts`, the loupe element in `gestures.dom.test.ts`, and two `e2e/gestures.spec.ts` cases on every row (snap off a refused cell, loupe only below 44 CSS px, mouse never aims; a drag aims 56 px up and lands there). The game's `scenes/altar/snap` test moved here.
343
+ - dense-board-4: `MagnifyOptions.size` may be a function, read each tick (the loupe is rebuilt when it changes), so a game can use a smaller lens where targets are dense; `magnifySize(m)` reads it. `MagnifyOptions.clear(target, aim)` now also gets the game-space aim point, so the loupe can keep clear of the spot under the finger when nothing snapped. `MagnifyOptions.leader` draws a thin line from the loupe's rim to the snapped target: `LoupeView.leader`, `Loupe.leaderEl` (`tc-loupe-leader`, plus `<className>-leader`) and `loupeLeader(at, size, spot)` in `./ui`. Unit-tested in `magnify.test.ts`.
344
+
345
+ ### Gestures (ws/gestures)
346
+ - `Gestures` in `@thunderclap-engine/engine/input` (`input/gestures.ts`): `tap`, `doubleTap`, `longPress` (default 450 ms), `pan` (threshold default 10 CSS px, with `dx`, `dy` in game space and `pointers` 1 or 2), `drag` (`start`, `move`, `end`, `cancel`, with the `origin`) and `pinch` (`scale` and per-tick `delta`). Thresholds are CSS px; positions are game space through the same `PointerTransform` as `Input` (`inside: false` in the letterbox bars). Options: `panThreshold`, `longPressMs`, `doubleTapMs` (300), `doubleTapDistance` (24 CSS px), `blocked`, `now`. Public feed methods for tests: `pointerDown`, `pointerMove`, `pointerUp`, `pointerCancel`, `cancelAll`. Types `TapGesture`, `PanGesture`, `DragGesture`, `PinchGesture`, `GesturePoint`, `GesturePhase`, `GestureDevice`, `GestureKind`, `GestureOptions`, `GestureAttachOptions`.
347
+ - `Input.setGestures(g)` (or the `gestures` option) links gestures to an input: they share `blocked()` and the pointer transform, attach and detach with `Input.attach`, and advance in `Input.update()`.
348
+ - New binding kind `{ gesture: GestureKind }`: `tap` and `doubleTap` press for one tick, `longPress` holds until the pointer lifts, `pan`, `drag` and `pinch` hold from start to end. It reads inactive with no gestures linked.
349
+ - Engine e2e: `harness/gestures.html` (no WebGPU; a 4:3 stage letterboxed so every row has bars) and `e2e/gestures.spec.ts` on the whole matrix with real CDP touch on the iPad and phone rows and the mouse on desktop and in the itch iframe: tap position and binding, the 10 CSS px tap-versus-pan boundary, long-press timing, double tap, drag origin and drag-off into the bar, `blocked()` and its cancel, and a two-finger pinch.
350
+ - No change for games that don't link gestures; Last Rune does not use them yet.
351
+
352
+ ## 0.2.0-alpha.1 (2026-11-04)
353
+
354
+ Phase 1 of the refactor plan (D-041). Every promoted module is listed here before Gate 1.
355
+
356
+ ### Changed
357
+ - The engine is a pnpm workspace package, `@thunderclap-engine/engine`, at `packages/engine/`, with its sources under `src/`. The move kept git history (`git log --follow`).
358
+ - The public API is the `exports` map: `./audio`, `./core`, `./fx`, `./input`, `./platform`, `./post`, `./render`, `./save`, `./ui`, and `./testing` (the recording fake `GPUDevice`, which was `render/fake-gpu`). Deep imports such as `@engine/render/renderer` are no longer reachable from a game.
359
+ - The `@engine/*` path alias is gone. Games import `@thunderclap-engine/engine/<module>`; engine modules import each other by relative path.
360
+ - The package has its own `tsconfig.json`, `vitest.config.ts` and `typecheck`, `lint`, `test` and `check` scripts.
361
+ - The game's CSS variables became engine tokens: `--lr-text-scale` → `--tc-text-scale`, `--lr-text-min` → `--tc-text-min`, `--lr-tap` → `--tc-tap-min`, `--lr-css-px` → `--tc-css-px`, `--lr-bar` → `--tc-bar`, `--lr-third` → `--tc-third`, `--lr-crop-t`/`-b` → `--tc-crop-t`/`-b`. There are no `--lr-*` aliases: it is a rename with the same values, so layout does not change.
362
+
363
+ ### Added
364
+ - `ARCHITECTURE.md`: the contracts (Surface, Camera, Frame loop, Host, Input, Layout, UI/Audio/Save/FX/Post, Scene and Ctx), each marked as existing today or planned.
365
+ - An import-boundary check (`scripts/check-boundaries.mjs` at the repo root, run by `pnpm check`).
366
+ - The engine e2e harness (D-041 addendum 2). `harness/` is a noun-free page that boots the platform layer and publishes `window.__harness`. `e2e/` holds the specs and `playwright.config.ts` holds the matrix: desktop 1280×720 and 1920×1080, iPad 1180×820, four phones and an itch-style cross-origin iframe (`e2e/wrapper.mjs`). Its seed spec, `smoke.spec.ts`, checks WebGPU init, surface letterbox and resize, and one tap. Run it with `pnpm test:e2e` in the package or `pnpm test:e2e:engine` from the repo root.
367
+
368
+ - `ui/menu-nav` (from the game's `ui/menu-nav.ts`, ws/promote-ui): `menuNav(input, root, actions?)` moves focus through a non-modal menu with up/down and clicks on a gamepad confirm. The action names are options (default `up`, `down`, `confirm`).
369
+ - `ui/text-scale` (from the game's `ui/text-scale.ts`): the density tokens `--tc-text-scale` and `--tc-text-min` (`TEXT_SCALE_VAR`, `TEXT_MIN_VAR`), `scaleFontSizes(css)`, `scalePxCss`, `scaledPx`, `textScaleTokens` and `applyTextScale(root, factor, setTheme?)`. The factor is 1 for 100%. The game keeps its 100/125/150 setting values (`TEXT_SCALES`, `toTextScale`).
370
+ - `platform/device-mode` (from the game's `ui/device-mode.ts`, interim until Phase 2's layout): `createDeviceMode(root, opts?)` with `phone`, `touch`, `onChange`, `setScale` and crop info; it sets `data-phone`, `data-touch`, `data-fill` and the tokens `--tc-text-min`, `--tc-tap-min`, `--tc-css-px`, `--tc-bar`, `--tc-third`, `--tc-crop-t` and `--tc-crop-b`. `PHONE_SCALE`, `PHONE_TEXT_MIN` and `PHONE_TAP_MIN` are exported; `DeviceModeOptions` overrides them.
371
+ - `ui/rotate-card` (from the rotate card in the game's `ui/phone-chrome.ts`, interim until Phase 2's portrait regions): `createRotateCard({ title, lines?, note?, ariaLabel?, device?, colors? })`. Every word and colour is an option.
372
+ - The UI harness page `harness/ui.html` (no WebGPU) and `e2e/ui.spec.ts`: the text-scale tokens at 100/125/150 with the phone floor, the device-mode flags, the rotate card on upright phones, `menuNav` from a pad and the arrows, panel focus trap and restore (to the opener, or home for `NO_RESTORE_FOCUS_ATTR`), and Escape from a focused button as a button key.
373
+
374
+ ### Host (ws/host)
375
+ - New module `@thunderclap-engine/engine/host` (`"./host"` in the exports map): `interface Host { kind; embed; openExternal; copyText; clipboardWriteAllowed; fullscreen; storage; window? }`, `createWebHost({ win? })`, and `defaultHost()` / `setDefaultHost(host)`. The web host wraps the D-036 link service, the fullscreen helpers in `platform/device.ts`, and `localStorage` behind `storage.open()` (probed on every call: `null` when missing, blocked or failing a write). `kind: 'electron'` and the optional `window` are the seam for `createElectronHost()` (Phase 4).
376
+ - `createSaveStore`, `listSlots` and `persistMixer` take their storage from the host: new option `host?: Pick<Host, 'storage'>`, default `defaultHost()`. `storage:` still overrides it (tests). Keys and format are unchanged, so v1.0 saves (`thunderclap:<game>:<slot>`) load as before.
377
+ - `externalLink` takes `host?: Pick<Host, 'openExternal' | 'copyText'>`, default `defaultHost()`.
378
+ - **Breaking:** `openExternal`, `copyText`, `clipboardWriteAllowed` and `execCopy` are no longer exported from `./platform`; use `host.openExternal`, `host.copyText` and `host.clipboardWriteAllowed()`. `safeExternalUrl` and `embedContext` stay in `./platform`. `probeStorage(get)` now needs its getter (it no longer defaults to `globalThis.localStorage`).
379
+ - After this, `window.open`, `navigator.clipboard`, `execCommand` and `localStorage` appear only in `src/host/web.ts` and `src/host/web-links.ts`.
380
+ - Engine e2e: a host probe in the harness (`harness/host-probe.ts`, `window.__host`) and `e2e/host.spec.ts` on the whole matrix: embed detection, clipboard policy, copy (granted, refused, missing, `execCommand` fallback, the selected fallback field), open only on a click, a blocked pad open, storage blocked, full and kept across a reload, fullscreen in and out, and itch's click-to-launch (`e2e/wrapper.mjs?launch=1`). The matching game e2e checks were trimmed.
381
+
382
+
383
+ ### createGame (ws/create-game)
384
+ - New root entry `@thunderclap-engine/engine` (`"."` in the exports map, `src/index.ts`): `createGame(opts): Promise<EngineCtx | null>` and the types `CreateGameOptions`, `EngineCtx`, `GameHooks` and `FlushableSave`. It is the noun-free half of Last Rune's old `game/ctx.ts`: surface, renderer, post, particles, input, pad nav, audio, overlay, save, host, device mode, fill bands, the loop, lifecycle and device loss. Last Rune's `GameCtx` now extends `EngineCtx`, and its `main.ts` is `createGame(...)` then the title. See ARCHITECTURE.md, "Scene and Ctx".
385
+ - `platform/blockBrowserZoom(doc?)`: stops double-tap, pinch and ctrl+wheel page zoom (keyboard zoom stays). `createGame` installs it before boot; it was an inline script in the game's `index.html`.
386
+ - Engine e2e: `harness/game.html` boots through `createGame` and publishes `window.__harnessGame` (frames, loop, audio, hides, device loss, backbuffer, a save that only a flush stores). `e2e/lifecycle.spec.ts` (hidden, pagehide and freeze flush the save and stop frames and audio, show resumes; audio unlocks on the first gesture; zoom blocking) and `e2e/surface.spec.ts` (resize and DPR mid-scene keep canvas, overlay and backbuffer in step; device loss shows the reload screen on top and halts frames, audio and timers; ten orientation flips leak no GPU textures or buffers). `e2e/wrapper.mjs?page=` embeds another harness page. The game's `tests/e2e/lifecycle.spec.ts` and robustness :91, :133 and :164 were removed; play time while hidden is a `game/flow` unit test.
387
+ - Unit tests for `createGame` (`core/game.test.ts`): boot failure, one ctx for every scene, hook order, lifecycle, device loss, fill bands, input blocked under a modal.
388
+ - Behaviour change (PM-approved): Last Rune's settings export copies through `ctx.host.copyText`, so a page without the Clipboard API falls back to `execCommand` or tells the player to copy by hand.
389
+
390
+ Apart from the createGame changes above (a reordered device-loss teardown, the zoom block moved into the bundle, the settings export through the host), no runtime behaviour changed.