Prisma Duel/The ray tracer Play the game →

How the laser works

A spectral ray tracer as a game weapon — Snell, Fresnel, Cauchy and 96 wavelengths, with live examples

In Prisma Duel the weapon is light. Not a line with a hit test on it, but a beam that is traced as light: ninety-six wavelengths leave the muzzle, each one bends by its own amount when it meets a crystal, some of it reflects and some transmits at every face, it fades as it crosses glass, it comes off the mirrored walls at 86%, and whatever lands on a hull is the damage. This page walks through the whole thing, from a single wavelength to the shader that draws it. Every figure is live — drag the sliders, drag on the tracer.

The code shown is the game's own, lightly trimmed. It is all in one file, so you can read the rest of it in the page source.

1. One wavelength, and what colour it is

A ray in this tracer is not red or green or blue. It is a wavelength, in nanometres, somewhere between 398 and 706 — the part of the spectrum a display can say anything useful about. To put it on screen it has to become RGB, and the honest way to do that is the way the eye does: integrate against the CIE 1931 colour-matching functions to get XYZ, then apply the sRGB matrix.

// Wyman/Sloan/Shirley multi-lobe Gaussian fits of the CIE 1931 observer
function cieXYZ(l) {
  const x = gauss(l, 1.056, 599.8, 37.9, 31.0) + gauss(l, 0.362, 442.0, 16.0, 26.7) + gauss(l, -0.065, 501.1, 20.4, 26.2);
  const y = gauss(l, 0.821, 568.8, 46.9, 40.5) + gauss(l, 0.286, 530.9, 16.3, 31.1);
  const z = gauss(l, 1.217, 437.0, 11.8, 36.0) + gauss(l, 0.681, 459.0, 26.0, 13.8);
  return [x, y, z];
}

Two details matter. Pure spectral colours lie outside the sRGB gamut, so the matrix hands back negative components; clipping them to zero flattens violet and cyan into mush. Instead each colour is desaturated just enough to bring it inside — add white until the smallest channel is zero — which keeps its hue and its luminance. And the whole band is normalised so that a flat, equal-energy spectrum integrates to neutral white. That normalisation is what makes a dispersed fan keep its true luminance profile: bright in the yellow-green, dim at the violet end, exactly as a real rainbow is.

Figure 1. The three observer curves, and underneath them what each single wavelength becomes after the matrix, the gamut fix and the normalisation. Slide to see the XYZ and RGB of one wavelength. Outside 398–706 nm the eye's response is so weak that the game does not bother.

2. Snell's law: the bend at a face

When a ray reaches the face of a crystal it changes direction. Snell's law says n₁ sin θᵢ = n₂ sin θₜ: the ratio of the sines is the ratio of the refractive indices. Going into glass (n about 1.4) the ray bends towards the normal; coming out it bends away. Coming out there is an angle beyond which sin θₜ would have to exceed 1, and nothing transmits at all — total internal reflection. That is a big part of why light gets trapped and guided inside a crystal, and why a shot into one at a shallow angle comes out somewhere you did not expect.

Figure 2. White is the incoming ray. The two that leave the face are drawn as bright as the fraction of the power they carry: reflected in white, transmitted in blue. Tick leaving and push the angle up to watch the transmitted ray disappear at the critical angle.

3. Fresnel: how much reflects, and how much goes through

Snell says where; Fresnel says how much. At every face a ray splits into a reflected part and a transmitted part, and the split depends on the angle: head-on, a 1.4 glass reflects about 3%; at a grazing angle it reflects nearly everything, which is why a lake is a mirror at sunset. The game uses the full equations for the two polarisations and averages them, because a laser in this world is unpolarised and because it costs nothing.

const Rs = ((n1 * cosi - nn * cost) / (n1 * cosi + nn * cost)) ** 2;
const Rp = ((n1 * cost - nn * cosi) / (n1 * cost + nn * cosi)) ** 2;
R = clamp((Rs + Rp) * 0.5, 0, 1);        // unpolarised
if (sin2t > 1) R = 1;                       // total internal reflection
Figure 3. Reflectance against angle. Blue is the average the game uses going into glass; the thin grey pair are the two polarisations, and the dip in the dashed one is Brewster's angle. Yellow is the same ray trying to get out: it climbs to 100% at the critical angle and stays there. This one curve is why aiming matters — past about 75° a crystal is a mirror, not a prism.

4. Dispersion: why the rainbow

