← blog

VOIDROIDS — an arcade shooter with no art department

VOIDROIDS is an arena roguelite written in Rust on top of macroquad — the loop everyone already knows from the 1979 arcade original, with a level-up system, unlockable ships, boss fights, and an endless mode bolted on top. What's worth writing up isn't the roguelite part, though — it's that none of it needed a single art or audio asset. Every rock, every explosion, every siren is produced by a formula, either once at load time or fresh every frame. This post walks through the three places where that actually shows: the shapes, the sound, and the way the game keeps getting harder forever without anyone having designed a "level 400."

The 1979 loop, roguelite'd

The core loop is the one everyone already knows: a ship on a wrap-around screen, rocks that split into smaller rocks when you shoot them, momentum-based flight with no friction. That's where the resemblance to the original is deliberate, and where it stops — everything layered on top is a roguelite the 1979 cabinet never had. Waves of varied enemy types, card picks on level-up, unlockable ships, boss fights every few minutes, and an endless mode that just keeps extrapolating past the scripted content. Think of it less as a clone and more as "what if the 1979 loop was the movement system of a modern run-based shooter."

None of that extra layer needed art or audio assets, and it isn't a stylistic choice made after the fact — it's a constraint the game imports along with the genre it's imitating. On the vector hardware VOIDROIDS is nodding to, there were no sprite sheets to load; a shape was whatever the electron beam was told to trace. The repo ships zero images and zero audio files, true down to the last pixel. Everything you see and hear is generated, either once at startup or on the fly, per object, per frame.

Wireframes instead of sprites

Every object in the game — ship, asteroid, boss, bullet — is stored as a list of unit-scale points, Vec<(f32, f32)>, centered on the origin with radius roughly 1. Drawing an object is a single transform:

rustpub fn shape_points(shape: &[(f32, f32)], pos: Vec2, angle: f32, scale: f32) -> Vec<Vec2> {
    let (s, c) = angle.sin_cos();
    shape.iter()
        .map(|&(x, y)| pos + vec2(x * c - y * s, x * s + y * c) * scale)
        .collect()
}

Rotate by angle, scale by scale, translate by pos. The outline is then rendered three times at decreasing width and increasing transparency (glow_polyline) to fake the phosphor bloom of a vector monitor. There's no texture anywhere in this pipeline — a "sprite" is just a handful of floats, which is what makes generating new ones at runtime cheap enough to do per-enemy instead of once at design time.

Step 1 — asteroids that are never quite the same twice

The actual rock shape comes from one small function:

rustfn asteroid_shape(points: usize) -> Vec<(f32, f32)> {
    (0..points)
        .map(|i| {
            let a = i as f32 / points as f32 * TAU;
            let r = gen_range(0.72_f32, 1.15);
            (a.cos() * r, a.sin() * r)
        })
        .collect()
}

It walks evenly around a circle in points steps and, at each step, picks a random radius between 0.72 and 1.15 instead of the fixed radius 1 a real circle would use. That's the entire trick: a circle with jittered radii stops looking like a circle and starts looking like a rock, because real rocks are exactly that — a roughly convex blob with an irregular boundary. No two calls produce the same asteroid, and no vertex data is stored anywhere; the shape is regenerated once when the enemy spawns and then just gets rotated and translated every frame after that.

The vertex count is tied to size: large asteroids get 11 points, mid ones 9, small ones 7. Fewer points means the radius jitter has fewer chances to average out, so small rocks read as visibly craggier than big ones — a side effect of the math, not a separate "roughness" parameter anyone had to tune. When a large or medium asteroid dies it splits into two smaller ones (AstL → AstM → AstS, each a fresh call to asteroid_shape with its own random seed), which is itself just a lookup table (splits_into) rather than special-cased spawn code.

unit circle, 11 pointsradius jittered 0.72–1.15 → rocksplits into two, fewer points, fresh seed
⏸ pause
One asteroid_shape() call: a perfect circle's radii get jittered into a rock, which — on death — is replaced by two fresh, independently jittered rocks at lower vertex count. Click to pause.

Step 2 — sound effects synthesized, not recorded

The audio side does the same thing one octave down: instead of storing waveforms, the game stores recipes for waveforms and renders them to a WAV buffer once at startup. audio.rs builds a 44-byte RIFF/WAVE header by hand, appends 16-bit PCM samples, and hands the bytes straight to macroquad's load_sound_from_bytes — there is no .wav file on disk at any point, generated or otherwise.

Two primitives cover almost every sound in the game. The first is a frequency sweep:

rustfn sweep(f0: f32, f1: f32, dur: f32, vol: f32, square: bool, decay: f32) -> Vec<f32> {
    let n = (dur * RATE as f32) as usize;
    let mut phase = 0.0_f32;
    (0..n).map(|i| {
        let t = i as f32 / n as f32;
        let f = f0 + (f1 - f0) * t;
        phase += f / RATE as f32;
        let mut s = (phase * TAU).sin();
        if square { s = s.signum() * 0.6; }
        s * vol * (1.0 - t).powf(decay)
    }).collect()
}

