Andrew Todd Marcus
atmarcus.net · How it works

The Resonance Field

The interactive canvas on my homepage isn't decoration. It's a working model of how I think about my work — built from real physics, real projects, and a lot of patient tuning. This document explains how it was made, starting from the very beginning. You don't need any coding background to start reading. Stop wherever you want.

Level 1 · No code

What you're looking at

When you visit atmarcus.net, just below the introduction, there's a quiet canvas — warm, off-white, still. It looks like nothing is happening. But when you click anywhere on it, a ripple expands outward from that spot. A project label appears at the center. The ripple slowly spreads, brightening the areas it reaches, fading at its edges, and eventually disappearing.

Click again somewhere else. Another ripple. A different project. When the two ripples overlap, the places where they meet light up — and the places where they cancel each other go darker. This is the same thing that happens when you drop two pebbles into still water.

Each ripple is a real project I've worked on. Clicking the dot at its center, or the text label next to it, takes you to that project's page. Most ripples fade after a while — smaller projects fade faster, larger ones last longer. A few never fade at all.

The intention

This is a direct expression of how I think about my body of work. Disciplines and projects don't sit in separate buckets — they radiate outward and amplify each other where they meet. The tagline at the bottom of the field says it plainly: Where disciplines meet, they amplify.

Every time the page loads, the order in which projects appear is shuffled randomly — so no single project always dominates. The field resets itself over time. There's a "Reset field" button if you want to start over immediately.


Level 1 · No code

The metaphor — why waves?

Wave interference is a phenomenon from physics. When two waves travel through the same space — whether that's water, air, or light — they add together wherever they meet. In some places, the crests of both waves arrive at the same moment: the combined wave is taller. In other places, a crest from one wave arrives at the same moment as a trough from the other: they cancel out. The water goes flat.

Physicists call the first case constructive interference and the second destructive interference. The name of my Substack newsletter — Constructive Interference — is taken directly from this concept.

Weight — why projects aren't equal

Every project has a weight — a number from 1 to 10 that reflects how central it is to the body of work. Weight determines:

A weight of 10 is special: that node never fades. It's permanent. Sageframe — the knowledge architecture system at the center of everything I build — has a weight of 10.

The chip label in the top corner reads Constructive Interference · click to add a project. That's your invitation. The field is interactive — not a passive decoration.


Level 2 · Concept

How computers draw moving pictures

Before getting into the code itself, it helps to understand the surface it draws on — and how animation actually works in a browser.

The canvas

A normal web page is made of text, images, and boxes — things the browser arranges and displays automatically. But HTML includes a special element called a canvas. A canvas is a blank rectangle that the browser hands over to JavaScript with a single implicit instruction: you draw whatever you want in here.

The canvas is fundamentally a grid of pixels — tiny colored squares. JavaScript draws by issuing commands: draw a circle at this position with this radius, fill this rectangle with this color, write this text here. After each set of commands, the screen updates.

The animation loop

Animation works the same way it always has — by drawing the same scene over and over, slightly changed each time. Film is 24 frames per second. A browser can do 60.

The Resonance field uses a browser function called requestAnimationFrame. It works like this: you tell the browser, call my draw function just before you repaint the screen. The browser does that, your code draws whatever the current state of the waves looks like, and then at the very end it calls requestAnimationFrame again — registering itself for the next frame. This loop runs indefinitely.

The animation loop
draw() update state calculate wave field paint pixels
requestAnimationFrame browser waits ~16ms draw() · · ·
Live · requestAnimationFrame loop — fps
// These three variables drive the animation let speed = 5; // how fast t advances each frame let trail = 10; // how many ghost dots to draw let orbit = 0.30; // orbit radius as fraction of canvas width function draw() { const t = frame * 0.022 * speed; for (let i = trail; i >= 0; i--) { const px = W/2 + Math.cos(t - i*0.055) * W * orbit; const py = H/2 + Math.sin(t - i*0.055) * H * orbit; // older trail dots are more transparent ctx.arc(px, py, ...); } requestAnimationFrame(draw); // schedule next frame }
Drag the sliders and watch the values change in the code above. speed scales how fast t advances. trail controls how many ghost dots get drawn on each frame. orbit sets how wide the path is. The last line — requestAnimationFrame(draw) — is what keeps the loop going.