Everything above would be true of a grey ray. The rainbow happens because the refractive index is not one number: it depends on wavelength. Violet bends more than red. The classic empirical form for glass is Cauchy's equation, n(λ) = A + B/λ², and the game uses it, written so that A is the index at the sodium D line (589.3 nm) — the number a glass catalogue quotes — and B is how strongly the crystal disperses:

/** Cauchy dispersion for one prism: n(lambda) = A + B/lambda^2, lambda in um. */
function iorAt(P, lam) {
  const um = lam * 1e-3;
  return P.ior + P.disp / (um * um) - P.disp / (0.5893 * 0.5893);
}

Every crystal in an arena is generated with its own ior between 1.34 and 1.44 and its own disp between 0.045 and 0.075, from the arena seed. That is deliberately more dispersive than real optical glass (a heavy flint is around 0.01 in these units): the arena is small and a beam crosses a crystal in a couple of hundred pixels, so the fan has to open fast to be visible at all.

Figure 4. Index against wavelength, the curve coloured by the light it bends. The shaded band is every crystal the game can generate. Set B to zero and the fan closes: every colour takes the same path and the beam stays white.

How much rainbow you get is decided at the way in. Snell says the angular spread between red and violet grows with the sine of the angle of incidence — a head-on shot barely disperses at all — but past about 75° Fresnel reflects most of the light before it can. So the shot the bots learn to take is off-axis, and not so far off-axis that the glass turns mirror.

5. Absorption inside the glass

Light crossing a medium loses power exponentially with distance: Beer–Lambert. The game applies p *= exp(-0.55 · d) along every segment inside a crystal. It is a small effect over one crossing and a large one for light that gets trapped bouncing around inside by total internal reflection — which is what stops a trapped ray living forever, and what makes the interior glow fade the way a real guided beam does.

Figure 5. Power remaining against distance travelled inside a crystal. A single crossing costs 10–25%; five internal bounces cost most of it.

6. Putting it together: the tracer

With those four rules, the tracer is short. Every firing ship emits N rays; each ray is a wavelength; each ray is pushed on a stack as [x, y, dx, dy, power, depth, insidePrism]; and the loop pops rays until the stack is empty, finding the nearest thing each one hits:

  • a hull — the ray is absorbed and, on the damage pass, recorded;
  • a mirrored wall — reflect it, times 0.86, and push it back;
  • a crystal face — compute n(λ), Fresnel, and push two rays: the reflected one with p·R and, unless it is totally internally reflected, the transmitted one with p·T in the refracted direction.
while (stack.length) {
  let [x, y, ux, uy, p, depth, inPrism, dist] = stack.pop();
  if (p < traceRefPower * 0.006 || depth > MAX_BOUNCE) continue;   // too dim, or 9 bounces deep
  const h = sceneHit(x, y, ux, uy, ...);
  if (inPrism >= 0) p *= Math.exp(-h.t * 0.55);                    // Beer–Lambert
  pushSeg(x, y, hx, hy, r, g, b, p, ...);                            // what the GPU draws
  if (h.kind === HULL)   { if (collectHits) beamHits.push({...}); continue; }
  if (h.kind === WALL)   { stack.push([..., p * WALL_REFLECT, depth + 1, ...]); continue; }
  // a crystal face: Fresnel split into a reflected and a refracted ray
  const nGlass = iorAt(P, lam);  ...
  if (p * R > cutoff) stack.push([..., p * R, depth + 1, inPrism]);
  if (hasT && p * T > cutoff) stack.push([..., p * T, depth + 1, entering ? h.idx : ...]);
}

Two thresholds keep it bounded: a ray below 0.6% of a fresh ray's power is dropped, and nothing goes deeper than nine bounces. Every segment a ray travels is appended to a flat Float32Array — up to 90,000 of them — and handed to the GPU as one instanced draw. Here is that loop, in two dimensions, on one crystal:

Figure 6. Drag on the picture to aim. This is the game's tracer loop, in miniature: N wavelengths, Fresnel splitting at every face, total internal reflection, Cauchy dispersion, Beer–Lambert inside the glass, and walls at 86%. Try a shallow shot into a face for a wide fan; try a steep one to watch the face turn into a mirror; set rays to 8 to see what a fan is made of; untick the walls to see the fan on its own, and tick them again to see why an arena full of mirrors is a dangerous place.

7. Sampling: why the fan is a wash, not a comb

