PURPOSE

Pure Canvas 2D renderer for warp puddles. Draws each puddle group from the outside as a deep-space nebula seen through a crisp circular window with a pulsing purple halo and crackling electric arcs, and from the inside as the same nebula expanded across the whole screen with the non-puddle world masked to black. Visual silhouette is pixel-identical to the gameplay collision circle: the rendered edge is a plain arc at the exact collision radius, with oscillation and arcs layered on top as additive decoration that never deforms the boundary.

OWNS

  • A single lazily-built 1024 by 1024 nebula background canvas, cached in a module-level variable for the lifetime of the renderer. Generated procedurally on first use from a deterministic seed using big soft cloud gradients in a violet-blue / navy / wine palette, a handful of tighter brighter cores, and roughly 380 stars (mostly neutral, occasional warm amber).
  • Constants for edge color, edge glow, outer halo color and reach multiplier, electric arc color, and the per-frame arc budget.
  • The procedural arc-hash function used to seed short-lived rim sparks deterministically per frame.
  • The orchestration choice of which groups are drawn as exterior vs interior given the warp transition value and the active group id.

READS FROM

  • camera (position and zoom) from ../core/state for world-to-screen projection.
  • Clock.now() from ../core/clock for the animation time used by halo pulse, nebula scroll, and arc seeds.
  • The WarpPuddleGroup shape from ../world/warp-puddles: per-group id, axis-aligned bbox for visibility culling, and the members array, where each member supplies world x, y, collision radius, and a colorSeed used to keep arc placement varied between members.
  • The canvas dimensions on the supplied CanvasRenderingContext2D and the viewW / viewH passed to the public entry point for visibility culling.
  • The warpT transition value supplied by the caller, which selects the exterior alpha (1 - warpT) and the interior intensity (warpT).

PUSHES TO

  • The provided 2D context. All drawing is direct Canvas 2D: arc, fill, stroke, drawImage of the cached nebula canvas, with composite operations toggled between source-over and lighter. Interior masking uses a black rect with counter-clockwise member arcs filled with the evenodd rule to carve the union-shaped holes.
  • A module-private DOM canvas created via document.createElement('canvas') on first nebula build.

DOES NOT

  • Does not mutate any puddle, group, or member state. Read-only with respect to game state.
  • Does not run any per-pixel shader, WebGL pass, or filter. No ctx.filter, no offscreen blur. The rippling effect is achieved by drawing the nebula twice at different scroll speeds and composite modes.
  • Does not displace, warp, or animate the silhouette. The visible edge is always ctx.arc(sx, sy, sr, 0, TAU) at the exact collision radius. The pulsing glow varies stroke width only; arcs ride on the rim but use the same radius for their endpoints.
  • Does not compute groupings, collisions, transitions, or activation; those belong to the world module that produces WarpPuddleGroup.
  • Does not register itself into any global render queue or store; the engine bridge calls it directly each frame.
  • Does not allocate per-frame typed-array buffers, retain particles, or schedule deferred work.

Signals

  • warpT in [0, 1] drives the cross-fade: exterior groups use 1 - warpT as their alpha, and the active interior uses warpT for both the black mask alpha and the nebula intensity. The active group is skipped from the exterior pass whenever warpT > 0 so the player does not see the same group drawn twice during the transition.
  • activeGroupId identifies which group renders as interior. When null, only exteriors draw.
  • Early-out: the public entry returns immediately if there are no groups and warpT <= 0. The exterior pass is skipped entirely when the exterior alpha falls below 0.01. Each group is bbox-culled against the view rect in screen space.
  • Pulse signal for the outer edge glow is 0.85 + 0.15 * sin(t * 2.4) applied to stroke width, so the glow breathes without changing the silhouette.
  • The arc generator advances at twenty new spark seeds per second (floor(t * 20)) and emits a fixed budget per member per frame.

Entry points

  • renderWarpPuddles(ctx, groups, activeGroupId, warpT, viewW, viewH) — public per-frame entry. Iterates groups, draws exteriors for visible non-active groups, then draws the interior pass for the active group when warpT > 0. Called by the engine bridge.
  • disposeWarpPuddleRender() — drops the cached nebula canvas so it can be rebuilt on next use. Called on teardown.
  • resizeWarpPuddleRuntimes(viewW, viewH) — no-op stub kept for symmetry with WebGL-backed renderers. Canvas 2D has nothing to resize. Called by the engine bridge on resize.
  • Internal: getNebulaBg, addClipUnionPath, fillNebulaClipped, drawCrispEdge, arcHash, drawEdgeArcs, groupIsVisible, drawExterior, drawInterior.

Pattern notes

  • Crisp-edge invariant: every visible boundary uses the member’s exact collision radius at the projected center. The collision test in the world module is a plain circle test against the same radius and center, so visual and gameplay boundaries match pixel-for-pixel. Wobble effects are explicitly forbidden inside the silhouette and live only as additive decoration outside or along the rim.
  • One texture, two layers, zero shaders: rippling motion is faked by drawing the cached nebula four times for tiling, then again four times counter-scrolled at a different speed in lighter composite at lower alpha. Cross-interference of the two layers reads as motion without any per-pixel work.
  • Wrap-around tiling: scroll offsets are clamped into [0, bg.width) and [0, bg.height) with a double-modulo to handle negative values, then the texture is drawn at four positions (origin, +width, +height, +width and +height) to cover the screen.
  • Interior masking uses the even-odd fill rule: a full-screen black rectangle is begun, then each member adds a counter-clockwise arc into the same path. Filling with evenodd carves the puddle-shaped holes in a single fill.
  • Nebula generation uses a linear congruential pseudo-random with a fixed seed so the background is identical across reloads.
  • The nebula canvas is module-level (not per-instance), shared across all groups and across the exterior and interior passes, so the world looks visually consistent when transitioning into any puddle.
  • Arc endpoints are computed from cos(theta) / sin(theta) at the boundary radius, with a small jagged polyline drawn between them whose perpendicular jitter is bounded by three percent of the projected radius — small enough to read as crackle, large enough to be visible.
  • The renderer is defensive only at the DOM boundary: getNebulaBg returns null when document is undefined (SSR) or when the 2D context is unavailable, and the fill function bails out silently in that case.