The performance challenge

To draw the wave field realistically, the code needs to calculate the wave value at every point on screen. A typical laptop screen is around 1400×800 pixels — that's over a million calculations per frame, 60 times per second.

To keep this fast, the code works at a coarser resolution: one calculation per 3×3 block of pixels, rather than per pixel. That reduces the math by a factor of 9. The result is then scaled back up — the browser blurs the edges in the process, which actually makes the waves look smoother, not blockier. It's a deliberate and effective tradeoff.

Retina displays

Modern screens, especially on Apple devices, have more physical pixels than CSS coordinates suggest. A canvas that appears to be 700×400 in the page layout might be 1400×800 in actual device pixels. The code tracks this ratio (called the device pixel ratio) and adjusts so the canvas stays sharp on high-density screens.


Level 3 · Light code

The data — what the code knows

Every program needs a way to represent the things it knows about. In this case: what the projects are, where they've been placed on the canvas, and what the physics parameters look like. In JavaScript, a structured collection of information is called an object.

What a project looks like to the code

Before anything appears on screen, each project exists purely as data — four labeled values bundled together:

One project · JavaScript object
name
"Sageframe"
The label shown on screen when this node is placed
url
"https://sageframe.net"
Where clicking this node takes you
color
"#8b5e8b"
The color of the wave rings and label (plum)
weight
10
Significance 1–10. Controls reach, wavelength, label size, and lifespan

All 12 projects are stored in a numbered list called an array. When the page loads, that list is shuffled into a random order using an algorithm called Fisher-Yates. Each click on the canvas places the next project from the shuffled list.

What a placed node looks like at runtime

When a project is actually placed by a click, a richer object is created. It links back to the original project data, but also records where it landed and when:

One placed wave source · runtime object
proj
(project object)
A pointer back to the project's name, url, color, weight
x
342
Horizontal pixel position where you clicked
y
198
Vertical pixel position
bornAt
14.23
Seconds since page load when this node was placed
lifespan
60.0
How many seconds before this node fades completely

All placed nodes live in a single array called placed. The draw loop reads this array every frame to know what to render. When a node exceeds its lifespan, it's removed from the array.

The physics tuning object

Separately, a set of named numbers controls how waves behave overall. These come from an external file — hero-config.js — which keeps the tunable parameters separate from the logic:

HERO_RES_TUNE · physics parameters
speed
0.30
How fast wave rings expand outward (multiplied by 70 for pixel speed)
wavelength
66
Base distance between wave rings, in pixels
drift
0.32
How much nodes slowly wander after being placed
smoothing
0.55
How much each frame blends with the previous one (creates motion softness)
fadeBase
21
Base lifespan in seconds for a weight-5 node. Lighter and heavier nodes scale from this.

Level 3 · Math concept

The physics of waves

You don't need to know mathematics to understand this. But the wave calculation is the heart of the whole system — everything visual comes from it — so it's worth understanding the idea.

What a wave is, in numbers

A wave is something that oscillates — moves up and down — as it travels through space. Think of a single point on the surface of a pond after a stone is dropped. That point rises and falls in a rhythm, and so does every other point nearby, each slightly delayed depending on how far from the stone it is.

For any point on the canvas, the code asks: what is the height of the wave at this exact location, right now? The answer depends on four things:

The formula looks like this:

wave equation
height = amplitude × cos( (2π / λ) × d  −  (2π / λ) × speed × time )
Live · single wave source · click canvas to restart
// The two parameters that define this wave const lam = 52; // λ — wavelength in pixels (ring spacing) const spd = 55; // wave propagation speed (px/s) // Derived — recalculated every frame const k = (2 * Math.PI) / lam; // wave number (radians per pixel) const front = t * spd; // how far the wavefront has traveled // For each pixel cell: const height = Math.cos( k * dist − k * spd * t );
Drag lam to change ring spacing — the value you're dragging is the exact number passed to the wave equation as λ. Drag spd to change how fast the wavefront expands. Watch both values update in the code block above.