A dispersed fan is, in truth, N discrete lines — one per wavelength. Space them evenly and, after a couple of bounces have spread them apart, they read as a comb of coloured rays instead of a rainbow. The fix is the usual one in rendering: stratify the wavelengths, then jitter each within its stratum. The jitter must be deterministic — the physics runs on every peer — so it comes from a van der Corput sequence rather than Math.random:

const u   = (k + (vdc(k + 1) - 0.5) * 0.9) / (N - 1);   // stratified + low-discrepancy jitter
const lam = SPEC_LO + clamp(u, 0, 1) * (SPEC_HI - SPEC_LO);

Untick jitter in Figure 6 and drop the ray count to see the comb. In the game the drawn beam uses 96 rays during a match and 512 on the start screen, where nothing is waiting on the CPU; the quality governor steps that down on a machine that cannot hold its frame rate.

8. Damage is dwell, and it is deterministic

The same tracer runs twice. The display pass, above, is allowed to vary. The damage pass is not: it always uses exactly 16 rays, because every peer in a multiplayer match must compute the same damage from the same orders with no server to arbitrate. Once per substep — there are 96 in a four-second turn — every hit ray deposits power × milliseconds × DPS into the hull it landed on. Damage is therefore literally per millisecond of dwell: a beam that sweeps across a ship for a tenth of a second does a tenth of a second's worth.

The 16 damage rays are also spread across the beam's waist, on a base-3 van der Corput sequence so it is decorrelated from the wavelength one. Every ray used to leave the muzzle from the same point, so the beam was a mathematically thin line: a hull intercepted all of it or none, and a graze that clipped a wingtip did as much damage as a shot down the throat. Now damage is proportional to how much of the beam the ship actually blocks:

Figure 7. The damage rays across the waist and a hull sliding across the beam. Bright rays land. The waist is 0.0101 world units against a hull radius of 0.046, so a clean hit still catches every ray at any range — the game does not lose damage with distance, on purpose: a range penalty was measured to suppress bank shots through crystals fivefold, and killed the mechanic.

Because the tracer's power already has every real loss taken out of it — Fresnel at each face, absorption inside glass, the walls' 86% — a ray that arrives weakened does proportionally less. A fan through a crystal is many faint rays; a bank shot off two walls is 74% of a direct one. No separate damage table exists.

9. Drawing it so it looks like light

The segments go to a WebGL2 shader as instanced quads, each with its colour, its power and whether it is inside glass. The core of a laser is far thinner than a pixel, and point-sampling something thinner than a pixel scintillates as it moves. So the shader draws a Gaussian core convolved with the pixel footprint — the physical width added in quadrature to the pixel's — and divides the peak by the widening, so the flux carried by the line is constant however thin it gets. It measures the exact screen footprint of the local beam frame with the analytic gradient rather than fwidth(), so a beam at 45° is exactly as thick as one on an axis.

// core: pixel-footprint-convolved gaussian, energy preserving
float s0   = uCore * 0.50 * sqrt(vSpread);   // physical sigma
float sp   = fwy   * 0.42;                  // the pixel's
float se   = sqrt(s0*s0 + sp*sp);
float core = 1.55 * (s0/se) * exp(-0.5*(r*r)/(se*se));
Figure 8. The core's cross-section. Grey is the physical profile; blue is what the shader draws. Take σ down to a twentieth of a pixel and the blue peak drops so the area stays put — a razor line that never aliases and never flickers.

The wings of the beam are deliberately weak. A laser in clean air scatters almost nothing sideways; the soft glow you see around a real beam is lens veiling. So the profile falls to the background within two pixels and carries only the spectral hue outward, and every soft halo comes later from the bloom. The frame is composited in linear HDR (16-bit float), bright-passed, bloomed, given an anamorphic streak, and tonemapped with ACES at the very end — so an over-driven core saturates to white while its wings keep their colour, which is what makes a fan look volumetric instead of like coloured strokes.

10. Why bother

Because it changes how the game plays, not just how it looks. A tracer that obeys Fresnel means the angle you approach a crystal at decides whether it is a window or a mirror. Dispersion means a shot through glass spreads into a fan that can graze three pilots for a little each. Mirrored walls mean your own beam can come back for you. None of that is scripted; it falls out of four short physical rules and a stack. And because the same rules run identically on every peer, a fan that clips somebody on your screen clips them on theirs. How that turns into a game, and how the peers stay in agreement.

Fire one yourself

How it worksCritical MassGames like itMultiplayerSource