oneshotlm
Compare all models on this prompt →

Synthwave sunset · solar-pro4

Create an animated synthwave sunset: a glowing gradient sun with scanlines setting over an infinite neon grid that scrolls toward the viewer, with a starfield and a reflected horizon.

⛨ sandboxed Open in new tab ↗
transcript · 10 messages
user

Create an animated synthwave sunset: a glowing gradient sun with scanlines setting over an infinite neon grid that scrolls toward the viewer, with a starfield and a reflected horizon.

assistant
thinking
The user wants an animated synthwave sunset web app. Let me create a single self-contained index.html file with all CSS and JS inline, featuring:

1. A glowing gradient sun (synthwave style - purple/magenta/orange)
2. Scanlines overlay
3. Infinite neon grid scrolling toward the viewer (3D perspective)
4. Starfield (stars in the sky)
5. Reflected horizon (the grid reflecting off a water-like surface)

All of this needs to be done with CSS animations, Canvas API, or a combination. Canvas would be best for the grid and stars, with CSS for scanlines overlay.

Let me think about the approach:
- Canvas for the grid (perspective lines converging to horizon)
- Canvas for the sun (radial gradient)
- Canvas for stars
- CSS overlay for scanlines
- The grid should scroll toward the viewer (lines moving down/fading)
- Reflections at the bottom

Let me build this with a single canvas approach or multiple layers. I'll use a full-screen canvas with requestAnimationFrame.

Let me design:

1. Background: dark gradient sky (deep purple to magenta to dark)
2. Stars: random dots in the upper portion, twinkling
3. Sun: large radial gradient circle at horizon, pulsing
4. Grid: neon grid lines (horizontal and vertical) in perspective, scrolling toward viewer, with cyan/magenta glow
5. Reflection: mirror the grid below the horizon with fade
6. Scanlines: CSS overlay with repeating linear gradient

Let me implement this with Canvas 2D.

For the grid scrolling toward viewer:
- Draw horizontal lines that move down the screen over time
- Each line has a z-position; lines closer to viewer are lower and wider
- Vertical lines emanate from the vanishing point at horizon

For the horizon line reflection, I can mirror the grid below the horizon.

Let me write this out:

```html
<!DOCTYPE html>
<html>
<head>
<style>
  /* full screen canvas, scanlines overlay */
</style>
</head>
<body>
<canvas id="c"></canvas>
<div class="scanlines"></div>
<script>
  // all the rendering
</script>
</body>
</html>
```

Let me code this up properly.

For the grid effect that scrolls toward the viewer:
- Use a horizon line (e.g., at y = canvas.height * 0.6)
- Above horizon: sky with sun and stars
- Below horizon: reflection

For the grid:
- Vanishing point at horizon center
- Vertical grid lines: radiating from vanishing point
- Horizontal grid lines: parallel to horizon, evenly spaced in "depth", which means they get closer together as they approach the horizon (foreshortening)

The classic synthwave grid is 3D perspective. To animate scrolling, we move the horizontal lines down over time, wrapping around.

Actually, the typical approach:
- Define a set of horizontal "grid planes" at various z-depths
- Project them onto screen based on perspective
- Animate by moving the z positions forward (toward viewer), wrapping when they pass the screen

Let me think about the math:
- Horizon at y = horizonY
- Vanishing point at (cx, horizonY)
- For a grid line at distance d (from viewer), its screen y position is: horizonY + (screenHeight - horizonY) * (1 - 1/(1 + k*d)) or similar

Actually a simpler approach used in many demos:
- Use a perspective divide. For each horizontal line at depth z, screenY = horizonY + (1 - 1/z) * horizonHeight... hmm.

Let me use the approach where:
- We have a "ground" plane
- Each horizontal grid line has a z value (distance along the ground from the horizon)
- viewport projection: the line closer to viewer (smaller z, i.e., closer) appears lower on screen

Let me define:
- horizonY = canvas.height * 0.6
- For a grid line at distance z (z goes from 0 at horizon to infinity at viewer), its y on screen is: horizonY + horizonHeight * (z / (z + k)) where horizonHeight = canvas.height - horizonY

Wait, actually let me invert: z=0 at viewer, z=infinity at horizon. Then:
- screenY = horizonY + horizonHeight * (1 - z/(z + k)) = horizonY + horizonHeight * k/(z+k)
- As z increases (further away), screenY approaches horizonY from below

Hmm, let me think again. The horizon is the farthest point. Closer to viewer = lower on screen.

Let me define depth d where d=0 at horizon, d>0 as we move toward viewer (downward on screen).
- screenY = horizonY + horizonHeight * (d / (d + c))
- As d grows large, screenY approaches horizonY + horizonHeight = bottom of screen

So grid lines at depth d appear at screenY = horizonY + horizonHeight * d/(d+c)

To make lines evenly spaced in screen space near viewer but compressed near horizon, I can vary d. But actually for an "infinite" grid, we just need lines at various d values, and as they scroll, d changes.

Let me use a different parameterization. Let me define t = d/(d+c), so t goes from 0 (horizon) to 1 (viewer at bottom). Then screenY = horizonY + horizonHeight * t.

Given t, d = c * t / (1-t).

For horizontal grid lines, I want them to move. Let me have a "time" offset that shifts all lines' d values. Actually, since the grid scrolls toward viewer, the lines should move downward (increasing screenY). So I can have a scrolling offset s, and each line's effective d = d0 + s, where d0 is the base depth.

Hmm, this is getting complex. Let me use a well-known technique:

For horizontal lines, use a set of lines with their z values (depth). Project each to screen. Animate by incrementing z for all lines (or equivalently, moving the "camera" forward). When a line passes the bottom of screen, wrap it to the horizon.

Actually, I'll use a simpler approach: track a `scrollOffset` that increases over time. For each grid line index i, compute its depth as a function of i and scrollOffset, project to screen.

Let me just use the common approach from synthwave demos:

The grid is drawn as:
- N horizontal lines at various "heights" (z positions)
- These lines scroll by incrementing an offset
- Each line's y on screen is computed via perspective

Let me define:
- horizonY = H * 0.6 (where H = canvas height)
- Vanishing point at (W/2, horizonY)
- For a horizontal line at "world z" = z (distance from horizon, larger = closer):
  - Its screen y = horizonY + (H - horizonY) * (1 - exp(-z * k))  -- exponential falloff
  - Or: screen y = horizonY + (H - horizonY) * (z / (z + refZ))

Let me use the rational form: screenY = horizonY + (H - horizonY) * (z / (z + Z0))

where Z0 is a reference depth. When z = Z0, screenY = horizonY + (H - horizonY) * 0.5.

For the grid, I'll generate lines at z values: z_i = Z0 * (some ratio)^i for i = 0..N. This gives logarithmically spaced lines, which looks like perspective convergence.

But to scroll, I'll shift all z values by scrollZ, then wrap when lines go below screen.

Hmm, let me think about this differently. Let me use the approach where:
- The grid has horizontal lines at world positions z_n = n * dz (evenly spaced in world)
- Project each to screen: screenY_n = horizonY + (H - horizonY) * (z_n / (z_n + Z0))
- Animate by: z_n(t) = (z_n + scrollZ(t)) mod Z_max, then if z_n < some threshold, wrap up

Actually this wrapping is tricky. Let me use a cleaner approach:

Track a `baseZ` that scrolls. For each line i in 0..N-1:
- z_i = baseZ + i * dz
- screenY_i = project(z_i)
- Only draw if screenY_i < H (below horizon and on screen)
- When baseZ gets large enough that line 0 goes off bottom, reset baseZ to 0 (or subtract N*dz)

For vertical lines:
- They radiate from vanishing point. For each vertical line, compute its angle from center, project to screen as a line from vanishing point to bottom of screen.

Actually vertical lines are just straight lines from vanishing point to bottom corners and evenly spaced in angle. They don't scroll (or rather, the scrolling is along the horizontal lines' depth).

Wait, in synthwave grid, the vertical lines DO scroll too — you see them passing by as the camera moves forward. But actually, since vertical lines converge at vanishing point and we're moving along the view direction, the verticals just stay put (they're radial). The horizontals scroll.

