Complete Technical Narrative: Awe-Some Aurora App
Written as an explanation for someone with programming knowledge to fully understand and reproduce the software
Introduction
Let me walk you through exactly how this aurora simulation was built, from the ground up. I’ll explain every system, every calculation, and every design decision so you could recreate this entirely from scratch.
The application is a single HTML file, approximately seventeen hundred lines of code, containing HTML structure, embedded CSS, and vanilla JavaScript. There are no frameworks, no build tools, no external libraries whatsoever. It runs entirely in the browser using the HTML5 Canvas API.
The HTML Foundation
The document begins with a standard HTML5 doctype and opens with a container div that uses CSS Flexbox to create a two-panel layout. The left panel is subdivided vertically into two halves—Norway on top, Australia on the bottom. The right panel contains the Sun and Earth visualization.
<div class="game-container">
<div class="panel" id="observer-panel">
<div class="observer-half" id="norway-section">
<canvas id="norway-canvas"></canvas>
<!-- Labels and UI overlays -->
</div>
<div class="observer-half" id="australia-section">
<canvas id="australia-canvas"></canvas>
<!-- Labels and UI overlays -->
</div>
</div>
<div class="panel" id="sun-panel">
<canvas id="sun-canvas"></canvas>
<!-- Instructions, buttons, overlays -->
</div>
</div>
Each section contains a canvas element that fills its parent container completely. On top of each canvas, we layer absolutely-positioned HTML elements for the user interface—labels showing location names, awe counters, speech bubbles for observer reactions, a stats display, the Carrington Event button, and instructional text.
CSS Styling
The CSS is embedded in a style block within the document head. The body uses a very dark purple-blue background color, hex code zero-five-zero-five-one-zero, which provides the deep night sky feeling.
The game container is a flex container set to one hundred viewport width and one hundred viewport height, ensuring the game fills the entire browser window. The two main panels each have flex: 1, making them equal width.
The observer panel uses flex-direction: column to stack Norway above Australia, with each half also using flex: 1 for equal vertical distribution. A thin colored border separates them—blue-tinted for Norway, warm-tinted for Australia.
For the Carrington Event button, I used a CSS keyframe animation called carringtonPulse that scales the button from one to one-point-zero-five over one and a half seconds, creating a subtle pulsing effect that draws attention without being distracting.
@keyframes carringtonPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
The button itself uses a linear gradient background going from orange-red to bright orange, with a matching border and a box-shadow glow effect.
Canvas Setup and Resize Handling
At the start of the JavaScript, we grab references to all three canvas elements and their 2D rendering contexts:
const norwayCanvas = document.getElementById('norway-canvas');
const australiaCanvas = document.getElementById('australia-canvas');
const sunCanvas = document.getElementById('sun-canvas');
const norwayCtx = norwayCanvas.getContext('2d');
const australiaCtx = australiaCanvas.getContext('2d');
const sunCtx = sunCanvas.getContext('2d');
A resize function sets each canvas’s internal pixel dimensions to match its container’s client dimensions. This is called immediately on load and again whenever the window resizes:
function resize() {
norwayCanvas.width = norwaySection.clientWidth;
norwayCanvas.height = norwaySection.clientHeight;
// Same for australia and sun canvases
}
resize();
window.addEventListener('resize', resize);
Core State Variables
The game maintains several categories of state. First, timing variables: time is a frame counter that increments every animation frame, roughly sixty times per second. elapsedSeconds tracks real-world seconds for display purposes.
For Earth’s rotation, earthSpin holds the current rotation angle in degrees, ranging from zero to three-sixty. The constant AXIAL_TILT is set to twenty-three point five degrees, matching Earth’s actual axial tilt.
Player interaction uses isDragging as a boolean flag, plus aimX and aimY to store the current cursor position while aiming.
Scoring and feedback uses six variables: norwayAwe and australiaAwe are cumulative point totals. norwayGlow and australiaGlow are floating-point values from zero to one representing the current sky illumination intensity. norwayAweLevel and australiaAweLevel track the observer’s current excitement level, which affects their pose animation.
Five arrays hold active game objects: rays for solar projectiles in flight, norwayAuroras and australiaAuroras for active aurora curtain particles in each hemisphere, pendingAuroras for auroras queued to appear after a delay, and fieldParticles for the glowing energy particles that travel along magnetic field lines.
For performance protection, I defined three constants: MAX_AURORAS_PER_HEMISPHERE is twenty-five, MAX_FIELD_PARTICLES is thirty, and MAX_PENDING_AURORAS is ten. These prevent the simulation from creating too many objects when the player clicks rapidly.
The Carrington Event system uses six state variables: carringtonAvailable tracks whether the button should be visible, carringtonTriggered indicates whether it’s been activated, carringtonBlastActive controls the expanding wave animation, carringtonBlastProgress is a zero-to-one progress value for that animation, lightsOn is a boolean for whether building lights are illuminated, and lightFlicker is a countdown timer for the flickering effect before blackout.
The Wombat Easter Egg
There’s a wombat object with three properties: x for horizontal position, active as a boolean, and timer as a frame counter. Every frame, the timer increments. When inactive and the timer exceeds three hundred frames—about five seconds—there’s a zero-point-five percent random chance per frame of spawning the wombat at x-position negative fifty, off the left edge of the screen. Once active, the wombat moves right at zero-point-three-five pixels per frame—a slow saunter. When it exits the right edge, it deactivates and the timer resets.
Continent Data Structure
For accurate Earth rendering, I defined six continent shapes as JavaScript objects. Each has a center longitude, a center latitude, and an array of polygon points defined as longitude and latitude offsets from that center.
const continents = {
northAmerica: {
lon: -100, lat: 45,
points: [[-25,-15], [-20,-25], [-5,-30], [10,-25], [15,-15],
[20,5], [15,15], [5,20], [-10,15], [-20,5], [-25,-5]]
},
// Similarly for southAmerica, europe, africa, asia, australia
};
North America is centered at longitude negative one hundred, latitude forty-five. South America at negative sixty, negative fifteen. Europe at fifteen, fifty. Africa at twenty, zero. Asia at one hundred, forty. And Australia at one hundred thirty-five, negative twenty-five.
The point arrays are simplified outlines—not geographically perfect, but recognizable shapes that work at the small scale of the Earth visualization.
Earth Rendering Mathematics
The drawTiltedEarth function handles all Earth rendering. It begins by converting the axial tilt to radians:
const tiltRad = AXIAL_TILT * Math.PI / 180;
Then it saves the canvas context state, translates to Earth’s center position, and rotates by the tilt angle. All subsequent drawing happens in this tilted coordinate system.
First, it draws the ocean as a filled circle with a radial gradient—lighter blue at the upper-left suggesting sunlight, darker blue toward the edges.
Then it loops through each continent. For each one, it calculates the adjusted longitude by adding earthSpin to the continent’s base longitude, then taking modulo three-sixty to keep it in range:
const adjustedLon = (cont.lon + earthSpin) % 360;
This adjusted longitude is converted to radians, offset by ninety degrees because we want longitude zero to face the viewer:
const lonRad = (adjustedLon - 90) * Math.PI / 180;
The visibility is calculated as the cosine of this angle. When the continent faces the viewer, this value is close to one. When it’s on the far side of Earth, it’s negative. We only render continents with visibility greater than negative zero-point-two, allowing them to appear slightly around the edges before disappearing.
The continent’s center X position is calculated using sine of the longitude angle, multiplied by forty-five pixels—the Earth’s radius—and further multiplied by the cosine of the latitude to account for convergence toward the poles. The Y position is simply the latitude scaled appropriately.
The scale factor combines a base of zero-point-four with visibility times zero-point-three, creating a perspective effect where continents appear smaller as they rotate toward the edges.
Finally, we loop through each point in the continent’s polygon, apply the offsets and scale, and draw the filled shape in a forest green color.
After all continents, I draw location markers for Norway and Tasmania. Norway is at longitude ten, so I calculate its visibility the same way, and if visible, draw a small cyan circle at the appropriate position. Tasmania is at longitude one thirty-five, rendered as a small orange circle.
Observer Scene Rendering
The drawObserverScene function renders either the Norway or Australia view. It takes the canvas context, canvas reference, a boolean indicating which hemisphere, the aurora array for that hemisphere, the current glow intensity, and the current awe level.
It begins with a sky gradient from top to bottom. The top color is dynamically calculated based on glow intensity—when auroras are active, it adds green and blue tints:
const glowR = Math.floor(5 + glowIntensity * 25);
const glowG = Math.floor(5 + glowIntensity * 50);
const glowB = Math.floor(15 + glowIntensity * 25);
sky.addColorStop(0, `rgb(${glowR}, ${glowG}, ${glowB})`);
Next come the stars. Fifty stars are drawn at pseudo-random positions calculated from the loop index using prime number multiplication to distribute them evenly. Each star twinkles slowly using a sine function of time plus an offset based on star index:
const twinkle = Math.sin(time * 0.012 + i * 0.7) * 0.2 + 0.6;
The twinkle value modulates the star’s alpha, and is also reduced when glow intensity is high—stars fade when bright auroras are present.
After stars come the auroras themselves, which I’ll detail in the next section.
Finally, the function calls either drawNorwayLandscape or drawAustraliaLandscape depending on the hemisphere flag.
Aurora Rendering System
Each aurora object represents a vertical curtain of light. The rendering calculates the current alpha based on lifecycle phase:
if (a.phase < 0.25) {
a.alpha = (a.phase / 0.25) * a.intensity; // Fade in
} else if (a.phase < 0.75) {
a.alpha = a.intensity; // Full brightness
} else {
a.alpha = ((1 - a.phase) / 0.25) * a.intensity; // Fade out
}
This creates a slow fade-in during the first quarter of the lifecycle, full brightness for the middle half, and fade-out during the final quarter.
The wave effect uses a sine function: Math.sin(a.wavePhase) * a.waveAmp. The shimmer is another sine function modulating brightness. The morph value adjusts the curtain width over time.
The curtain is drawn as a filled path with a linear gradient. For super-saturated auroras, the gradient uses vibrant HSL colors at full saturation:
grad.addColorStop(0, `hsla(${a.hue}, 100%, 70%, 0)`); // Transparent at top
grad.addColorStop(0.15, `hsla(${a.hue}, 100%, 68%, ${alpha * 0.3})`);
grad.addColorStop(0.4, `hsla(${a.hue + 25}, 95%, 62%, ${alpha * 0.7})`);
grad.addColorStop(0.7, `hsla(${a.hue + 50}, 90%, 55%, ${alpha * 0.5})`);
grad.addColorStop(1, `hsla(${a.hue}, 80%, 50%, ${alpha * 0.15})`); // Faint at horizon
Notice the hue shifts by twenty-five degrees and then fifty degrees down the gradient—this creates the characteristic multi-color banding of real auroras.
For regular, non-super-saturated auroras, the colors are more muted, with saturation values around forty to fifty-five percent and lower alpha multipliers.
The curtain shape itself is drawn by iterating from horizon to top along both edges, applying wave offsets at each point:
for (let y = horizonY; y >= curtainTop; y -= 8) {
const heightRatio = (horizonY - y) / height;
const waveOffset = Math.sin(heightRatio * Math.PI * 2 + a.wavePhase) * a.waveAmp * 0.4;
const morphOffset = Math.sin(heightRatio * Math.PI + a.morphPhase) * 5;
ctx.lineTo(x - width/2 + waveOffset + morphOffset, y);
}
The height ratio creates a wave pattern that varies along the height of the curtain. A quadratic curve connects the left and right edges at the top for a rounded appearance.
For super-saturated auroras, an additional glow effect is applied using canvas shadow blur set to thirty pixels with the aurora’s hue.
Landscape Rendering
The Norway landscape consists of layered silhouettes. First, a jagged mountain range drawn as a filled polygon with peaks at various heights—thirty-five to fifty-five percent of canvas height. Small triangular snow caps are added to the highest peaks.
Below that, a flat ground layer fills the bottom twenty-five percent in a very dark color. Triangular pine trees are drawn at pseudo-random positions along the treeline.
The Australia landscape uses rolling hills instead of jagged peaks, drawn with quadratic Bezier curves for smooth contours. Eucalyptus trees have thin trunks with circular foliage clusters at different heights. One tree hosts a koala—drawn as overlapping ellipses in gray with a dark nose.
Building and Light System
Each hemisphere has an array of light definitions. Norway has cabins and one village. Australia has houses, one town, and a farmhouse. Each entry specifies x and y as proportions of canvas dimensions, a type string, and a size multiplier.
The drawLights function first checks if (!lightsOn) return;—after the Carrington Event, this short-circuits and no lights are drawn.
For each light, we calculate pixel positions from the proportional coordinates. If lightFlicker is greater than zero—during the pre-blackout phase—brightness randomly drops to twenty percent about thirty percent of the time, creating an unsettling flicker effect.
For villages and towns, we draw clusters of four to six buildings. Each building is a dark rectangle with height varying based on index. Every other building gets a peaked triangular roof. Windows are drawn as small rectangles with warm yellow fill and a radial gradient glow extending outward.
An ambient glow dome is drawn over the entire settlement using a radial gradient from warm yellow at center to transparent at edges.
For individual cabins and houses, we draw a single larger structure with a peaked roof, chimney for cabins, two square windows with glow effects, and a dark door rectangle at ground level.
Magnetic Field Visualization
The drawMagneticFieldLines function creates the visible field line curves. It operates in tilted coordinate space, translated and rotated to match Earth’s orientation.
Seven field lines are drawn, offset horizontally from negative thirty-six to positive thirty-six pixels in twelve-pixel increments:
for (let i = 0; i < 7; i++) {
const xOffset = (i - 3) * 12;
Each field line is a parametric curve from t equals zero to one, where t represents the path from north pole to south pole:
for (let t = 0; t <= 1; t += 0.05) {
const angle = t * Math.PI; // 0 at north, π at south
const r = 55 + Math.abs(xOffset) * 1.5;
const x = xOffset + Math.sin(angle) * Math.abs(xOffset) * 0.8;
const y = -Math.cos(angle) * r;
The radius increases for lines further from the center, creating the characteristic bulging dipole shape. The x-coordinate includes a sinusoidal term that creates the outward curve in the middle of each line.
Field particles are rendered similarly. Each particle has a progress value from zero to one, moving from hit point toward its target pole. The position is calculated along a curved path:
const angle = goingNorth ? (1 - t) * Math.PI * 0.5 : 0.5 * Math.PI + t * Math.PI * 0.5;
const r = 70 + Math.sin(t * Math.PI) * 30;
Particles going north start at angle ninety degrees and move to zero. Particles going south start at ninety and move to one-eighty. The radius bulges outward at the midpoint of travel.
After calculating local coordinates, we apply the rotation transformation manually:
const screenX = earthX + localX * cosT - localY * sinT;
const screenY = earthY + localX * sinT + localY * cosT;
Particles are drawn as small circles with shadow blur for glow, colored cyan for northbound and orange for southbound.
Sun Rendering
The Sun is drawn at position seventy-eight percent from the left and twenty-eight percent from the top of the sun panel. Its radius pulses slowly between forty and forty-four pixels using a sine function of time.
First, a large radial gradient glow extends to two-and-a-half times the sun radius, fading from bright yellow through orange to transparent.
The sun body itself uses another radial gradient, offset slightly upper-left to suggest three-dimensionality, going from pale yellow through golden yellow to orange at the edges.
The corona consists of twelve radiating lines at evenly spaced angles. The entire corona rotates very slowly—the angle calculation includes time * 0.003. Each line’s outer endpoint pulses in and out using another sine function.
Aim Line and Ray Rendering
When isDragging is true, we calculate the direction vector from sun center to cursor position, normalize it, and draw a dashed line extending one hundred eighty pixels from the sun’s surface. The line uses setLineDash([10, 8]) for the dashed pattern. A small filled circle marks the aim endpoint.
Active rays are drawn with a motion trail. Each ray stores up to six previous positions in its trail array. These are rendered as progressively smaller and more transparent circles behind the main ray position. The main ray uses a radial gradient from white-yellow center to orange edge, with shadow blur for glow.
Hit Detection and Dual-Hemisphere Triggering
In the update function, we check each ray’s distance to Earth center. When that distance falls below one hundred five pixels, the ray has entered the magnetosphere.
We calculate the hit angle in degrees:
const hitAngle = Math.atan2(ray.y - earthY, ray.x - earthX) * 180 / Math.PI;
The optimal angle for the north pole, accounting for axial tilt, is negative ninety plus twenty-three-point-five, which equals negative sixty-six-point-five degrees. For the south pole, it’s ninety plus twenty-three-point-five, or one hundred thirteen-point-five degrees.
We calculate the absolute difference between hit angle and each optimal angle. The minimum of these two differences determines hit quality:
- Less than fifteen degrees: Perfect hit, base intensity one, super-saturated
- Less than thirty-five degrees: Excellent, intensity zero-point-eight, super-saturated
- Less than fifty-five degrees: Good, intensity zero-point-six, normal saturation
- Less than eighty degrees: Decent, intensity zero-point-four
- Otherwise: Weak, intensity zero-point-two-five
For the special south bonus zone—a dashed circle target visible on the display—we check if the hit angle is within twenty degrees of the south optimal and the distance is greater than eighty-five pixels. If so, we heavily favor the south hemisphere and mark it as a bonus hit.
For normal hits, we distribute intensity between hemispheres based on their relative angular distances:
const totalDiff = northDiff + southDiff;
const northRatio = Math.max(0.15, 1 - (northDiff / totalDiff));
const southRatio = Math.max(0.15, 1 - (southDiff / totalDiff));
The minimum of fifteen percent ensures both hemispheres always receive some energy, matching the scientific reality that field lines connect both poles.
We then queue two pending aurora entries—one for each hemisphere—with a time value sixty frames in the future, creating a one-second delay while the field particles visibly travel.
Aurora Creation with Saturation Limits
The createAurora function first checks if the target hemisphere is at capacity. If the aurora array length equals or exceeds MAX_AURORAS_PER_HEMISPHERE, instead of adding new auroras, we boost existing ones—incrementing their life and intensity values.
If there’s room, we calculate how many auroras to create based on intensity and whether it’s super-saturated. We then create that many aurora objects, or as many as we have slots for, whichever is smaller.
Each new aurora gets randomized properties: horizontal position across ninety percent of the canvas width, a height between one hundred and two hundred forty pixels, random phases for all animation parameters, and hue values that either span greens and cyans for normal auroras or include purples, magentas, and yellows for super-saturated ones.
The Carrington Event Sequence
The Carrington button becomes available when elapsedSeconds reaches twenty. At that point, we set carringtonAvailable to true and change the button’s display style from ‘none’ to ‘block’.
When clicked, the triggerCarringtonEvent function sets carringtonTriggered to true, activates the blast animation, hides the button, and starts the light flicker countdown at one hundred twenty frames—two seconds.
During the blast, carringtonBlastProgress increments by zero-point-zero-zero-eight each frame. The blast is rendered as an expanding radial gradient ring centered on the sun, with radius equal to progress times four hundred pixels. The alpha decreases as progress increases, creating a fading effect.
When progress exceeds zero-point-four—the blast reaching Earth—we trigger the blackout. The warning message’s opacity is set to one. lightsOn becomes false permanently. And we call createCarringtonAuroras.
That function creates twenty-five maximum-intensity super-saturated auroras in each hemisphere. It then loops through all existing auroras and boosts them dramatically: life set to three for triple duration, phase reset to zero-point-one, intensity maxed at one, height multiplied by one-point-five, width by one-point-three, and random vibrant hues assigned.
We also add five hundred awe points to each hemisphere and set their glow and awe levels to maximum.
After the event, the sustained effect kicks in. Every two seconds, we add fresh auroras to both hemispheres. Every second, we check for fading auroras and boost their life if it’s dropped below zero-point-three.
Observer Figure Animation
The standing figure is drawn using canvas stroke and fill operations. Two legs angle slightly outward from a central hip point. A vertical body line connects to a head circle that’s offset slightly right and up, suggesting the person is looking at the sky.
The arms change position based on awe level. When it exceeds thirty, both arms angle upward and outward in an expression of amazement. Below that threshold, arms hang relaxed at the sides.
User Input Handling
Mouse events are attached to the sun canvas. On mousedown, we calculate the click position relative to the canvas, accounting for any CSS scaling by multiplying by the ratio of canvas internal dimensions to displayed dimensions. If the click is within eighty pixels of the sun center, we enter dragging mode.
On mousemove during dragging, we update the aim coordinates.
On mouseup, we calculate the direction vector from sun to aim position, normalize it, and if the drag distance exceeded fifteen pixels, we create a new ray object with position at the sun center and velocity of eleven pixels per frame in the aim direction.
Touch events mirror this logic exactly, using e.touches[0] to get the touch position and calling preventDefault to avoid scroll behavior.
Performance Optimizations
Several strategies prevent performance degradation from rapid clicking.
First, the saturation limits cap maximum object counts. When limits are reached, we enhance existing objects rather than creating new ones.
Second, the Carrington sustained effect uses frame-count checks rather than running every frame. Aurora boosting happens only when time % 60 === 0—once per second. New aurora creation happens only when time % 120 === 0—every two seconds.
Third, aurora rendering skips any aurora with alpha below zero-point-zero-one, avoiding unnecessary gradient creation and path drawing for invisible elements.
Fourth, field particles are capped at thirty, and we check the limit before creating any new ones, bailing out entirely if at capacity.
The Animation Update Cycle
Every frame, the update function executes this sequence:
- Increment the time counter
- Every sixty frames, increment elapsed seconds and update the display
- Increment earth spin by five-sixtieths of a degree, completing a full rotation every seventy-two seconds
- Update wombat timer, potentially spawn or move the wombat
- Update field particles—increment progress, remove those that reached one
- Update rays—add current position to trail, move by velocity, check for magnetosphere collision or screen exit
- Process pending auroras whose trigger time has passed
- Update all active auroras—increment phases, decrement life, remove expired ones
- Decay glow values by multiplying by zero-point-nine-nine-two
- Decay awe levels by subtracting zero-point-one-two
- Check Carrington availability at twenty seconds
- Update Carrington blast animation if active
- Maintain sustained Carrington effects if triggered
The game loop function calls update, then calls the three draw functions for Norway, Australia, and the Sun panel, then requests the next animation frame.
How to Reproduce This From Scratch
Start with an HTML file. Create a flex container filling the viewport. Add two panels—left for observers at fifty percent width, right for the sun.
Split the left panel vertically with flex-direction column. Add canvas elements to each section. Add overlay divs for labels and UI elements.
In JavaScript, get all canvas contexts. Write a resize handler. Initialize all state variables.
Build the game loop: update then draw then requestAnimationFrame.
Implement Earth drawing with continent polygons, axial tilt rotation, and spin animation.
Create the aurora particle system with gradient rendering and wave animations.
Add hit detection with angle calculations and dual-hemisphere distribution.
Implement the Carrington Event with blast animation, aurora superburst, and blackout logic.
Add building rendering with window glows.
Wire up mouse and touch events for aim-and-fire interaction.
Add performance limits to all particle creation functions.
Test extensively, especially rapid clicking, to ensure smooth performance.
That’s the complete technical breakdown. Every calculation, every constant, every design decision is documented here. Someone with JavaScript and Canvas experience could rebuild this simulation entirely from this description.