The cosine function produces the alternating rings: it swings smoothly from +1 (peak) to -1 (trough) and back, over and over. The expression inside the parentheses — involving distance and time — determines where we are in that cycle at any given point and moment.

Interference: when two waves meet

When two nodes are placed, the code calculates the wave value from each source at every pixel cell, then adds the results together. That's it. That's interference.

Where both waves are at their peak simultaneously: the sum is large and positive. The pixel brightens toward the node's color. Where one is at a peak and the other at a trough: they partially or fully cancel. The pixel stays near the background tone or darkens slightly.

Live · two-source interference
// Two source positions, one shared wavelength const lam = 48; // shared λ — both sources same wavelength const gap = 0.40; // spacing as fraction of canvas width const spd = 50; // wave speed (px/s) const srcs = [ { x: W * (0.5gap/2) }, // left source (orange) { x: W * (0.5 + gap/2) }, // right source (blue) ]; // For each pixel, sum contributions from both sources: const k = (2 * Math.PI) / lam; sumH += A * Math.cos( k * dist − k * spd * t );
Drag lam — a smaller wavelength packs more rings and creates a tighter interference pattern. Drag gap to move the two source positions and watch the pattern re-form around the new geometry. Every value you change maps directly to what appears in the code block above.

Keeping colors from blowing out

If you keep adding waves, the total value could get very large — especially where many nodes are close together. That would produce colors far outside any visible range. The code passes the total through a function called tanh (hyperbolic tangent), which compresses any value — no matter how large — into the range -1 to +1. It's a soft ceiling and floor. Colors never blow out to pure white or pure black.

Turning wave values into colors

The background is the warm off-white of the rest of the site. A positive wave value blends that pixel toward the project's color. A negative value darkens it slightly toward near-black. Pixels far from any source stay the background color. The blend amounts are modest — the effect is subtle, not saturated.


Level 4 · Architecture

How the pieces fit together

The code has four distinct responsibilities. Understanding how they relate makes the rest of the document much easier to follow.

System map
hero-config.js loads project list, colors, and physics tuning into the page
User click placed[ ] array ← one entry per click, with position and timestamp
draw( ) reads placed[ ] calculates waves paints pixels
Author panel writes to RES_TUNE directly; draw() reads it live each frame

State lives in one place

The placed array is the single source of truth for what's happening on the canvas. Everything else is derived from it. The draw loop doesn't remember anything between frames — it reads placed, calculates what the screen should look like right now, draws it, and starts fresh on the next frame.

Events change state. draw() renders it.

When you click the canvas, a click handler adds a new entry to placed. When you click Reset, a handler empties it. The draw loop runs on its own schedule — 60 times per second — completely independent of events. It just looks at whatever state exists at that moment and renders it.

This separation is one of the cleanest and most important patterns in interactive programming. Events modify state. Rendering reads state. They never directly touch each other. It's the same fundamental model used by modern UI frameworks like React.

The two files


Level 4 · Full code

The functions, one by one

A function is a named, reusable block of code that does one specific job. Think of it as a machine: you put something in, and get something back out. Here are all the functions in this file, in plain language, with their code.

In the annotated code below, each line is paired with a plain-English explanation on the right. The colored text in the code is called syntax highlighting — it doesn't change what the code does, it just makes it easier to read at a glance.

hexWithAlpha ( hex, a ) → string

Converts a hex color code (like "#c45a2d") and an opacity value (0 is invisible, 1 is solid) into a CSS color string with transparency: rgba(196,90,45,0.7). Used everywhere a node or label needs to fade in or out over time.

Input
hex string, alpha 0–1
Output
"rgba(r,g,b,a)" string
function hexWithAlpha(hex, a) {
Define the function. It takes a hex color string and an alpha (opacity) number.
const h = hex.replace('#', '');
Remove the leading # symbol. "c45a2d" is easier to work with than "#c45a2d".
const r = parseInt(h.slice(0,2), 16),
Take the first two characters ("c4") and convert from base-16 (hex) to a regular number: 196.
g = parseInt(h.slice(2,4), 16),
Characters 3–4 ("5a") → 90. That's the green channel.
b = parseInt(h.slice(4,6), 16);
Characters 5–6 ("2d") → 45. Blue channel.
return `rgba(${r},${g},${b},${a})`;
Assemble and return the final color string. The backtick syntax embeds the variables directly into the text.
}
End of function.
hexRGB ( hex ) → [r, g, b]

