Hole Game

v0.8-multihole

Bots' holes cut the world too — wake-fix + fullscreen · Three things shipped together: (1) the shader cutout now applies to every hole, not just the player's — bots' holes dissolve the ground and streets just like yours. (2) Wake-fix in the dynamic mesh ground: rebuilds every FixedUpdate (no movement threshold) and uses a swept-cull that culls cells the hole's footprint passes *through* this tick, so cubes the hole passes over at high speed actually fall now. (3) A fullscreen toggle button on every version page — works on desktop, Android Chrome, and iOS Safari 16.4+.

Build notes

Multi-hole shader

  • HoleClippable.shader globals were single-hole in v0.7: _HoleCenter (Vector4), _HoleRadius (float). v0.8 promotes them to fixed-size arrays of 4: _HoleCenters[4], _HoleRadii[4]. No _HoleCount — the activation gate is the per-slot radius check (_HoleRadii[i] > 0.001), so unused slots are no-ops.
  • Each pass (ForwardLit, ShadowCaster, DepthOnly) now calls a shared InsideAnyHole(p) helper that [unroll]-iterates the 4 slots and returns true if the fragment's XZ falls inside any active hole's footprint AND below that hole's Y plane. One discard per fragment instead of one check per fragment.
  • HoleClipFeeder.cs rewritten: walks every non-defeated HoleActor, fills up to 4 slots with (x, y + clipPlaneOffset, z) and growth.Radius, zeroes the rest. Defeated bots drop their slot the same frame they're defeated, so their (invisible during respawn) hole stops cutting until they're back.
  • Pushed via Shader.SetGlobalVectorArray / Shader.SetGlobalFloatArray once per LateUpdate. Cap of 4 holes matches the current scene (player + 3 bots); raising it is a one-line MaxHoles constant change plus the matching HOLE_MAX in the shader.
  • PrototypeSceneBuilder needed no changes — the cylindrical wall, dynamic mesh ground, and disc-at-Y=-4.8 were all already per-actor (they iterate pgm.Actors). Only the shader globals were the lone single-hole holdout.

Wake-fix (the v0.7 known limitation)

  • Symptom: cubes occasionally refused to fall when the player's hole passed underneath at high speed. Three causes compounded.
  • Cause 1 — mesh-rebuild lag. v0.7's GroundTileManager only rebuilt when a hole moved >8 cm or its radius changed >3 cm. At 9 m/s × 20 ms FixedUpdate = 18 cm/tick, the cell-cutout mesh always trailed the hole's actual position by one tick.
  • Cause 2 — current-frame-only test. Even with always-rebuild, CellIntersectsHole only checked the cell against the hole's *current* footprint. A cube the hole passed *over* between ticks (entered + exited within one FixedUpdate) was never inside the current footprint at any rebuild moment, so its cell was never culled.
  • Cause 3 — PhysX re-sleep. Bodies with kinetic energy below sleepThreshold = 0.005 re-sleep next solver step. A 20 ms support-gap accelerates a body by only 0.2 m/s and 2 mm of fall — right at the threshold. Even a 1-tick gap often wasn't enough to commit to falling.
  • Fix. rebuildMoveThreshold and rebuildRadiusThreshold dropped to 0 (rebuild every FixedUpdate when anything moves). HoleSnapshot gains a PrevCenter field. A Dictionary<HoleActor, Vector2> lastCenters tracks each actor's last-frame XZ; refreshed at the end of CollectHoles (defeated/destroyed actors drop out automatically).
  • Swept-cull. CellIntersectsHole now samples the segment prev → current at half-radius spacing and runs the standard closest-point-on-AABB-to-circle check at each sample. Always conservative (might cull marginally extra cells, never fewer). At normal play speed the segment is shorter than half the radius so the loop runs 1–2 times per cell-hole pair — cost is imperceptible.
  • Cell size kept at 0.5 m: halving to 0.25 m would have quadrupled the per-frame mesh upload (~57k → ~230k vertices) and PhysX recook cost on a now-every-tick rebuild — risk of WebGL stutter wasn't worth the secondary partial-support edge case. Deferred for a future pass if the rim-edge case shows up.

Fullscreen on every version page

  • New client component HoleGameFrame wraps the iframe with a small toggle button at bottom-right. Calls containerRef.current.requestFullscreen() (or webkitRequestFullscreen for older Safari).
  • Drops the iframe's aspect-video constraint when fullscreen via Tailwind's [&:fullscreen]: arbitrary variants — canvas fills the screen edge-to-edge instead of letterboxing on phones that aren't 16:9.
  • Listens to both standard fullscreenchange and the webkit-prefixed event so the button label flips correctly when the user exits via ESC / mobile back-gesture.
  • Feature-detects: button is suppressed if requestFullscreen isn't available (e.g. iOS Safari pre-16.4) so it doesn't appear and lie. Modern iOS / Android / desktop all just work.
  • Applies retroactively to all version pages (v0.1 through v0.8), not just this one.

Known limitations (still deferred)

  • Multi-hole capped at 4 active simultaneously. Current scene has player + 3 bots so it's exact; future scenes with more holes need the constant bumped (one line in the shader, one constant in the feeder).
  • Bots can still clip through buildings (no NavMesh / pathfinding). Same as before, separate milestone.
  • Cell quantization at 0.5 m can still leave partial support if a cube straddles the hole rim with most of its footprint outside. Rare in practice; deferred to a possible follow-up that halves cell size and switches to procedural circular-cutout triangulation.