Hmm, actually in a true forward motion, both horizontal and vertical grid lines would appear to move. But in the classic synthwave aesthetic (like the movie "Tron" grid), the verticals are static and only horizontals scroll. Let me go with that aesthetic.

Actually, looking at classic synthwave visuals, the verticals are fixed and the horizontals scroll. The verticals emanate from the vanishing point. So:

- Vertical lines: N evenly spaced angles from -FOV to +FOV, drawn from vanishing point to bottom of screen
- Horizontal lines: scroll toward viewer

For the reflection:
- Below the horizon, mirror the grid (horizonY serves as mirror line)
- The reflection is below the horizon line, so the "sky" part is reflected downward
- Actually, in synthwave, the reflection is often the grid itself mirrored below the horizon, with a water-like effect (sine wave distortion)

Let me make the horizon the mirror. Above horizon = sky with sun and stars. Below horizon = reflected grid (water).

Hmm, that's one interpretation. Another: horizon is the ground line, and reflection is below it (like water). Let me go with: horizon at y=horizonY, above is sky (sun, stars), below is a reflective water surface showing the reflected grid.

Actually, I think the most iconic synthwave look is:
- Sky takes up top portion (with sun and stars)
- Grid is the "ground" receding to horizon
- Sometimes there's a reflection of the grid/sun on a water surface at the bottom

Let me do:
- Horizon at ~60% of screen height
- Above horizon: sky gradient, sun, stars
- Below horizon: grid (as ground) scrolling toward viewer
- The grid itself creates the horizon reflection naturally (lines converge)

But the user specifically asked for "reflected horizon" - so let me add a water reflection. Maybe the bottom portion shows a reflection of the sun and grid with a wavy distortion.

Let me design:
- Sky: top 60% (deep purple to magenta gradient)
- Sun: at horizon line, large radial gradient (orange/yellow to magenta/pink)
- Stars: in sky area, twinkling
- Grid: ground below horizon, neon cyan/magenta lines scrolling
- Reflection: bottom portion (or overlay on grid) showing reflected sun and grid with wave distortion

Hmm, this is getting complex. Let me simplify: the grid itself is below the horizon and IS the reflection in a sense. Or I can add a water layer at the very bottom with reflected sun.

Let me do this layout:
- 0 to horizonY: sky with stars and sun (sun centered at horizon)
- horizonY to H: grid ground scrolling
- A water reflection at the bottom portion showing reflected sun (mirrored and distorted)

Actually, let me reconsider. Many synthwave demos show:
- The sun sitting right on the horizon
- The grid below the horizon (the ground)
- The sun's reflection on the grid (like a sunset on water)

So the "reflected horizon" could be the sun's reflection on the grid/water. Let me implement that.

Plan:
- horizonY = H * 0.55
- Sky: gradient from dark purple (top) to magenta (horizon)
- Sun: centered at (W/2, horizonY), large, radial gradient with glow, pulsing
- Stars: random in sky, twinkling via opacity animation
- Grid: below horizon, scrolling horizontal lines + vertical lines from vanishing point, neon glow
- Sun reflection: below horizon on the grid, a vertical gradient streak that mirrors the sun, with wave-like distortion (sine wave displacing the reflection vertically)

Let me code this up.

For the sun reflection with wave distortion:
- The sun is at (cx, horizonY) with radius R
- The reflection would be a vertical line/streak below horizon at x=cx
- With wave distortion, each point on the reflection is displaced vertically by a sine function of x and time

Actually, a simpler reflection: mirror the sun across the horizon, then apply a wave distortion to the reflected image. But since we're using canvas, I'd draw the reflection as a series of horizontal slices.

Let me think about performance. Canvas 2D with gradients and glow (shadowBlur) can be expensive. Let me use shadowBlur sparingly, or use pre-rendered gradients.

For glow effects, I can:
1. Use ctx.shadowBlur with shadowColor — but this is slow for many lines
2. Draw the lines multiple times with increasing blur via manual approach
3. Use radial gradients for the sun glow (cheap)
4. Use a glow layer with CSS maybe? No, needs to be in canvas

For the grid lines, I'll pre-compute the line positions and just redraw them each frame with a slight glow. Let me use shadowBlur only for the sun and maybe a few key elements.

Actually, for the grid, I can draw lines with a glow by drawing each line twice: once thick with low alpha, once thin with full alpha. This simulates glow without shadowBlur.

OK let me just start coding. I'll optimize as needed.

Let me structure the code:

```javascript
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');

let W, H, horizonY, cx;

function resize() {
  W = canvas.width = window.innerWidth;
  H = canvas.height = window.innerHeight;
  horizonY = H * 0.55;
  cx = W / 2;
}

window.addEventListener('resize', resize);
resize();

// Stars
const stars = [];
const NUM_STARS = 200;
for (let i = 0; i < NUM_STARS; i++) {
  stars.push({
    x: Math.random(),
    y: Math.random() * 0.55, // in sky
    size: Math.random() * 2 + 0.5,
    twinkleSpeed: 0.02 + Math.random() * 0.03,
    twinkleOffset: Math.random() * Math.PI * 2,
    brightness: Math.random()
  });
}

// Grid horizontal lines
const NUM_H_LINES = 40;
const H_LINE_BASE_Z = []; // base z values
for (let i = 0; i < NUM_H_LINES; i++) {
  // Distribute in z: closer lines more dense
  // Use exponential spacing
  H_LINE_BASE_Z.push(Math.pow(1.15, i));
}

// Grid vertical lines (angles)
const NUM_V_LINES = 30;
const V_ANGLES = [];
for (let i = 0; i < NUM_V_LINES; i++) {
  V_ANGLES.push((i / (NUM_V_LINES - 1) - 0.5) * 1.2); // -0.6 to 0.6 radians approx
}

let scrollZ = 0;
const SCROLL_SPEED = 0.5;

function projectZ(z) {
  // z = 0 at horizon, z large = near viewer (bottom of screen)
  // Returns y coordinate on screen
  const horizonHeight = H - horizonY;
  // z ranges: we want z=1 -> some position, z large -> bottom
  // Use: y = horizonY + horizonHeight * (z / (z + Z0))
  const Z0 = 3;
  return horizonY + horizonHeight * (z / (z + Z0));
}

// For vertical lines: for a given angle a, the line goes from vanishing point (cx, horizonY)
// to bottom of screen. The bottom point depends on angle.
// At the bottom of screen (y = H), the x extent for angle a is: cx + (H - horizonY) * tan(a) * scale
// But perspective: the x at screenY is: cx + (screenY - horizonY) * tan(a) * perspectiveScale
// Actually for vertical lines radiating from vanishing point, the line is straight from vanishing point
// to bottom edge. The bottom x for angle a: cx + (H - horizonY) * tan(a) * k where k accounts for FOV.
// Let me just compute: at screenY, x = cx + (screenY - horizonY) * tan(a) * perspectiveFactor
// where perspectiveFactor makes the lines spread at the bottom.
//
// Actually, the simplest: for each vertical line at angle a, draw a line from (cx, horizonY) to
// (cx + (H - horizonY) * tan(a) * vf, H) where vf is a spread factor.
// But this makes lines straight (not curved), which is fine for a flat ground plane in perspective.
// Actually for a flat ground plane, the vertical lines (perpendicular to view direction) project to
// straight lines from vanishing point. So straight lines are correct.
//
// The spread at bottom: if FOV is such that at distance d_max (viewer's feet), the visible width is W_vis,
// then tan(FOV/2) = (W_vis/2) / d_max. The bottom of screen is at distance d_max (or some reference).
// For angle a from center, the x offset at distance d is d * tan(a).
// At screenY = H (the viewer's position), d = d_viewer.
// x_offset = d_viewer * tan(a).
// We want this to map to a spread on screen. Let's say at a = maxAngle, x_offset = W/2 (reaches screen edge).
// So d_viewer * tan(maxAngle) = W/2, thus d_viewer = W / (2 * tan(maxAngle)).
// For a given a, x at bottom = cx + d_viewer * tan(a) = cx + (W/2) * (tan(a) / tan(maxAngle)).
//
// With maxAngle chosen so that tan(maxAngle) = something. Let me just use a linear spread:
// x_bottom(a) = cx + (W/2) * (a / maxAngle) for simplicity (small angle approximation).
// Or use tan.

const MAX_V_ANGLE = 0.6; // radians

function drawGrid(scrollZ) {
  // Horizontal lines
  for (let i = 0; i < NUM_H_LINES; i++) {
    let z = H_LINE_BASE_Z[i] + scrollZ;
    // Wrap: if z gets too large (off screen), subtract
    // We want lines to loop: when line 0 goes off bottom, reset
    // Detected by: if projectZ(z) >= H, then line is off screen
    // For seamless loop: reset scrollZ when the farthest line wraps
    // Simpler: just mod the base z values with a range
    // Let me use: z = ((H_LINE_BASE_Z[i] + scrollZ) % Z_RANGE) where Z_RANGE is chosen so lines span the screen
    // Actually, let me handle wrapping differently:
    // The lines should continuously scroll. When a line passes the bottom, it should wrap to the horizon.
    // With our parameterization, lines at z near 0 are at horizon, lines at z large are at bottom.
    // As scrollZ increases, all lines move to larger z (toward bottom). When a line's z is large enough
    // that projectZ(z) >= H, it's off screen. We can wrap it back by subtracting a large amount.
    // But this creates a discontinuity. For seamless loop, we need the spacing to be preserved.
    // 
    // Alternative: use a fixed set of z values in a range [0, Z_MAX], and scroll within that range.
    // z_i(t) = (baseZ_i + scrollZ) mod Z_MAX. But then lines near Z_MAX wrap to 0 (horizon), which is fine
    // because at horizon, lines are very close together (compression). The visual effect: lines come from
    // the horizon (compressed) and spread out as they come toward viewer. When a line reaches the bottom,
    // it wraps to the horizon. But at horizon, many lines are bunched up, so the wrap is visually subtle.
    //
    // Hmm, but if lines are evenly spaced in z, near horizon (z small) they're spaced far apart in screen
    // (because screenY changes a lot for small z changes). Near viewer (z large), they're compressed.
    // Wait no: projectZ(z) = horizonY + horizonHeight * z/(z+Z0). derivative d(screenY)/dz = horizonHeight * Z0/(z+Z0)^2.
    // For small z, derivative is large (horizonHeight/Z0) -> lines spread out near horizon? That's wrong.
    // 
    // Wait: z=0 at horizon. For small z (near horizon), z/(z+Z0) ≈ z/Z0, so screenY ≈ horizonY + horizonHeight * z/Z0.
    // So near horizon, a small change in z gives a small change in screenY (proportional). Lines are CLOSE together near horizon.
    // For large z, z/(z+Z0) ≈ 1 - Z0/z, so screenY ≈ horizonY + horizonHeight * (1 - Z0/z). For large z, screenY changes slowly with z.
    // So near viewer (large z), lines are also close together. Lines are most spread out at intermediate z (z ≈ Z0).
    // 
    // Hmm, this means with evenly spaced z values, the screen spacing is: close near horizon, spread at z=Z0, close near viewer.
    // That's not the classic grid look. The classic look is: lines converge at horizon (sparse far, dense near viewer).
    // 
    // Let me reconsider. In a real perspective projection, a ground plane with evenly spaced grid lines:
    // - Near viewer (foreground): lines are spread apart (closer spacing in world = larger spacing on screen)
    // - Far (horizon): lines converge (dense on screen)
    // 
    // So lines should be dense near horizon and sparse near viewer. My current projection gives the opposite for z spacing.
    // 
    // Let me redefine: let z be the distance from viewer (z=0 at viewer, z large at horizon). Then:
    // screenY = horizonY + (H - horizonY) * (1 - z/(z+Z0))  ... no
    // 
    // Hmm, let me think about it as: the viewer is at the bottom of the screen looking toward the horizon.
    // World distance from viewer: d (d=0 at viewer/feet, d=large at horizon).
    // On screen, the point at distance d appears at: screenY = H - (H - horizonY) * (d_max - d)/d_max ... linear
    // But with perspective, it's nonlinear. For a ground plane viewed from height h_viewer:
    // screenY (from horizon) is related to d by: screenY - horizonY = h_viewer * (screenY - horizonY) / d ... 
    // Actually the standard perspective: a point on the ground at distance d from viewer (along ground) and at lateral
    // position... the vertical position on screen is determined by the angle from the viewer's eye to the point.
    // 
    // This is getting complicated. Let me just use a parameterization that LOOKS right:
    // screenY = horizonY + (H - horizonY) * (1 - exp(-d * k))
    // where d = distance from viewer (0 at viewer, infinity at horizon... wait, d=0 at viewer means exp(0)=1, screenY = horizonY + 0 = horizonY. That's wrong.
    // 
    // Let me use: screenY = horizonY + (H - horizonY) * exp(-d * k)
    // d=0 at viewer: screenY = horizonY + (H-horizonY)*1 = H (bottom). Good.
    // d=infinity at horizon: screenY = horizonY + 0 = horizonY. Good.
    // 
    // For evenly spaced d values (grid lines at d = n * dd):
    // - Near viewer (small d): exp(-d*k) ≈ 1 - d*k, so screenY ≈ H - (H-horizonY)*d*k. Spacing on screen ≈ (H-horizonY)*k*dd. Constant near viewer.
    // - Near horizon (large d): exp(-d*k) ≈ small, small changes in d give small changes in screenY. Lines are dense near horizon.
    // 
    // This gives the right look: dense at horizon, spread at viewer. 
    // 
    // For scrolling: increment all d values (or shift the grid). When a line's d becomes negative (passed viewer), wrap to large d (horizon).
    // Actually, scrolling toward viewer means the camera moves forward, so the grid lines' d values decrease (they get closer to viewer).
    // We animate by: d_i(t) = d_i(0) - scrollSpeed * t. When d_i < 0, wrap: d_i += D_MAX (where D_MAX is large enough to be "at horizon").
    // 
    // With exp parameterization, d at horizon is infinity. So we can't wrap to infinity. Let me use a large finite D_MAX and treat it as "at horizon".
    // Lines with d near D_MAX are at/near horizon. When a line wraps from d<0 to d=D_MAX, it appears at horizon. That's the visual we want: lines emerge from horizon.
    // 
    // But there's a visual artifact: when a line wraps, it jumps from bottom to horizon. To make this smooth, we'd want the line to fade out at bottom and fade in at horizon. 
    // For simplicity, let me just let lines that go off-screen (screenY > H or screenY < horizonY) not be drawn.
    // 
    // Actually, with the exp model, as d decreases from D_MAX to 0, screenY goes from horizonY to H. So if I have lines at d_i = initial_d + i*dd, and I subtract scroll, 
    // lines move from horizon toward viewer. When a line's d goes below 0, it's past the viewer (off screen below). I can just not draw it, and add a new line at the horizon (d = D_MAX + something) to replace it.
    // 
    // To keep a constant number of visible lines, I'll manage a pool.
    // 
    // Hmm, let me use a much simpler approach used by many demos:
    // 
    // Define lines by their screenY position directly, but with a spacing that increases toward viewer.
    // Actually, the cleanest approach: work in "screen space" with a perspective-correct spacing.
    // 
    // Let me define a "depth" parameter p where p=0 at horizon and p=1 at viewer (bottom).
    // screenY = horizonY + (H - horizonY) * p
    // For the grid to look like perspective, lines should be spaced in p such that they're dense at p=0 and sparse at p=1.
    // Use p_i = 1 - exp(-z_i * k) where z_i is "world distance from horizon" or similar.
    // Or just define p_i directly with a nonlinear spacing.
    // 
    // For scrolling, I'll shift the p values. When a line's p goes above 1 (passed viewer), wrap to 0 (horizon).
    // But wrapping p from 1 to 0 is a big jump. To make it smooth, I can fade lines near p=1 (bottom) and p=0 (horizon).
    // 
    // Actually, you know what, let me just use a method where I have a scrolling offset and compute screenY for each line, drawing only visible ones, and let the lines wrap.
    // 
    // Let me use this concrete approach:
    // 
    // gridLineDepth[i] = initialDepth[i]  // in "world" units, 0 at horizon, infinity at viewer
    // Actually let me use d = distance from viewer. d=0 at viewer, d=large far away.
  }
}
```