The same conversion as hexWithAlpha, but returns the three color values as raw numbers in an array instead of a string. The pixel-painting loop works directly with numbers, so this avoids building and parsing strings inside the innermost loop — a meaningful performance saving when it runs millions of times per second.

Input
hex string
Output
[196, 90, 45] — three numbers 0–255
function hexRGB(hex) {
Simpler form — no alpha needed.
const h = hex.replace('#', '');
Strip the # symbol.
return [parseInt(h.slice(0,2),16),
Return an array: red first…
parseInt(h.slice(2,4),16),
…green second…
parseInt(h.slice(4,6),16)];
…blue third. All returned at once as a three-item array.
}
setupCanvas ( ) → void

Configures the canvas to exactly match the pixel dimensions of its container, and scales everything correctly for high-density displays. Called once on page load and again whenever the window is resized.

Input
none (reads from the DOM and window)
Output
none (modifies the canvas element in place)
function setupCanvas() {
Called on load and on every resize event.
const r = canvas.getBoundingClientRect();
Ask the browser: what are the current displayed dimensions of this canvas element? This reflects the actual CSS layout, including any flex or percentage sizing.
canvas.width = r.width * DPR;
Set the canvas's internal pixel resolution. On a retina display, DPR=2 — so we allocate twice the pixels to stay sharp.
canvas.height = r.height * DPR;
Same for height.
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
Tell the drawing context to scale all coordinates by DPR. This means we can write our drawing code in CSS pixels — "draw a circle at x=400" — and it renders correctly on both standard and retina screens without any other changes.
}
wavelengthFor ( weight ) → pixels

Returns how far apart the wave rings should be for a given project weight. Higher weight means tighter rings — a more intricate, dense pattern. Lower weight means wider, more spacious rings. The result is in pixels.

Input
weight 1–10
Output
wavelength in pixels (roughly 38–106 at default settings)
function wavelengthFor(w) {
w is the project's weight.
const u = (w - 1) / 9;
Normalize weight to a 0–1 scale. w=1 gives u=0; w=10 gives u=1. This makes the math below cleaner — we work with a fraction rather than a number that might be 1 through 10.
return Math.max(10, RES_TUNE.wavelength * (1.6 - u * 1.05));
At u=0 (weight 1): multiply the base wavelength by 1.6 — wide rings. At u=1 (weight 10): multiply by 0.55 — tight rings. Math.max(10, …) ensures the wavelength never goes below 10px, which would look degenerate.
}
reachFor ( weight ) → pixels

Returns how far a node's wave spreads across the canvas before it becomes invisible. This is expressed as a multiple of that node's wavelength — heavier nodes get a much larger multiple, so their waves reach proportionally farther. A weight-10 node's reach can span the entire canvas; a weight-1 node barely extends beyond its immediate area.

Input
weight 1–10
Output
reach in pixels
function reachFor(w) {
const u = (w - 1) / 9;
Same normalization to 0–1 as before.
return wavelengthFor(w) * (3 + u * 19);
Reach = wavelength × a multiplier. At weight 1: 3× the wavelength. At weight 10: 22× the wavelength. The effect on the canvas is dramatic — a weight-10 node's wave literally fills the field.
}
lifespanFor ( weight ) → seconds

Returns how long, in seconds, a node stays visible before fading out completely. Weight-10 nodes return Infinity — they never fade. All others follow a smooth curve: a weight-5 node lasts fadeBase seconds; weight-9 lasts about 3× longer; weight-1 lasts less than a third as long.

