Black Hole — Building a Gravitational Lens Visualizer and Survival Game
Visualizing a black hole's gravitational lensing effect on Canvas 2D and building a survival game around its event horizon — the development story
Starting Point — What Does It Mean to "Draw" a Black Hole?
A black hole is an object so dense that not even light can escape. Since it emits nothing, its presence can only be inferred from the way it bends the light of surrounding objects — gravitational lensing. Like Interstellar's Gargantua, like the actual image of M87* captured by the Event Horizon Telescope.
The question driving this project was: how far can Canvas 2D approximate that effect? No ray tracing, no shaders — just radial-gradient and arc, assembled into something that reads as a black hole.
Gravitational Lens Visualization
Real gravitational lensing creates Einstein rings — light from behind the black hole bent into a circular halo. Pixel-level ray tracing is not feasible in Canvas 2D, so the effect is built from layered gradients instead.
// Photon sphere — the boundary where light orbits in circles
const photonRing = ctx.createRadialGradient(cx, cy, r * 0.9, cx, cy, r * 1.3);
photonRing.addColorStop(0, 'rgba(255, 200, 80, 0.0)');
photonRing.addColorStop(0.5, 'rgba(255, 200, 80, 0.55)');
photonRing.addColorStop(1, 'rgba(255, 200, 80, 0.0)');
// Event horizon — absolute darkness
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(cx, cy, r * 0.85, 0, Math.PI * 2);
ctx.fill();
Layers stack outward: the dark central disk, the bright photon ring at its boundary, and the accretion disk extending beyond.
Accretion Disk
The superheated gas disk orbiting the black hole glows blue-white near the center (millions of degrees) and fades to red-orange at the outer edge — a color temperature gradient.
// Accretion disk rendered as a rotating ellipse
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(diskAngle); // incremented each frame
ctx.scale(1, 0.3); // flatten into an ellipse to suggest inclination
const diskGrad = ctx.createRadialGradient(0, 0, r * 1.0, 0, 0, r * 2.8);
diskGrad.addColorStop(0, 'rgba(255, 255, 220, 0.7)');
diskGrad.addColorStop(0.3, 'rgba(255, 140, 40, 0.5)');
diskGrad.addColorStop(0.7, 'rgba(180, 50, 20, 0.25)');
diskGrad.addColorStop(1, 'rgba(100, 20, 10, 0.0)');
ctx.fillStyle = diskGrad;
ctx.beginPath();
ctx.arc(0, 0, r * 2.8, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.scale(1, 0.3) squashes the circle into an ellipse that reads as a tilted disk. Accurate 3D inclination would require matrix transforms, but this approximation is visually convincing enough.
Survival Game Mechanics
Visualization alone offers no interactivity. The game adds the experience of fighting a black hole's gravity in real time.
The player's spacecraft is controlled via arrow keys or touch. Each frame, the black hole pulls the ship with inverse-square gravity.
const dist = Math.hypot(player.x - bh.x, player.y - bh.y);
const gravForce = (BH_GRAVITY * bh.mass) / (dist * dist);
const angle = Math.atan2(bh.y - player.y, bh.x - player.x);
player.vx += Math.cos(angle) * gravForce;
player.vy += Math.sin(angle) * gravForce;
// Inside the event horizon → game over
if (dist < bh.eventHorizonR) {
st.phase = 'dead';
}
As the ship gets closer, gravity scales as 1/r² — naturally creating a zone close to the black hole where even maximum thrust cannot prevent capture.
Best Survival Time: useHighScore + bh_best
Survival is measured in seconds and persisted via the useHighScore hook under the bh_best key.
const { highScoreRef, update } = useHighScore('bh_best');
// On game over
const survived = (Date.now() - startTimeRef.current) / 1000;
update(survived); // updates only if this is a new record
The previous best time is shown at game start, and a celebratory effect plays when it is beaten.
Preventing Stale Closures with localeRef
The black hole simulation displays captions inside the RAF loop in both Korean and English. The problem is that a RAF closure captures the initial value of locale and never sees updates — a stale closure.
// ❌ Wrong: the RAF closure captures locale at creation time
const loop = () => {
drawCaption(ctx, locale); // will not reflect locale changes
};
// ✅ Correct: always read the latest value through a ref
const localeRef = useRef(locale);
localeRef.current = locale; // updated on every render
const loop = () => {
drawCaption(ctx, localeRef.current); // always up to date
};
Assigning localeRef.current = locale directly in the render body (not inside a useEffect) works because writing to a ref does not trigger a re-render.
Stabilizing CanvasSpaceBackground Props
BlackholeContainer passes a black hole configuration array to CanvasSpaceBackground as a prop. Creating a new array object on every render causes the background component to remount unnecessarily.
// ❌ New array on every render → background resets
<CanvasSpaceBackground blackhole={[{ xPct: 6, yPct: 15 }, { xPct: 90, yPct: 80 }]} />
// ✅ Module-level constant — reference never changes
const BLACKHOLE_CONFIG = [
{ xPct: 6, yPct: 15 },
{ xPct: 90, yPct: 80 },
] as const;
<CanvasSpaceBackground blackhole={BLACKHOLE_CONFIG} />
This pattern applies to every page that uses CanvasSpaceBackground.
Canvas Performance — Color String Caching
The star tail effect draws gradient trails behind each star as it orbits or falls toward the black hole. Each trail segment requires an hsla(...) color string with a unique hue and alpha value — and with dozens of stars each rendering 20+ trail segments per frame, the string allocation count adds up quickly.
Hue values are quantized to integer degrees and alpha to two decimal places, then cached in a Map. Since the hue spectrum is 0–360 and alpha steps are limited, the cache stays small while eliminating the vast majority of repeated string constructions.
const hslaCache = new Map<string, string>();
function getCachedHsla(h: number, s: number, l: number, a: number): string {
const key = `${Math.round(h)},${(a * 100) | 0}`;
let c = hslaCache.get(key);
if (!c) {
c = `hsla(${Math.round(h)}, ${s}%, ${l}%, ${a.toFixed(2)})`;
hslaCache.set(key, c);
}
return c;
}
The accretion disk and gravitational lens effects are already drawn with reusable radialGradient objects, so the star trails were the remaining allocation hotspot. With caching in place, GC pauses during the survival game are noticeably reduced.
Closing Thoughts
Making a black hole look visually convincing turned out to be harder than making it physically accurate. The equations are unambiguous — but what a person perceives as "a black hole" lives outside those equations.
The color temperature of the accretion disk, the brightness of the photon ring, the ratio between ring and event horizon — after dozens of iterations, there was a moment where it finally looked right. That moment was the most satisfying part of this project.