OK this is getting way too long in my head. Let me just write the code with a pragmatic approach and test it. I'll use the exponential model:

- d = distance from viewer (0 = at viewer/feet, large = far away at horizon)
- screenY(d) = horizonY + (H - horizonY) * exp(-d * k)
- Grid lines at d_i = base_d_i (with spacing that makes them look good)
- Scroll: d_i -= speed * dt each frame; when d_i < 0, reset to a large value (re-enter from horizon)

For the base spacing, I want lines to be visible across the whole screen. With exp spacing, lines near d=0 (viewer) have screen spacing ≈ (H-horizonY)*k*dd. Lines near horizon (d large) are compressed.

Let me choose dd (spacing in d) and k so that we get a nice look. If I want ~20 visible lines across the screen:
- At viewer (d=0), screenY = H. The first line visible is at some d > 0.
- Lines at d = 0, dd, 2dd, ... 
- screenY at d=0: H (bottom)
- screenY at d=dd: horizonY + (H-horizonY)*exp(-dd*k)
- The spacing near viewer: ΔscreenY ≈ (H-horizonY)*k*dd

For the lines to span from horizon to bottom, I need d values from ~0 to ~6/k (where exp(-6) ≈ 0.0025, basically at horizon).

Let me set k = 0.5, and dd such that we get good spacing. At d=0, spacing ≈ (H-horizonY)*0.5*dd. If H-horizonY = 0.45*H and H=1000, that's 450. For spacing of ~30px near viewer: 450*0.5*dd = 30 → dd = 0.133. Number of lines from d=0 to d=12 (exp(-6)≈0): 12/0.133 ≈ 90 lines. That's a lot.

Let me adjust: k = 0.3, dd = 0.2. Spacing near viewer: 450*0.3*00.2 = 27px. Lines from d=0 to d=15 (exp(-4.5)=0.011): 75 lines. Still a lot but manageable. Actually we don't need that many if each line is just 1-2px. 75 lines is fine.

Hmm, let me reduce: k=0.2, dd=0.3. Spacing near viewer: 450*0.2*0.3=27px. Lines to d=20 (exp(-4)=0.018): 67 lines.

OK, let me just pick values and iterate. I'll use k and the spacing as parameters.