It linearly interpolates frequency from f0 to f1 over the sound's duration, integrates that into a phase, and takes the sine of it — a laser "pew" is just sweep(880.0, 420.0, 0.08, ..), a falling pitch over 80 ms. Flipping square clips the sine into a square wave via signum(), which is the cheapest possible way to get the harsher, more retro-arcade timbre used for shots and pickups. The (1 − t).powf(decay) term is the envelope: it fades the sound out, and raising decay above 1 front-loads that fade so the sound reads as a short "pop" instead of a lingering tone.

The second primitive is noise:

rustfn noise_burst(dur: f32, vol: f32, decay: f32) -> Vec<f32> {
    let n = (dur * RATE as f32) as usize;
    let mut seed = 0x2545_f491_u32;
    (0..n).map(|i| {
        let t = i as f32 / n as f32;
        seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
        let r = (seed >> 8) as f32 / 8_388_608.0 - 1.0;
        r * vol * (1.0 - t).powf(decay)
    }).collect()
}

seed = seed * 1_664_525 + 1_013_904_223 is a linear congruential generator — the same constants Numerical Recipes made famous — reduced to a single multiply-add per sample. It's a low-quality random number generator by cryptographic standards, but nothing here needs cryptographic quality; it needs to be fast and to sound like static, and a bad PRNG is indistinguishable from a good one once you're only listening to it.

Everything else is composition. mix() sums two tracks sample-for-sample, so a big explosion is a noise burst layered under a slow downward sweep — the noise gives it the crack, the sweep gives it the low rumble. concat() glues tracks end to end, so the level-up jingle is three square-wave sweeps at rising pitch played back to back, and the boss warning siren is four alternating tones. Eight sound effects, two synthesis primitives, two combinators — that's the entire audio budget of the game, and it fits in 190 lines.

sweep() — pitch chirp+noise_burst() — LCG static=mix() — explosion
⏸ pause
Redrawn from the actual sweep() and noise_burst() formulas (frequencies rescaled for legibility, not literal Hz). mix() is nothing more than summing the two sample arrays. Click to pause.

Step 3 — a difficulty curve with no level design in it

"Endless mode" sounds like it should require hand-authored late-game content — new enemy types, scripted spikes, some kind of "wave 50" special case. VOIDROIDS doesn't have any of that. The run is split into 150-second stages, each ending in a boss fight, and everything that makes the game harder is one of two continuous formulas evaluated every frame.

interval = clamp(2.6 − 0.15·(stage − 1) − stage_t·0.006, 0.55, 3.5) · diff.spawn

stage_t is seconds elapsed in the current stage, so enemies spawn faster the longer a stage runs, and faster still in later stages — clamped so it never drops below one every 0.55 s. Enemy toughness follows the same shape:

hp_mult = (1 + 0.45·(stage − 1) + 0.002·time) · diff.hp

time here is total run time in seconds, not stage time — it never resets, so a fight in stage 6 is tougher than the same fight would have been in stage 1 purely because more time has passed, on top of the per-stage jump.

Spawn rate faster per stage, faster within a stage clamped at 0.55 s so the screen never fully floods
Enemy HP rises with stage and with total elapsed time never resets — old bosses would be tougher fought late
Boss HP extra ×1.6 every 3 stages (cycle = (stage−1)/3) a sawtooth on top of the linear climb, timed to land on boss fights

The interesting part is what happens after the third and final scripted stage. Beating the stage-3 boss offers a choice: stop and bank the win, or continue. Choosing to continue does exactly three things — sets endless = true, increments stage, and resets the stage timer to 150 seconds — and then falls straight back into the same tick() function that ran stage 1. There is no branch anywhere that says "this is endless mode now, behave differently." The spawn-interval and HP-multiplier formulas above were never bounded to three stages in the first place; they're just linear (plus a small quadratic-ish nudge from time) in stage, so stage 4, 40, and 400 all fall out of the same two-line expressions that governed stage 1. Endless mode isn't a feature that was built — it's the absence of a stop condition on formulas that were already open-ended.

stage 1–3 · scripted contentstage 4+ · endless — same two formulas, no new codespawn interval (s) — lower = fasterenemy HP multiplier (×) — higher = tougher
⏸ pause
Both curves are the literal interval and hp_mult formulas, sampled across 9 stages. Dots on the axis mark boss fights; the larger ones are every third stage, where the boss multiplier steps up. Nothing changes shape at the stage-3 divider. Click to pause.

Why it holds together

None of the individual tricks here are novel — jittered-radius polygons and additive synthesis are both older than the arcade cabinets this game is imitating. What's worth taking away is how far "a shape is a list of floats, a sound is a list of floats" gets you once you commit to it everywhere: the same handful of primitives produce all six enemy types, all eight sound effects, and — via two formulas nobody had to extend — an arbitrarily long endless mode. The whole game, code included, is a few thousand lines and one binary with nothing to unpack next to it.