Building a Star Raiders Tribute in a Single HTML File

Atari Star Raiders game cartridge cover image

I need to tell you about Star Raiders.

Not because it’s historically important (it is). Not because it was technically groundbreaking (it was). But because this game, more than any other, defined what a computer was for in my teenage mind. It wasn’t a calculator. It wasn’t a word processor. It was a cockpit.

The Game That Ate My Teens

Star Raiders shipped in 1979 on an 8KB ROM cartridge for the Atari 800. Eight kilobytes. That’s less than the average favicon. Less than most icon files. Less than the JSON payload your average web app sends to check if a user is logged in.

And yet somehow, crammed into that impossibly small space, Douglas Neubauer managed to fit: a 3D space combat engine, a 128-sector galactic chart, hyperwarp travel, shield and energy management, six damageable ship systems, starbase docking, long-range sensors, an attack computer, four difficulty levels, and a ranking system that went from “Garbage Scow Captain” to “Star Commander.”

I played it obsessively. I played it when I should have been doing homework. I played it when I should have been sleeping. I played it in the dark with the volume down so nobody would hear the distinctive pew-pew of photon torpedoes. I knew every sound, every flicker of the display, every desperate moment when the “STARBASE UNDER ATTACK” warning started flashing and you had to decide whether to break off your current fight to save it.

The old 8-bit games had something that modern games often lack: they were immediately playable. No tutorials. No cutscenes. No 40GB day-one patches. You turned on the machine, pressed Start, and you were in space. The constraints of the hardware forced a clarity of design that made these games timeless.

The Source Code That Changed Everything

For decades, Star Raiders was a black box. People knew it was extraordinary — the game that proved home computers could do what arcades did — but nobody could see how.

Then Lorenz Wiest did something remarkable. He took the raw 8KB ROM binary and, without any access to original source materials, reverse-engineered the entire game into fully annotated 6502 assembly. Every subroutine documented. Every lookup table explained. Every clever optimisation laid bare.

When scans of Neubauer’s original (sparsely commented) source code surfaced a month later, Wiest’s reverse-engineering needed only trivial corrections. He’d essentially reconstructed the designer’s intent from the raw machine code. That’s like translating a novel back into the author’s native language by reading only the typesetting instructions.

The documented source became the foundation for our tribute. Not a line-for-line port — you can’t meaningfully port 6502 assembly to JavaScript — but a guide to understanding what the game actually does under the hood. When you know that the galaxy is a 16×8 grid, that energy drains use fractional accumulators, that enemy ships follow indexed flight patterns with velocity milestones, you can rebuild the spirit of the thing in any language.

2,288 Lines, One File, Zero Dependencies

Our tribute lives in a single star-raiders.html file. No build system. No npm. No webpack. No React. Just a <canvas> element, a <script> block, and 78KB of self-contained JavaScript.

The entire architecture fits in your head:

<!DOCTYPE html>
<html lang="en">
<head>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #000; display: flex; justify-content: center;
       align-items: center; height: 100vh; overflow: hidden; }