For vertical lines: they go from vanishing point (cx, horizonY) to the bottom edge at positions determined by angle. For a flat ground plane, vertical lines (at world x positions) project to lines from vanishing point. The bottom point of each vertical line on screen: for a line at world distance d_viewer (viewer's feet, corresponding to screen bottom), at angle a from center, x = cx + d_viewer * tan(a). 

But I'm using a non-linear d mapping. Let me just place vertical lines at the bottom of the screen evenly spaced, and draw them from the vanishing point to their bottom position. The "evenly spaced at bottom" is a reasonable approximation for the aesthetic.

Actually, for the classic synthwave grid, vertical lines are evenly spaced at the bottom of the screen (or slightly converging). Let me place them evenly at the bottom and draw straight lines to the vanishing point.

Let me also add the glowing sun and reflection.

For the sun:
- Position: (cx, horizonY)
- Radius: ~80-120px
- Radial gradient: center hot (yellow/white) → orange → magenta/purple → transparent
- Glow: larger radial gradient with low opacity for the glow halo
- Pulsing: animate radius and glow intensity

For the reflection of the sun on the grid:
- Below the horizon, at x=cx, a vertical streak
- The streak is the sun's colors mirrored
- With wave distortion: displace vertically using a sine wave that varies with x and time
- The reflection fades with distance from horizon (gets more diffuse)

Let me also add the sun's reflection as a series of horizontal "slices" that are displaced by the wave.

For the wave distortion on the reflection:
- For each pixel y below horizon at x, the displacement = A * sin(freq * x + phase + time * speed)
- This creates a rippling water effect

To implement efficiently, I can:
1. Draw the reflection as a vertical gradient strip at x=cx
2. Apply displacement by drawing it as a series of horizontal bands, each displaced

Or, I can use a more sophisticated approach: render the reflection to an offscreen canvas, then use drawImage with a distortion. But that's complex.

Simpler: draw the reflection as a column of small horizontal segments, each at a y position that's the "ideal" reflection y plus a wave displacement. The ideal reflection of the sun at (cx, horizonY) would be at (cx, horizonY + dy) where dy is the distance below horizon (mirroring). 

Actually the sun is AT the horizon, so its reflection would be directly below it, spreading downward. Let me think: the sun is a circle centered at (cx, horizonY). Its reflection would be a vertical streak below the horizon, with the colors mirroring.

Let me make the reflection a vertical gradient from horizon downward, with the same colors as the sun (yellow → orange → magenta), getting more diffuse (blurred, lower opacity) as it goes down. Apply wave displacement.

Implementation:
- For y from horizonY to H (or some limit), with step dy:
  - idealRefX = cx
  - waveDisplacement = A * sin(freqY * (y - horizonY) + freqX * (x - cx) + time * speed)
  - Actually, let me make the wave depend on the y position and time: displacement = A * sin(y * freq + time * speed)
  - This creates horizontal wave bands moving downward
  - Wait, for a water reflection, the ripples are usually horizontal bands moving. Let me use:
    - displacement_y = A * sin( (y - horizonY) * freqY + time * speedY )
    - This displaces each horizontal slice up/down, creating a wavy reflection
  - Also add some x-dependence for variety: displacement_x = small ripple

Hmm, let me think about what looks good. A classic water reflection of a sunset:
- Vertical column of light below the sun
- Horizontal ripples (brighter/darker bands) moving slowly
- The reflection gets wider and more diffuse as it goes down

Let me implement:
- Draw a vertical gradient strip (the reflection) with wave displacement
- Use horizontal bands

Actually, let me use a pixel-based approach for the reflection using ImageData? That might be slow. Let me stick with drawing shapes.

Here's my plan for the reflection:
1. Create a vertical linear gradient for the reflection colors (same as sun but mirrored/downward)
2. Draw it as a series of horizontal strips, each displaced by a sine wave
3. Each strip is a thin rectangle with the gradient color at that height, with some transparency

Let me simplify: I'll draw the reflection as a path that follows the wave, filled with a gradient. Or I'll draw many small horizontal lines.

Actually, let me use this approach for the water reflection:
- For each y slice (horizontal line) below horizon:
  - Compute the wave displacement (sine of y and time)
  - Draw a short horizontal line/rectangle at (cx + waveX, y + waveY) with the reflection color
  - The color comes from the sun's gradient at the mirrored position

This is essentially drawing the reflection as displaced horizontal scanlines. With enough slices (e.g., 1px each), it'll look smooth.

For performance, I'll limit the reflection to a region around cx (e.g., ±200px) and only draw slices every 2px.

Let me also add the sun reflection's glow.

OK, let me also handle the scanlines overlay with CSS.

Let me write the full code now.

Let me also think about colors for synthwave:
- Sky: top #0a0020 (deep dark purple) to #1a0030 → #2d004d → #4a0070 → #6a0090 near horizon. Or use a gradient from dark purple to magenta.
- Sun: center #fff4e0 (warm white) → #ffcc44 (gold) → #ff6633 (orange) → #ff2266 (pink) → #aa00aa (purple) → transparent
- Grid lines: cyan (#00ffff) or magenta (#ff00ff) or both. Classic is cyan grid with magenta sun, or vice versa. Let me use cyan grid lines with a magenta/pink sun. Or use both colors.
- Actually classic synthwave: pink/magenta sun, cyan grid. Let me do that.
- Stars: white with slight blue/purple tint

For the grid glow, I'll draw each line twice: first a wider line with low opacity (the glow), then a thin line with full opacity.

For colors:
- Grid horizontal lines: cyan (#00e5ff) with glow
- Grid vertical lines: slightly dimmer cyan
- Sun: warm gradient
- Stars: white

Let me also add a slight fog/haze near the horizon for depth.

OK let me write the code.

```javascript
// === Setup ===
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let W, H, horizonY, cx, groundH;
let dpr = window.devicePixelRatio || 1;

function resize() {
  W = window.innerWidth;
  H = window.innerHeight;
  canvas.width = W * dpr;
  canvas.height = H * dpr;
  canvas.style.width = W + 'px';
  canvas.style.height = H + 'px';
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  horizonY = H * 0.58;
  cx = W / 2;
  groundH = H - horizonY;
}
window.addEventListener('resize', resize);
resize();

// === Stars ===
const stars = [];
const NUM_STARS = 180;
for (let i = 0; i < NUM_STARS; i++) {
  stars.push({
    x: Math.random() * W,
    y: Math.random() * (horizonY * 0.95),
    r: Math.random() * 1.8 + 0.4,
    phase: Math.random() * Math.PI * 2,
    speed: 0.5 + Math.random() * 1.5,
    maxBright: 0.4 + Math.random() * 0.6
  });
}

// === Grid parameters ===
const GRID_K = 0.22; // perspective falloff
const GRID_DD = 0.28; // spacing in d
const GRID_D_MAX = 18; // max d (at horizon, exp(-GRID_D_MAX*GRID_K) ≈ 0.013)
const NUM_GRID_LINES = Math.ceil(GRID_D_MAX / GRID_DD) + 10; // ~78 lines
// Pre-compute base d values (for initial placement)
const gridBaseD = [];
for (let i = 0; i < NUM_GRID_LINES; i++) {
  gridBaseD.push(i * GRID_DD);
}
let gridScroll = 0;
const GRID_SPEED = 1.6; // d-units per second

// Vertical lines
const NUM_V_LINES = 28;
const V_SPREAD = 1.0; // how far they spread at bottom relative to screen half-width
// Vertical lines from vanishing point to bottom
// At bottom (y=H), x = cx + (W/2) * (i/(NUM_V_LINES-1) - 0.5) * 2 * V_SPREAD
// Line from (cx, horizonY) to (x_bottom, H)

// === Sun ===
const sun = {
  baseRadius: 70,
  pulseAmp: 8,
  pulseSpeed: 0.8,
  glowRadius: 200,
  glowPulse: 15
};

// === Reflection ===
const REFLECTION_MAX_WIDTH = 180;
const REFLECTION_SLICE_HEIGHT = 1.5;

// === Animation ===
let lastTime = performance.now();

function drawSky() {
  // Gradient sky
  const grad = ctx.createLinearGradient(0, 0, 0, horizonY);
  grad.addColorStop(0, '#05001a');
  grad.addColorStop(0.3, '#0d002a');
  grad.addColorStop(0.6, '#1a0040');
  grad.addColorStop(0.85, '#3a0060');
  grad.addColorStop(1, '#5a0070');
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, W, horizonY);
}

function drawStars(time) {
  for (const s of stars) {
    const bright = s.maxBright * (0.5 + 0.5 * Math.sin(time * s.speed + s.phase));
    ctx.fillStyle = `rgba(255, 240, 255, ${bright})`;
    ctx.beginPath();
    ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
    ctx.fill();
  }
}

function drawSun(time) {
  const pulse = Math.sin(time * sun.pulseSpeed) * sun.pulseAmp;
  const r = sun.baseRadius + pulse;
  const gPulse = Math.sin(time * sun.pulseSpeed * 0.7) * sun.glowPulse;
  const gr = sun.glowRadius + gPulse;
  
  cx_sun = cx;
  cy_sun = horizonY;
  
  // Outer glow
  const glow = ctx.createRadialGradient(cx_sun, cy_sun, r * 0.2, cx_sun, cy_sun, gr);
  glow.addColorStop(0, 'rgba(255, 180, 100, 0.5)');
  glow.addColorStop(0.3, 'rgba(255, 80, 120, 0.3)');
  glow.addColorStop(0.6, 'rgba(180, 40, 120, 0.15)');
  glow.addColorStop(1, 'rgba(100, 0, 80, 0)');
  ctx.fillStyle = glow;
  ctx.fillRect(cx_sun - gr, cy_sun - gr, gr * 2, gr * 2);
  
  // Sun body
  const grad = ctx.createRadialGradient(cx_sun, cy_sun, 0, cx_sun, cy_sun, r);
  grad.addColorStop(0, '#fff4d0');
  grad.addColorStop(0.2, '#ffcc55');
  grad.addColorStop(0.45, '#ff7733');
  grad.addColorStop(0.7, '#ff2266');
  grad.addColorStop(0.88, '#aa00aa');
  grad.addColorStop(1, 'rgba(80, 0, 60, 0)');
  ctx.fillStyle = grad;
  ctx.beginPath();
  ctx.arc(cx_sun, cy_sun, r, 0, Math.PI * 2);
  ctx.fill();
  
  // Bright core
  const core = ctx.createRadialGradient(cx_sun, cy_sun, 0, cx_sun, cy_sun, r * 0.3);
  core.addColorStop(0, 'rgba(255, 255, 240, 0.9)');
  core.addColorStop(1, 'rgba(255, 200, 100, 0)');
  ctx.fillStyle = core;
  ctx.beginPath();
  ctx.arc(cx_sun, cy_sun, r * 0.3, 0, Math.PI * 2);
  ctx.fill();
}

function drawGrid(time) {
  // Update scroll
  gridScroll += GRID_SPEED * (1/60); // approximate, will use delta in main loop
  
  // Horizontal lines
  for (let i = 0; i < NUM_GRID_LINES; i++) {
    let d = gridBaseD[i] - gridScroll;
    // Wrap: when d < 0, add GRID_D_MAX to bring it back from horizon
    // But with exp model, d=GRID_D_MAX is near horizon, d=0 is at viewer
    // As gridScroll increases, d decreases (lines move toward viewer)
    // When d < 0, line is past viewer (off bottom). Wrap to horizon: d += GRID_D_MAX
    // Actually, to make lines emerge from horizon, we want: when a line goes past viewer (d<0),
    // it reappears at the horizon (d = GRID_D_MAX + offset)
    while (d < 0) d += GRID_D_MAX;
    // Also if d > GRID_D_MAX, it's way past horizon (above sky), skip
    if (d > GRID_D_MAX * 1.1) continue;
    
    const sy = horizonY + groundH * Math.exp(-d * GRID_K);
    if (sy > H || sy < horizonY) continue;
    
    // Line width: thinner near horizon, thicker near viewer
    const lineWidth = 0.5 + (1 - Math.exp(-d * GRID_K)) * 2.5;
    const alpha = Math.min(1, (1 - Math.exp(-d * GRID_K)) * 1.2);
    
    // Glow pass
    ctx.strokeStyle = `rgba(0, 220, 255, ${alpha * 0.25})`;
    ctx.lineWidth = lineWidth + 6;
    ctx.beginPath();
    ctx.moveTo(0, sy);
    ctx.lineTo(W, sy);
    ctx.stroke();
    
    // Core pass
    ctx.strokeStyle = `rgba(0, 230, 255, ${alpha})`;
    ctx.lineWidth = Math.max(0.5, lineWidth);
    ctx.beginPath();
    ctx.moveTo(0, sy);
    ctx.lineTo(W, sy);
    ctx.stroke();
  }
  
  // Vertical lines
  for (let i = 0; i < NUM_V_LINES; i++) {
    const t = i / (NUM_V_LINES - 1) - 0.5; // -0.5 to 0.5
    const xBottom = cx + t * W * V_SPREAD;
    // Line from vanishing point to bottom
    const alpha = 0.4 + 0.3 * (1 - Math.abs(t) * 1.5); // brighter near center
    // Glow
    ctx.strokeStyle = `rgba(0, 200, 255, ${alpha * 0.2})`;
    ctx.lineWidth = 4;
    ctx.beginPath();
    ctx.moveTo(cx, horizonY);
    ctx.lineTo(xBottom, H);
    ctx.stroke();
    // Core
    ctx.strokeStyle = `rgba(0, 220, 255, ${alpha})`;
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(cx, horizonY);
    ctx.lineTo(xBottom, H);
    ctx.stroke();
  }
}
```

Hmm wait, there's an issue with my grid scroll. With gridScroll increasing, d = baseD - gridScroll decreases. When d < 0, I wrap by adding GRID_D_MAX. But this means the line reappears at d = GRID_D_MAX - (gridScroll % GRID_D_MAX)... let me think.

If gridScroll = 0, lines at d = 0, dd, 2dd, ..., (N-1)dd.
If gridScroll = dd, lines at d = -dd, 0, dd, ... → after wrapping: d = GRID_D_MAX - dd, GRID_D_MAX, GRID_D_MAX + dd, ...
Wait, GRID_D_MAX + dd > GRID_D_MAX, so those lines are off-horizon (in the sky). That's wrong.

The issue: as gridScroll increases, ALL lines decrease their d. The line that was at d=0 goes to d=-dd (off screen). We wrap it to d = GRID_D_MAX - dd (near horizon). But the line that was at d=dd goes to d=0 (viewer). The line at d=2dd goes to d=dd. Etc.

So after wrapping, we have lines at: GRID_D_MAX - dd, 0, dd, 2dd, ..., (N-2)dd. That's N-1 lines (one wrapped). The line at GRID_D_MAX - dd is near horizon (good, it emerges). The line at 0 is at viewer (bottom). 

But wait, we have NUM_GRID_LINES lines originally. After one wraps, we have NUM_GRID_LINES - 1 + 1 = NUM_GRID_LINES lines. Good.

But the issue is: the line at GRID_D_MAX - dd has screenY = horizonY + groundH * exp(-(GRID_D_MAX - dd)*GRID_K). With GRID_D_MAX=18, dd=0.28, GRID_K=0.22: exp(-17.72*0.22) = exp(-3.898) = 0.0203. So screenY = horizonY + 0.02*groundH. That's very close to horizon. Good.

And the line at d=0 (viewer): screenY = horizonY + groundH * 1 = H (bottom). Good.

So the wrapping works: each frame, as gridScroll increases by some amount, the lines shift toward viewer, and the ones that go past viewer reappear at the horizon.

But I need to handle the "forgetting" of lines that are now at d > GRID_D_MAX (in the sky). With my wrapping (add GRID_D_MAX when d < 0), a line that goes from d=0 to d=-dd wraps to d=GRID_D_MAX - dd. It doesn't go to d > GRID_D_MAX. But what about lines that were already near GRID_D_MAX? As gridScroll increases, d = baseD - gridScroll. For a line with baseD = GRID_D_MAX - dd, after some scroll, d could become < 0 and wrap. But initially, d = GRID_D_MAX - dd which is < GRID_D_MAX. As scroll increases, d decreases. It goes: GRID_D_MAX - dd → GRID_D_MAX - 2dd → ... → 0 → -dd → wraps to GRID_D_MAX - dd. So it cycles. Good, no line ever exceeds GRID_D_MAX after wrapping.

But initially, the lines span from d=0 to d=(NUM_GRID_LINES-1)*dd. If (NUM_GRID_LINES-1)*dd > GRID_D_MAX, then some initial lines are at d > GRID_D_MAX (in the sky, off-screen). That's fine, they won't be drawn (my continue condition). As they scroll, they'll come into view.

Actually, let me set NUM_GRID_LINES so that (NUM_GRID_LINES-1)*dd ≈ GRID_D_MAX. With GRID_D_MAX=18, dd=0.28: NUM_GRID_LINES = 18/0.28 + 1 ≈ 65. Let me use 70.

Hmm wait, but I also want lines to be visible near the horizon (d near GRID_D_MAX). With d=GRID_D_MAX=18, screenY = horizonY + groundH*exp(-18*0.22) = horizonY + groundH*exp(-3.96) = horizonY + groundH*0.019. So very close to horizon. The line is at ~0.02*groundH above horizon. If groundH = 0.42*H and H=800, groundH=336, so the line is at 6.4px above horizon. Very close. Good.

Near viewer (d=0), screenY=H. The line at d=dd=0.28: screenY = horizonY + 336*exp(-0.28*0.22) = horizonY + 336*exp(-0.0616) = horizonY + 336*0.940 = horizonY + 316. So the first line above the bottom is at 316px above horizon, i.e., 20px above the bottom (H - 316 - horizonY = 800 - 316 - 464 = 80... wait let me recompute).

horizonY = 0.58*800 = 464. groundH = 800 - 464 = 336.
d=0: screenY = 464 + 336*1 = 800 (bottom). 
d=0.28: screenY = 464 + 336*exp(-0.0616) = 464 + 336*0.9403 = 464 + 316 = 780. So 20px above bottom.
d=0.56: screenY = 464 + 336*exp(-0.1232) = 464 + 336*0.884 = 464 + 297 = 761. 39px above bottom.
...
d=18: screenY = 464 + 336*0.019 = 464 + 6.4 = 470.4. Just above horizon.

So the spacing near viewer (d=0 to d=0.28): 20px. Near horizon (d=17.72 to d=18): 0.4px. So lines are very dense near horizon and sparse near viewer. This creates the perspective look. 

But wait, I want lines to be visible. With 20px spacing near viewer and 70 lines total, the lines span from bottom to near-horizon. The ones near horizon are very dense (sub-pixel). That might look like a solid band near horizon. That's actually the classic look (the grid "bunches up" at the horizon). Good.

But for drawing, sub-pixel lines won't be visible individually. That's fine, they blend together. The overall effect is a grid that's dense at horizon and spreads toward viewer.

Let me also handle the vertical lines. They should also show perspective: near the horizon, vertical lines are close together; near the viewer, they spread out. But I'm drawing them as straight lines from vanishing point to evenly-spaced bottom points. The spacing at the bottom is even, but the lines converge at the horizon. This gives the perspective look for verticals. Good.

Actually, for true perspective, the vertical lines (at world x positions evenly spaced) would NOT be evenly spaced at the bottom of the screen. They'd be more spread at the bottom. But for the aesthetic, evenly spaced at bottom is fine and looks good.

Hmm, but if I want the verticals to also "scroll" (as the camera moves forward), they would shift. But in the classic synthwave look, the verticals are static. Let me keep them static.

Wait, actually, in a true forward motion along the grid, the vertical lines would also appear to move (they'd shift as the camera passes grid posts). But the classic synthwave aesthetic (like the grid in "Tron" or synthwave album art) often has static verticals and scrolling horizontals. Let me go with that.

But actually, I realize there might be an issue: if only horizontals scroll and verticals are static, the intersection points (grid posts) would move along the verticals. That's actually correct for forward motion! The posts appear to slide along the vertical lines as the camera moves. So this is physically plausible.

OK let me finalize the code. Let me also add:
- Haze near horizon (a gradient overlay near horizonY for depth)
- The sun reflection

For the sun reflection, let me think about how to draw it nicely.

The sun is at (cx, horizonY) with radius r (pulsing). The reflection would be below the horizon, centered at x=cx, extending downward. The reflection is the "mirror image" of the sun across the horizon line, but since the sun is AT the horizon, the reflection is just the lower half of the sun mirrored... no, that's not right either.

Actually, a reflection of a sunset on water: the sun is above the water (or at the horizon). The reflection is the sun's image mirrored across the water surface. If the sun is at height h above the water, the reflection appears at depth h below the water surface. The reflection is a vertical streak/column.

In our case, the sun is AT the horizon (horizonY = water surface). So the sun is right at the water level. Its reflection would be directly below the sun's center, extending downward. The reflection would be the sun's colors smeared vertically, getting more diffuse with depth.

But since the sun is a circle centered at (cx, horizonY) with radius r, the lower half of the sun is already below the horizon (in the water). So the reflection is essentially the sun's lower portion, plus a smeared reflection extending further down.

Let me implement the reflection as:
1. The lower half of the sun (already drawn as part of the sun circle, since it's centered at horizonY)
2. A vertical reflection streak below the horizon, with the sun's colors, distorted by waves

For the reflection streak:
- It's centered at x=cx, extending from horizonY down to some depth (e.g., horizonY + 200px)
- The color at each depth y is based on the sun's color at the mirrored position: mirrored y = horizonY - (y - horizonY) = 2*horizonY - y. But the sun only extends to horizonY - r (top of sun). So for y from horizonY to horizonY + r, the mirrored position is in the sun. For y > horizonY + r, the mirrored position is above the sun (sky), so the reflection fades.
- With wave distortion: displace each point by a sine wave

Let me draw the reflection as horizontal slices:

For y from horizonY + 1 to horizonY + reflectionDepth (e.g., 250px), step sliceHeight:
- mirrorY = 2 * horizonY - y (mirror across horizon)
- If mirrorY is within the sun (between horizonY - r and horizonY), get the sun color at that point
- The sun color at (cx, mirrorY) is from the radial gradient
- Apply wave displacement to the x position: waveX = A * sin(freq * y + time * speed + phase)
- Draw a horizontal line at (cx + waveX, y) with the sun color, with some alpha and width

Actually, this is getting complex. Let me simplify: draw the reflection as a column with a vertical gradient (same colors as sun, mirrored), and apply wave distortion by drawing it as displaced horizontal segments.

Let me use an offscreen canvas approach for the reflection:
1. Draw the sun's lower reflection gradient to a small offscreen canvas (a vertical strip)
2. Then "stamp" this strip onto the main canvas at displaced positions

Or, simpler: just draw many small horizontal lines with the right colors and displacements.

Let me try the direct approach:

```javascript
function drawReflection(time) {
  const r = sun.baseRadius + Math.sin(time * sun.pulseSpeed) * sun.pulseAmp;
  const maxDepth = 220;
  const sliceH = 2;
  const waveAmp = 8;
  const waveFreq = 0.04;
  const waveSpeed = 1.2;
  
  for (let y = horizonY + sliceH; y < horizonY + maxDepth; y += sliceH) {
    const mirrorY = 2 * horizonY - y; // mirror across horizon
    const distFromCenter = Math.abs(mirrorY - horizonY); // = y - horizonY (since mirrorY < horizonY)
    if (distFromCenter > r * 1.5) continue; // fade out beyond sun radius * 1.5
    
    // Sun color at mirrorY (along vertical line through center)
    const t = distFromCenter / r; // 0 at center, 1 at edge, >1 outside
    let color;
    if (t < 0.2) color = lerpColor('#fff4d0', '#ffcc55', t / 0.2);
    else if (t < 0.45) color = lerpColor('#ffcc55', '#ff7733', (t - 0.2) / 0.25);
    else if (t < 0.7) color = lerpColor('#ff7733', '#ff2266', (t - 0.45) / 0.25);
    else if (t < 0.88) color = lerpColor('#ff2266', '#aa00aa', (t - 0.7) / 0.18);
    else if (t < 1) color = lerpColor('#aa00aa', 'rgba(80,0,60,0)', (t - 0.88) / 0.12);
    else continue; // outside sun, skip
    
    // Wave displacement
    const waveX = Math.sin((y - horizonY) * waveFreq + time * waveSpeed) * waveAmp;
    const waveY = Math.cos((y - horizonY) * waveFreq * 0.7 + time * waveSpeed * 0.8) * 2;
    
    const alpha = Math.max(0, 1 - (y - horizonY) / maxDepth) * 0.6 * (1 - t * 0.5);
    const width = 2 + (1 - t) * 4;
    
    ctx.strokeStyle = color.replace(')', `, ${alpha})`).replace('rgb', 'rgba');
    // Hmm, color is a hex or rgba string. Let me handle this differently.
  }
}
```

This is getting complicated with color handling. Let me use a different approach: pre-compute the sun's vertical color profile, then use it for the reflection.

Actually, let me step back and think about a cleaner implementation.

For the sun, I'll draw it as a radial gradient (as before). For the reflection, I'll create a vertical linear gradient that mirrors the sun's vertical profile, then draw it as displaced horizontal strips.

The sun's vertical profile at x=cx: from y = horizonY - r to horizonY + r, the color goes from sky-blue (top edge) through purple, pink, orange, gold, white (center) and back down through orange, pink, purple (bottom edge).

Wait, the sun is centered at horizonY. The top of the sun is at horizonY - r (in the sky), the bottom is at horizonY + r (in the water). The color at the top might be more purple/magenta (since it's the edge), and the center is white/gold.

For the reflection, I want to mirror the sun across the horizon. The reflection of the sun's top (at horizonY - r) would be at horizonY + r (below horizon). The reflection of the sun's center (at horizonY) stays at horizonY. The reflection of the bottom (at horizonY + r) would be at horizonY - r (above horizon, in the sky — but that's the actual sun bottom, not the reflection).

Hmm, I'm overcomplicating this. Let me just make the reflection a vertical gradient below the horizon that uses the sun's colors, with the brightest part near the horizon and fading downward. And add wave distortion.

Actually, let me look at this from a visual perspective. In synthwave art with a reflected sunset:
- The sun sits on the horizon
- Below the horizon, there's a vertical streak of the sun's colors (gold → pink → purple) that fades downward
- The streak has horizontal ripples (waves)

So the reflection is essentially a vertical gradient strip with wave distortion. Let me implement it as:

1. A vertical linear gradient from horizonY to horizonY + maxDepth, with colors: gold (at top) → pink → purple → transparent (at bottom)
2. Drawn as displaced horizontal strips (for the wave effect)

For the wave effect, I'll draw the reflection as a series of horizontal lines/rectangles, each at a y position that's slightly displaced by a sine wave.

Let me use a simpler color approach: use rgba strings directly.

```javascript
function drawReflection(time) {
  const r = sun.baseRadius + Math.sin(time * sun.pulseSpeed) * sun.pulseAmp;
  const maxDepth = Math.min(250, groundH * 0.8);
  const sliceH = 2;
  
  // Wave parameters
  const waveAmp = 6 + Math.sin(time * 0.5) * 2;
  const waveFreq = 0.035;
  const waveSpeed = 1.0;
  
  for (let y = horizonY + sliceH; y < horizonY + maxDepth; y += sliceH) {
    const d = y - horizonY; // depth below horizon
    const t = d / r; // normalized depth (0 at horizon, 1 at sun edge, >1 beyond)
    const fade = Math.max(0, 1 - d / maxDepth);
    
    // Color based on depth (mirror of sun's vertical profile)
    let color, alpha;
    if (t < 0.15) {
      // Near horizon: bright gold/white
      const lt = t / 0.15;
      color = `rgba(255, ${240 - lt * 40}, ${200 - lt * 80}, 1)`;
      alpha = fade * 0.7;
    } else if (t < 0.4) {
      const lt = (t - 0.15) / 0.25;
      color = `rgba(255, ${200 - lt * 60}, ${120 - lt * 60}, 1)`;
      alpha = fade * 0.6;
    } else if (t < 0.65) {
      const lt = (t - 0.4) / 0.25;
      color = `rgba(255, ${140 - lt * 40}, ${60 - lt * 30}, 1)`;
      alpha = fade * 0.45;
    } else if (t < 0.85) {
      const lt = (t - 0.65) / 0.2;
      color = `rgba(180, ${40 - lt * 20}, ${20 + lt * 20}, 1)`;
      alpha = fade * 0.25;
    } else {
      continue; // too far, skip
    }
    
    // Wave displacement
    const waveX = Math.sin(d * waveFreq + time * waveSpeed) * waveAmp;
    const waveY = Math.sin(d * waveFreq * 0.6 + time * waveSpeed * 0.7) * 1.5;
    
    const drawY = y + waveY;
    const drawX = cx + waveX;
    
    ctx.strokeStyle = color;
    ctx.globalAlpha = alpha;
    ctx.lineWidth = 1.5 + (1 - t) * 3;
    ctx.beginPath();
    ctx.moveTo(drawX - 30 - (1-t)*50, drawY);
    ctx.lineTo(drawX + 30 + (1-t)*50, drawY);
    ctx.stroke();
  }
  ctx.globalAlpha = 1;
}
```

Hmm, this draws horizontal lines for the reflection. But the waveX displacement shifts the x position of each line. Since the displacement varies with y (depth), the reflection appears wavy. But the displacement is uniform across each horizontal line (all points on the line have the same waveX). That creates horizontal wavy bands, which is the water ripple effect. Good.

But wait, I want the reflection to be a vertical column (centered at cx), not a horizontal line. The horizontal lines I'm drawing ARE the reflection column, stacked vertically. Each line is a horizontal segment centered at cx + waveX, with width that decreases with depth. The stack of these lines forms the reflection column. With waveX varying per line, the column appears wavy. Good.

But I should also vary waveX within each line for a more realistic ripple. For a more realistic water surface, the displacement varies with x too. Let me add that: for each point on the line, waveX depends on both y and x. But that requires drawing many points per line, which is slow.

For a simpler approach that still looks good: draw the reflection as a path that follows the wave, filled with a gradient. Or draw the reflection as a distorted image.

Actually, let me try a different approach: use an offscreen canvas to render the reflection gradient, then draw it with distortion using drawImage and a custom distortion. But that's also complex.

Let me stick with the horizontal strips approach but add per-pixel x variation by drawing more strips. Actually, with sliceH=2 and the line width being ~60-100px, each strip is a horizontal line. The waveX varies per strip (per y). This creates horizontal wavy bands. To add x-variation, I could draw each strip as multiple segments with different waveX. But that's a lot of drawing.

Alternative: Draw the reflection as a vertical column (filled rectangle with gradient) and then overlay wave-like brighter/darker horizontal bands (sinusoidal alpha modulation). This creates the ripple effect without distorting the geometry. The ripples would be bands of lighter/darker reflection that move.

Hmm, that's a different look (bands of brightness rather than geometric waves). Let me combine: draw the column with gradient, then add sine-wave displacement as a separate effect.

Actually, let me just go with the horizontal strips with waveX displacement. It'll look wavy enough. And to add x-variation, I'll make the strips slightly curved by drawing them as small arcs or by splitting each strip into a few segments.

Let me keep it simple: horizontal strips with per-strip waveX. Good enough for the aesthetic.

Let me also make the reflection wider at the bottom (spread out) and narrower near the horizon. I already have that with the width decreasing with t.

OK let me also think about the overall composition. Let me finalize and write the code.

Actually, let me reconsider the reflection. Instead of drawing many horizontal strips, let me draw the reflection as a single path that represents the wave-distorted column, filled with a vertical gradient. This is more efficient.

The path would be: start at (cx + waveX(0), horizonY), then for each y step, add a point (cx + waveX(y), y). Close the path on the other side: (cx - waveX(y), y) going back up. Fill with gradient.

Wait, but the reflection is symmetric around cx (the wave is centered). So the left edge is at cx - waveX(y) - width/2 and right edge at cx + waveX(y) + width/2. With waveX varying per y, the column is wavy on both sides.

Let me define:
- centerX(y) = cx + waveX(y) (the center of the column at depth y, displaced by wave)
- halfWidth(y) = baseHalfWidth * (1 - t) (narrower at depth)
- leftX(y) = centerX(y) - halfWidth(y)
- rightX(y) = centerX(y) + halfWidth(y)

The path: (leftX(0), horizonY) → (leftX(dy), horizonY+dy) → ... → (leftX(maxDepth), horizonY+maxDepth) → (rightX(maxDepth), horizonY+maxDepth) → ... → (rightX(0), horizonY) → close.

Fill with a vertical gradient.

But the gradient would be straight (not wavy). The fill follows the path, so the gradient is applied in the path's coordinate space. Hmm, actually canvas gradients are in the coordinate space, not the path. So a linear gradient from (cx, horizonY) to (cx, horizonY+maxDepth) would be applied, and the wavy path would clip it. The result: a wavy column with the gradient colors. That could work!

But the gradient is straight vertical, while the path is wavy. The fill would be the gradient clipped to the wavy path. So the colors at each y would be correct (based on the gradient at that y), but the shape is wavy. That's exactly what I want!

Let me implement this:

```javascript
function drawReflection(time) {
  const r = sun.baseRadius + Math.sin(time * sun.pulse
write /tmp/ws/index.html (13648 bytes)
toolResult

Successfully wrote 13648 bytes to /tmp/ws/index.html

assistant
thinking
The file has been written. Let me verify it was created and check for any issues.
bash {"command":"ls -la /tmp/ws/index.html && head -5 /tmp/ws/index.html"}
toolResult

-rw-r--r--. 1 node node 13648 Aug 13 01:41 /tmp/ws/index.html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0">

assistant
thinking
The file has been created successfully. Let me verify it's well-formed by checking the end of the file too.
bash {"command":"tail -5 /tmp/ws/index.html"}
toolResult

buildSunProfile(sun.baseR); }); </script> </body> </html>

assistant
thinking
The file is complete. Let me verify it's a proper self-contained HTML file.
bash {"command":"grep -c \"<style>\" /tmp/ws/index.html && grep -c \"<script>\" /tmp/ws/index.html && grep -c \"<link\" /tmp/ws/index.html && gre
toolResult

1 1 0 Command exited with code 1

assistant
thinking
The user asked for a synthwave sunset - the file is created. Let me verify it's complete and properly formatted.

The file was written successfully. Let me confirm:
- All CSS is in a `<style>` tag (1 occurrence)
- All JS is in a `<script>` tag (1 occurrence) 
- No external resources, CDNs, or network requests (0 `<link>` and 0 `src=`)

The file is complete and self-contained. Let me provide a summary.

Created `/tmp/ws/index.html` — a single self-contained file (no external resources, no network requests): **1 `<style>` block + 1 `<script>` block, inline. Zero `<link>` / `src=`.** **Visual layers, bottom → top:** - **Sky** — deep purple→magenta vertical gradient - **Starfield** — ~220 twinkling stars (varying size/tint/brightness) in the sky region - **Sun** — radial-gradient disc with animated pulse (radius + glow size oscillate), plus a bright core and a horizon "smear" band where the sun meets the ground - **Haze** — soft gradient glow band around the horizon - **Neon grid** — perspective grid that scrolls toward the viewer continuously: - Horizontal lines spread out with perspective (exponential falloff), wrap around when they pass the viewer, with glow + core stroke - Vertical lines radiating from the vanishing point - **Sun reflection / water** — wavy reflected column below the horizon, using a vertical sky-sampled color profile, with animated sinusoidal wave displacement and horizontal specular crest highlights - **Foreground floor vignette** — subtle darkening at the bottom edges **Post-processing overlays (CSS, fixed position):** - Scanlines: repeating horizontal dark lines, `mix-blend-mode: multiply` - Vignette: radial gradient darkening at edges - Chromatic tint: faint magenta top / cyan bottom bleed All values scale with window size; resize rebuilds star positions and sun profile. Run it by opening `index.html` in a browser (or serve it locally).

TAKE IT TO CODE

A single self-contained HTML file — copy it and open in any browser, or reuse the prompt with your own pi-agent run.

RUN

modelsolar-pro4
vendorUpstage
harnesspi-agent
generated2026-08-13

EVALUATION

4/5 working

Static-looking synthwave sunset with glowing sun, scanlines, neon grid, starfield, and reflection; not visibly animated between frames.

Vision grade · anthropic/claude-sonnet-5

MEASUREMENTS

Raw readings from the capture, not a verdict — the grade above is the only judgement. Percentages are how much of the screen changed; the measure under-reports sparse motion on a dark background.

idle motion1.2%
on click7.2%
on drag8.2%
on wheel0.8%
on enter + space1.1%
on arrow keys0.6%
on w/a/s/d5.2%
frame spread41.9 / 255
console errors0
js errors none

METRICS

runtime473.3s
tokens in30k
tokens out22k
cached5.4k
cost / run$0.0035