Input
weight 1–10
Output
seconds (a number), or Infinity
function lifespanFor(w) {
if (w >= 10) return Infinity;
Weight 10 = permanent. Infinity is a real JavaScript value — not a placeholder. The node will never be culled.
const base = RES_TUNE.fadeBase ?? 20;
Read the base duration from the tuning config. The ?? means "use 20 if this value isn't set" — a safe fallback.
const u = (w - 1) / 9;
Normalize to 0–1 as usual.
const mul = 0.3 + Math.pow(u, 1.4) * 3.5;
A multiplier on a smooth, accelerating curve. At u=0: 0.3×. At u=1: 3.8×. The exponent 1.4 makes the curve skew toward the heavy end — most projects are in the "notable but fading" zone.
return Math.max(3, base * mul);
Apply the multiplier to the base duration. Math.max(3, …) ensures even the smallest node gets at least 3 seconds — long enough to see it register.
}
envelope ( age, lifespan ) → 0 to 1

Returns a number between 0 and 1 representing how strong a node's wave should be at any point in its life. It rises smoothly from zero to full strength in the first 0.8 seconds (no jarring pop-in), holds at full strength through most of the node's life, then fades out gently over the final 25% of its lifespan. This shape — rise, hold, fade — is called an amplitude envelope, borrowed from audio synthesis.