canvas { image-rendering: pixelated; image-rendering: crisp-edges; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
// ... 2,270 lines of game ...
</script>
</body>
</html>

Three lines of CSS. One HTML element. Everything else is code. There’s something deeply satisfying about that.

screenshot from Atari Star Raiders, mid-combat

From 6502 to Canvas: The 3D Engine

The original Star Raiders used a left-handed 3D coordinate system with 17-bit signed integers — three bytes per axis (sign + 16-bit mantissa), giving a range of −65,536 to +65,535. It managed 49 simultaneous space objects using Atari’s Player/Missile hardware sprites and a custom playfield renderer.

Our version uses the same conceptual model, but we have the luxury of floating-point maths and a 2D canvas:

const FOCAL = 200;

function worldToView(wx, wy, wz, aft) {
    let dx = wx - player.px;
    let dy = wy - player.py;
    let dz = wz - player.pz;

    let yaw = player.headYaw;
    let pitch = player.headPitch;
    if (aft) { yaw += Math.PI; pitch = -pitch; }

    const cy = Math.cos(yaw), sy = Math.sin(yaw);
    const cp = Math.cos(pitch), sp = Math.sin(pitch);

    // Yaw rotation (around Y), then pitch rotation (around X)
    let vx = dx * cy - dz * sy;
    let vz = dx * sy + dz * cy;
    let vy = dy * cp - vz * sp;
    vz = dy * sp + vz * cp;

    return { x: vx, y: vy, z: vz };
}

function project(x, y, z) {
    if (z <= 10) return null;
    const scale = FOCAL / z;
    return { sx: W/2 + x * scale, sy: VIEW_H/2 - y * scale, scale };
}

The original used a MAPTO80 lookup table to convert 3D coordinates to screen positions because multiplication was expensive on a 1.79 MHz 6502. We just… divide. The luxury of modern hardware is almost embarrassing.

The aft view is a neat trick — rather than maintaining a separate rear-facing camera, we simply add π to the yaw and negate the pitch. Same projection pipeline, different heading. The original game did something conceptually similar, though the implementation was more intricate due to the fixed-point arithmetic.

Energy: Fractional Accumulators vs. Floating Point

One of the cleverest tricks in the original source is the energy drain system. On a machine with no floating-point unit, Neubauer used a fractional accumulator called ENERGYCNT. It counted 256 “subunits” per displayed energy unit. Shields drained 0x08 subunits per frame. The attack computer drained 0x02. When the counter overflowed past 255, one visible energy unit was deducted.

This is essentially fixed-point arithmetic, and it’s beautiful in its simplicity. No division. No rounding. Just an 8-bit counter that naturally rolls over.

Our version has no such constraints, so we just use plain multiplication against delta time:

// Energy drain
if (player.shields && player.systems.shields > 0) {
    player.energy -= 10 * dt;
    player.energyUsed += 10 * dt;
}
player.energy -= player.speed * 3 * dt;
player.energyUsed += player.speed * 3 * dt;

It’s more readable. It’s easier to tune. But I’d be lying if I said there wasn’t a part of me that admired the elegance of the original approach more.

Sound: No Samples, No Files, Pure Synthesis

The original Atari game used the POKEY audio chip — four channels of square/noise waveforms with frequency and volume registers. Every sound was generated in real-time by writing to hardware registers.

We do the same thing with the Web Audio API. Every sound in the game is synthesised on the fly. No audio files are loaded, ever:

if (type === 'fire') {
    let buf = ac.createBuffer(1, Math.floor(ac.sampleRate * 0.15), ac.sampleRate);
    let d = buf.getChannelData(0);
    for (let i = 0; i < d.length; i++)
        d[i] = (Math.random() * 2 - 1) * (1 - i/d.length);
    // ... bandpass filter at 900Hz, Q of 2
}

That torpedo sound? It’s a 150ms burst of white noise with a linear amplitude decay, pushed through a bandpass filter. The explosion? A sawtooth oscillator sweeping from 200Hz down to 40Hz, layered with a noise burst. The hyperwarp? White noise through a bandpass filter that sweeps from 300Hz to 4000Hz over 3.5 seconds, then back down.

The player-hit sound is particularly fun — a square wave at 100Hz with a 20Hz LFO modulating the frequency, giving it that wobbly “your ship just got smacked” feel:

let osc = ac.createOscillator(); osc.type = 'square';
osc.frequency.value = 100;
let lfo = ac.createOscillator(); lfo.frequency.value = 20;
let lfoGain = ac.createGain(); lfoGain.gain.value = 50;
lfo.connect(lfoGain); lfoGain.connect(osc.frequency);

It’s the Web Audio equivalent of poking POKEY registers. Different decade, same idea: oscillators and envelopes are all you need.

screenshot from Atari Star Raiders, galactic chart

The Galaxy: A Grid of Possibilities

The original used a 16×8 grid — 128 sectors tracked in Player/Missile pixel coordinates relative to the galactic chart borders. Our galaxy works the same way:

function generateGalaxy() {
    galaxy = { sectors: [], playerSector: { x: 0, y: 0 }, totalEnemies: 0 };

    for (let i = 0; i < 16 * 8; i++) {
        galaxy.sectors.push({ enemies: 0, starbase: false });
    }

    // Place enemy groups
    let placed = 0;
    while (placed < diff.groups) {
        const x = randInt(0, 15), y = randInt(0, 7);
        const idx = y * 16 + x;
        if (galaxy.sectors[idx].enemies === 0) {
            const count = randInt(1, 6);
            galaxy.sectors[idx].enemies = count;
            placed += count;
            galaxy.totalEnemies += count;
        }
    }
}

Starbases are spread across the map using a simple quadrant system — each base is placed in a different quarter of the galaxy to ensure coverage. The player starts in a guaranteed-empty sector. Sector data persists between warps, so clearing a sector actually clears it. These are all behaviours documented in the original source.

AI: Approach, Strafe, Dodge

In Neubauer’s code, Zylon ships follow indexed flight patterns with velocity milestones. A “controlled toggling” mechanism alternates AI updates between two ships per frame to save CPU cycles. Ships hunt starbases using a 9-directional distance calculation table.

Our AI is simpler but captures the same behaviour:

if (e.aiState === 'approach' && dist < 300) {
    e.aiState = 'strafe';
    e.vx = randRange(-1, 1) * et.speed;
    e.vy = randRange(-1, 1) * et.speed;
    e.vz = randRange(-0.5, 0.5) * et.speed;
} else if (e.aiState === 'strafe') {
    e.aiState = dist > 600 ? 'approach' : 'strafe';
}

Enemies approach when far away, then switch to strafing runs when they get close. At higher difficulties they’ll dodge your torpedoes:

if (Math.random() < diff.aiDodge * dt) {
    for (const t of sector.torpedoes) {
        if (dist3D(t, e) < 150) {
            e.vx += randRange(-200, 200);
            e.vy += randRange(-200, 200);
            break;
        }
    }
}

Commander difficulty gives enemies a 50% dodge probability per second. You will miss a lot.

The Scanline Trick

One small detail I’m fond of: the scanline overlay.

ctx.fillStyle = 'rgba(0,0,0,0.12)';
for (let y = 0; y < VIEW_H; y += 2) {
    ctx.fillRect(0, y, W, 1);
}

Every other line gets a subtle darkening pass. It adds absolutely nothing to the gameplay. But it makes the whole thing feel like you’re looking at a CRT monitor, and that matters more than you’d think for a nostalgia project.

Difficulty: From Novice to “Survival Unlikely”

The original had four difficulty levels, and so do we. The tuning parameters are all exposed in a clean data structure:

const DIFFICULTY = [
    { name: 'NOVICE',    groups: 8,  fireInterval: 4.0, accuracy: 0.30,
      sbTimer: 60, aiDodge: 0.0 },
    { name: 'PILOT',     groups: 12, fireInterval: 3.0, accuracy: 0.45,
      sbTimer: 50, aiDodge: 0.15 },
    { name: 'WARRIOR',   groups: 20, fireInterval: 2.0, accuracy: 0.60,
      sbTimer: 40, aiDodge: 0.30 },
    { name: 'COMMANDER', groups: 28, fireInterval: 1.2, accuracy: 0.75,
      sbTimer: 30, aiDodge: 0.50 },
];

Commander mode is properly hostile. The enemies fire frequently, aim accurately, dodge your shots half the time, and starbases fall in 30 seconds if you don’t clear the sector. The description reads “survival unlikely” and it means it.

Damage: Everything Breaks

The original tracked hit severity via HITBADNESS — three states from “no hit” to “starship destroyed.” We use a continuous damage model where each of six ship systems has a 0–1 health value:

const sysKeys = SYSTEM_KEYS.filter(k => player.systems[k] > 0);
if (sysKeys.length > 0) {
    const key = pick(sysKeys);
    player.systems[key] = Math.max(0, player.systems[key] - amount);
}

When you take a hit, a random functioning system degrades. Shields reduce incoming damage by up to 70%. Engine damage reduces your turn rate and maximum speed. Destroyed photon torpedoes means you can’t fire. A dead long-range scanner shows static instead of sector data. A dead subspace radio means no more starbase distress warnings.

Everything breaks, and everything matters. That’s pure Star Raiders.

The Mothership

One addition that isn’t in the original: the mothership. It spawns periodically during combat, a large saucer-shaped vessel worth 1,000 points. It has a pulsing engine glow, a command bridge dome, and it leaves if you don’t kill it fast enough.

if (!mothershipActive && normalEnemies.length > 0) {
    mothershipTimer -= dt;
    if (mothershipTimer <= 0) {
        sector.enemies.push({
            type: 'mothership', hp: et.hp, maxHp: et.hp,
            // ...
            fleeTimer: randRange(8, 14),  // seconds before it escapes
        });
        alertMessage = 'MOTHERSHIP DETECTED';
        playSound('alert');
    }
}

The “MOTHERSHIP DETECTED” alert, followed by the frantic search for a large purple blip while Zylon warriors are still shooting at you, is exactly the kind of emergent chaos that makes space combat games fun.

Waves: The War Never Ends

Clear all enemies and you don’t get a victory screen — you get a new wave. More enemies, spread across a fresh galaxy, with an extended stardate limit. The game auto-hyperwarps you to the nearest enemy sector because there’s no time for a leisurely chart browse when Wave 3 has 32 enemy groups.

What 8KB Taught Me

Building this tribute gave me a renewed appreciation for what Neubauer achieved in 1979. Our version is 78KB — nearly ten times the size of the original — and it does less. No guided torpedoes (the Tracking Computer in the original could home photons onto targets). No player/missile graphics hardware sprites. No Display List Interrupts changing colours mid-scanline. No wrap-around counters that randomise star brightness without conditional branching.

The original Star Raiders is a masterclass in doing more with less. Every byte in that 8KB ROM earned its place. The structure-of-arrays memory layout for space objects. The packed 8-bit velocity format with sign in bit 7. The MAPTOBCD99 lookup table that converts binary to BCD without division. The fractional energy counter. Each one is an elegant solution to a problem that simply doesn’t exist on modern hardware.

We have Math.random() and floating-point division and gigabytes of RAM and WebGL and 60 FPS rendering pipelines. Neubauer had a 1.79 MHz CPU, 8KB of ROM, and the determination to make you feel like you were commanding a starship.

He succeeded. Forty-seven years later, the game still holds up. Our tribute exists because the original is worth tributing.

Now if you’ll excuse me, there’s a starbase under attack in sector 12,3 and I’ve only got 2,000 energy left. Some things never change.

Want to join me in our quest to save the galaxy? Head on over to the game, I’ll see you there.