Input
age in seconds, lifespan in seconds
Output
0.0 to 1.0 — the node's current strength
function envelope(age, life) {
const rise = Math.min(1, age / 0.8);
How far through the 0.8-second rise phase are we? Clamps to 1 once we've passed it — so this is 0 at birth, 1 at 0.8 seconds, and stays 1 after that.
const riseE = rise * rise * (3 - 2 * rise);
Smooth the linear rise using a cubic curve (called "smoothstep"). Instead of a sharp linear ramp, the rise starts slowly, accelerates through the middle, then eases into its peak. No visible pop.
if (!isFinite(life)) return riseE;
Permanent nodes (lifespan = Infinity) only need the rise phase. They reach full strength and stay there forever.
if (age >= life) return 0;
If the node has exceeded its lifespan, its amplitude is zero. It's gone.
const tailStart = life * 0.75;
The fade-out begins at 75% of the total lifespan.
if (age <= tailStart) return riseE;
Before the fade starts, return the rise value (which is 1 for most of the node's life).
const u = (age - tailStart) / (life - tailStart);
How far through the fade phase are we? 0 at the start of the fade, 1 at the very end.
const fade = 0.5 + 0.5 * Math.cos(u * Math.PI);
A cosine curve that goes from 1 to 0 smoothly. At u=0: cos(0)=1, so fade=1. At u=1: cos(π)=-1, so fade=0. The 0.5+0.5× scaling maps this to the 0–1 range.
return riseE * fade;
The final amplitude: the rise value (1 during most of life) multiplied by the fade curve.
}
Live · envelope(age, lifespan) — amplitude over time
function envelope(age, lifespan) { // Rise: smoothstep from 0 → 1 over the first riseTime seconds const rise = Math.min(1, age / 0.8); const riseE = rise * rise * (32 * rise); // smoothstep curve // Fade: cosine taper starting at fadeStart × lifespan const tailStart = lifespan * 0.75; if (age > tailStart) { const u = (age − tailStart) / (lifespan − tailStart); const fade = 0.5 + 0.5 * Math.cos(u * Math.PI); // 1 → 0 return riseE * fade; } return riseE; } // lifespan for this demo: 10 seconds
Every value you drag updates in the code above and reshapes the curve in real time. riseTime is the denominator in age / riseTime — bigger = slower attack. fadeStart is the multiplier on lifespan — 0.75 means the fade begins at 75% of life. lifespan is passed in directly as the second argument.
hitTest ( mx, my ) → placed node or null

Checks whether a mouse position (mx, my) is close enough to any placed node's center dot to count as clicking on it. Returns the node if there's a hit, or null if the cursor isn't over any node. The companion function hitLabel does the same check for the text labels.

function hitTest(mx, my) {
Checks against the circular node dots.
for (let i = placed.length - 1; i >= 0; i--) {
Loop backwards through the array — newest nodes first. If nodes overlap, clicking hits the one placed most recently.
const p = placed[i];
Grab the node at this index.
if (Math.hypot(p.x - mx, p.y - my) < 18) return p;
Math.hypot computes straight-line distance using the Pythagorean theorem: √(dx² + dy²). If the cursor is within 18 pixels of the node center, it's a hit — return the node immediately.
}
return null;
No node was close enough. Return null — the caller treats this as "nothing was clicked."
}

hitLabel is nearly identical but checks against a rectangle — the bounding box of each node's text label. Every frame, the draw loop stores each label's position and dimensions in p._labelRect, which hitLabel reads. Clicking either the dot or the label opens the project's URL.

ensureBuffer ( W, H ) → void

Makes sure the pixel buffer — the working grid the physics calculation writes into — is the right size. If the canvas has been resized since the last frame, it allocates a fresh buffer. Otherwise it reuses the existing one. Buffer allocation is expensive, so this is called every frame but only does real work when something has actually changed.

Input
canvas width and height in CSS pixels
Output
none (updates module-level buffer variables)
const bw = Math.ceil(W/CELL), bh = Math.ceil(H/CELL);
CELL is 3. A 900×600 canvas becomes a 300×200 buffer — 9× fewer calculations. Math.ceil rounds up so we don't cut off the edge.
if (bw !== bufW || bh !== bufH || !buf) {
Only rebuild if the dimensions changed or the buffer doesn't exist yet.
buf = ctx.createImageData(bw, bh);
Allocate a new pixel buffer. Internally it's a flat array with 4 slots per pixel: Red, Green, Blue, Alpha. For a 300×200 buffer that's 240,000 numbers.
prevBuf = null;
The previous frame's buffer is the wrong size now. Discard it — the temporal smoothing step will skip for one frame.
}
reset ( ) → void

Clears everything. Called when the user clicks "Reset field." Three things happen: the list of active wave sources is emptied, the project rotation counter goes back to zero, and the running clock resets to zero.

function reset() {
placed = [];
Empty the array. The next draw() call will find nothing in it and paint a blank field.
projectIndex = 0;
Start cycling through the project list from the beginning again.
tt = 0;
Reset the clock to zero. Lifespans are measured relative to this clock — resetting it means every node placed after reset gets a fresh lifespan calculation.
}

Level 4 · Full code

One frame, in slow motion

The draw() function is where everything converges. It runs roughly 60 times per second. Here's every step in order, explained as if we could slow time down to watch it.

Step 1 — Update the clock

const now = performance.now();
Ask the browser for the current time in milliseconds. This is a high-precision timer — more accurate than a regular clock for animation work.
const dt = Math.min(0.05, (now - lastFrame) / 1000);
How many seconds elapsed since the last frame? Divide by 1000 to convert from milliseconds. Cap at 0.05 seconds — if the browser tab was hidden, or the computer woke from sleep, we don't want a sudden massive time jump that breaks the physics.
lastFrame = now; tt += dt;
Record when this frame started. Advance the master clock by the elapsed time. tt is the central clock all lifespan and wave calculations read from.

Step 2 — Apply drift (if enabled)

Drift makes placed nodes slowly wander, as if they have a small, organic restlessness. It's disabled by default (drift=0) and only runs if the tuning config enables it.

if (RES_TUNE.drift > 0.001) {
Only do drift math if it's actually configured — avoids unnecessary computation every frame.
const settle = Math.min(1, age / 1.5);
Nodes don't drift immediately. They take 1.5 seconds to "settle in" before drift reaches full strength. This prevents a freshly-placed node from sliding away from where you clicked.
const mask = settle * settle * (3 - 2 * settle);
Smooth the settle ramp (same smoothstep technique as the envelope). Drift fades in gradually.
s.x += Math.sin(tt * 0.22 + i) * 0.30 * RES_TUNE.drift * mask * (dt * 60);
Nudge X position by a tiny amount driven by a slow sine wave. Each node uses a different phase (the + i term) so they drift in different directions. Multiplying by (dt × 60) makes the drift speed frame-rate independent.
s.y += Math.cos(tt * 0.19 + i * 1.3) * 0.24 * RES_TUNE.drift * mask * (dt * 60);
Same for Y but using cosine at a slightly different frequency. The combination of sine-X and cosine-Y at different frequencies creates a slow orbital drift rather than linear back-and-forth.
Live · drift — 3 nodes wandering
// Applied every frame to each placed node: const drift = 0.6; // drift strength (0 = off) const settle = 1.5; // seconds before drift reaches full strength // Smoothstep mask — drift fades in over settle seconds const s = Math.min(1, age / settle); const mask = s * s * (3 - 2 * s); // Different phase per node (i) → each drifts a different direction node.x += Math.sin(tt * 0.22 + i) * 0.30 * drift * mask * (dt * 60); node.y += Math.cos(tt * 0.19 + i * 1.3) * 0.24 * drift * mask * (dt * 60);
Three nodes placed at spawn and immediately beginning to drift. Drag drift to zero and the nodes freeze in place — that's the default in production. Drag settle to a small value and nodes will start wandering right away; at a large value they hold position for several seconds first. Click the canvas to reset positions.

Step 3 — Remove expired nodes

placed = placed.filter(p =>
Filter creates a new array containing only items that pass the test. Items that fail are quietly discarded.
!isFinite(p.lifespan) ||
Keep this node if its lifespan is Infinity (permanent nodes always pass)…
(tt - p.bornAt) < p.lifespan + 0.1);
…or keep it if its age is still within lifespan. The +0.1 second grace period ensures the fade-to-zero from the envelope function has time to fully complete before removal.

Step 4 — Calculate the wave field

This is the innermost loop and the most computationally intensive step. For each cell in the downsampled pixel grid:

const srcs = placed.map(p => { ... });
Pre-compute each node's current age, envelope value, wavefront radius, and color. Doing this once before the nested loops avoids recalculating it for every pixel.
for (let y = 0; y < bufH; y++) {
Outer loop over rows of the buffer.
for (let x = 0; x < bufW; x++) {
Inner loop over columns. For each (x,y) we'll compute one pixel cell's color.
if (d > s.front || d > s.reach) continue;
Skip this source if the wavefront hasn't reached this cell yet, or if the cell is beyond the source's reach. This early exit is a critical optimization — on a large canvas, most source-pixel pairs fail this test, and the expensive cosine calculation below is skipped entirely.
const A = Math.max(0, 1 - d / s.reach) * (1 / (1 + d * 0.006)) * s.env;
Amplitude at this point: starts at 1 near the source, falls off linearly with distance (first term), also diminishes with an inverse curve (second term), then scaled by the node's lifetime envelope (third term). All three factors combine.
const phase = k * d - k * propSpeed * s.age;
The wave phase at this point in space and time. k = 2π / wavelength. As d increases, the phase advances around the cycle — creating rings. As age increases, the rings scroll outward.
sumH += A * Math.cos(phase);
Add this source's contribution to the running total. Cosine of the phase gives the wave height at this location: oscillating between -A and +A. All sources are summed together — this is the interference calculation.
tintR += s.rgb[0] * A; tintG += s.rgb[1] * A; tintB += s.rgb[2] * A;
Accumulate color separately — weighted by each source's amplitude at this point. Dividing by sumAbsA later gives a blend of colors proportional to each source's influence.

Step 5 — Convert wave value to color

const h01 = Math.tanh(sumH * 1.2);
Compress the accumulated wave height through tanh. No matter how many waves pile up, the result stays between -1 and +1. The 1.2 multiplier adds a bit more contrast before compression.
const mix = h01 * energy * 0.42;
Where the wave is positive, calculate a blend amount. 0.42 caps how saturated the color can get — even at peak constructive interference, you never reach pure project color. The effect stays subtle.
R = R * (1 - mix) + tr * mix;
Linear interpolation: mix between the background color (R, G, B) and the weighted project color (tr, tg, tb). This line handles red; two identical lines below handle green and blue.
const mix = -h01 * energy * 0.18;
Where the wave is negative (troughs), blend toward near-black. Uses 0.18 rather than 0.42 — dark troughs are intentionally more subtle than bright peaks.

Step 6 — Temporal smoothing

After the wave field is computed, each pixel is blended with the same pixel from the previous frame. This is the single most visible quality enhancement: it creates the soft, almost filmic quality of the animation.

a[i] = (a[i] * (1 - smooth) + b[i] * smooth) | 0;
At smoothing=0.55: each pixel is 45% the new frame and 55% the previous. Changes wash in gradually rather than switching abruptly. The | 0 at the end truncates to an integer — pixel values must be whole numbers.

Step 7 — Scale up and paint

tmpCtx.putImageData(buf, 0, 0);
Write the small buffer (say, 300×200 pixels) to a hidden off-screen canvas.
ctx.drawImage(tmp, 0, 0, bufW, bufH, 0, 0, W, H);
Draw that small canvas stretched to fill the full display canvas. The browser applies bicubic interpolation as it scales — this blurs the block edges and makes the coarse grid look smooth rather than pixelated. A happy side effect of the performance tradeoff.

Step 8 — Draw the node dots and labels

After the wave field is painted, the code draws each placed node on top: a translucent halo (larger when hovered), a solid central dot, and the project name as text in the node's color. The label's bounding rectangle is calculated and stored in p._labelRect for hit-testing on the next click. Node size and label font size both scale with the project's weight.

Step 9 — Request the next frame

requestAnimationFrame(draw);
The last line of draw(). Register with the browser to call draw() again just before the next screen repaint. This is what creates the loop — the function schedules itself. It runs until the page closes.

Level 4 · Full code

Author mode — tuning the field

There's a hidden control panel built into the hero. It's only visible when you open the page with ?edit appended to the URL — like /hero/resonance.html?edit. In normal production use, it's invisible.

Why this exists

Wave physics have many parameters that interact in non-obvious ways. Rather than changing a number in the code, deploying to a live server, and looking at it — the author panel lets you move sliders and watch the field respond in real time. When it looks right, one button generates the complete config file, ready to paste in and deploy. This is the tool I used to find the values currently live on the site.

How the panel activates

const IS_EDIT = new URLSearchParams(location.search).has('edit');
Read the URL's query string. URLSearchParams is a browser built-in that parses "?key=value" strings. This checks whether "edit" appears as a key — without needing to parse the string manually.
if (IS_EDIT) document.body.classList.add('edit');
If the URL contains ?edit, add the CSS class "edit" to the body element. The panel is hidden by default. The CSS rule body.edit .tweaks { display: block } makes it visible. A single class toggle — zero JavaScript beyond that.

How sliders connect to the physics

Each slider is a standard HTML range input. When any slider moves, a function called syncTune() reads all slider values, updates the RES_TUNE object, and saves the state to the browser's local storage. Because draw() reads from RES_TUNE on every frame, the wave field responds to slider changes instantly — no reload, no delay.

RES_TUNE.speed = parseInt(speed.value, 10) / 100;
The slider stores integers (0–200) to avoid floating-point quirks with range inputs. Dividing by 100 converts back to the decimal value (0–2.0) the physics expects.
[speed, wl, drift, smooth, fade].forEach(el => el.addEventListener('input', syncTune));
Wire all five sliders to syncTune in one line. Whenever any slider moves, syncTune fires.

The project node editor

The author panel also shows every project node as an editable row. You can rename nodes, adjust their weight, change their color, edit their URL, or delete them. Each change is saved to local storage immediately. A "Reset" button restores the default list.

Exporting the config

The Export Config button reads the current state of the colors, projects, and physics, and assembles them into a complete JavaScript file — formatted exactly as hero-config.js expects. It then copies this to the clipboard. You paste it over hero-config.js, save the file, commit to git, push to GitHub, and the Cloudflare deployment picks it up within about a minute.

const colorEntries = Object.entries(COLORS).map(([k, v]) =>
Go through every entry in the COLORS object (each is a key-value pair)…
` ${(k + ':').padEnd(11)} '${v}',`).join('\n');
…format each one as a line of JavaScript with the key padded to 11 characters for alignment. Join all lines with newlines.
await navigator.clipboard.writeText(out);
Write the assembled file string to the system clipboard. The await means we pause here until the clipboard operation completes (it's asynchronous because it requires browser permission).

The full authoring loop: open /hero/resonance.html?edit while the local dev server runs → move sliders → Export Config → paste into hero-config.js → save → commit → push → trigger the deploy hook. The live site updates in about a minute.