ZX Spectrum Demoscene Intro: Z80 Build Guide
See how I built a ZX Spectrum demoscene intro in Z80 assembly, from a pixel-scrolling text effect to AY music and a starfield. Explore the code and tricks.

Building a ZX Spectrum Demoscene Intro in Z80 Assembly
In the late 1980s, I spent more time than I care to admit trying to recreate Amiga crack intros on a ZX Spectrum. My setup was simple - Sinclair BASIC, a little Z80 assembly lifted from magazines, and a very optimistic idea of what an 8-bit home computer could handle. What I made never quite landed the way those intros did on my friend's machine.
Decades later, I finally made one: a hand-built demoscene intro that runs on a stock 128K ZX Spectrum. It uses the classic ingredients - scrolling text, an AY-chip soundtrack, bouncing VU meters, and a parallax starfield - and it fits into a compact tape release. The finished code is 10,287 bytes, and the complete tape image is 19,552 bytes.
This wasn't a matter of following a recipe. I had to relearn Z80 assembly, work around the Spectrum's odd screen memory, and keep reshaping effects until they fit the CPU time and memory budget. Below is how I approached it, what I actually used, what I dropped, and why keeping a notebook nearby sometimes saved more time than another hour at the keyboard.
Why Build a Cracktro Now?
An Amiga cracktro was a short audiovisual production shown before a cracked game. Groups used them to put their name up front - graphics, music, and whatever technical flex they could fit into a tiny runtime. The format was easy to recognize: a prominent logo, scrolling messages, a starfield or other visual effect, and a soundtrack.
Over time, the demoscene pushed past the "intro before a game" role. People started making standalone productions that targeted specific machines and pushed on the limits. The spirit was the same, but my goal was narrower: I wanted a single-screen scrolly intro - the kind that stuck with me from when I was younger.
The project started after a conversation with Desertkun, another Spectrum enthusiast working on a Wi‑Fi network card. We were comparing setups when he asked why I didn't write some Z80 assembly. It was casual, but it turned into a useful constraint. Instead of rewriting someone else's intro, I built a small, self-contained one to promote my TNFS site.
I've written Z80 and 68k assembly before, but years of higher-level languages had blurred the details. I wasn't starting from zero - I was starting from a fuzzy mental model of registers, flags, and instructions, plus the annoying confidence that it couldn't be that hard.
Then I tried writing the effects.
The Spectrum is not an Amiga. There's no blitter, no hardware sprites. On a 128K machine you get the AY‑3‑8912 with three sound channels, but the CPU time and memory are still tight. The real problem wasn't "can I copy an Amiga intro feature for feature?" It was deciding what to keep and making each part cheap enough to fit into one frame budget.
My first milestone was boring in the best way: a "Hello, world!" example to confirm the toolchain worked, then a static screen loaded into memory. That gave me something to build on - a stage with a logo above, a scrolling message below, and room for a few other effects. I didn't have a full design yet. A working image on the actual hardware mattered more than locking in every detail upfront.
The Tools and Foundations
I kept the development environment deliberately plain. A Makefile runs Pasmo (for assembling) and a fork of zmakebas for building the BASIC loader files. For testing, I used the FUSE emulator. For final checks, I uploaded tape images to a local TNFS server and tested them on a Spectrum +3.
The goal was repeatability: edit assembly, assemble, package the files, run it in the emulator - no ceremony. If every test required a manual chain of steps, I'd spend more time juggling the build than understanding what the code was doing.
A few resources helped a lot:
- An interactive Z80 instruction reference helped me step through instructions and inspect registers and flags. It used a slightly different syntax from Pasmo, so it wasn't a drop-in editor, but it made the "what did this instruction actually change?" question less mysterious.
- Annotated Spectrum ROM documentation at SkoolKid explained built-in routines. I started by using ROM printing code to get text on screen, then I mostly moved away from it when I needed tighter control over how characters were drawn.
- Break Into Program for Spectrum screen layout and address calculations.
- A notebook and pen for tracking register values, bit masks, and screen addresses. This wasn't a quaint debugging prop - it was often the fastest way to make a confusing state transition obvious.
Start by getting a screen on the machine
A Spectrum screen file is 6,912 bytes: 6,144 bytes of bitmap data plus 768 bytes of colour attributes. The bitmap starts at address 16,384 (hex $4000). Copying a screen image there with LDIR is a straightforward first test: load the source into HL, the destination into DE, the byte count into BC, then run LDIR.
Once I had a static background in place, I could treat the rest of the intro like additions around a stable base. I also experimented with ROM routines for printing text. Even when I eventually replaced them for the scroller, they were useful for learning how the machine handled strings and cursor positions.
To improve the look of ROM-printed text, I swapped the Spectrum's standard font by changing the system variable that tells the ROM where character data lives. The takeaway was practical: reuse a built-in routine when it meets your needs, but don't build your whole design around it if you'll want more control later.
The screen layout is the first major constraint
The Spectrum bitmap isn't a simple sequence of rows. Pixel data is split into three sections, and scanlines are interleaved in a way that makes a linear walk through memory feel "wrong" at first. Colour attributes live in a separate area: one attribute byte for each 8×8 pixel cell. Each cell has an ink and paper colour, which is why colour clash is such a visible part of the Spectrum experience.
This layout is awkward if you're expecting a modern framebuffer. But once you understand it, it also gives you shortcuts. Moving across a character row often comes down to changing the low byte of an address; moving a pixel scanline can be as simple as incrementing the high byte. The exact operations depend on where you are on the screen and what kind of movement you're trying to do, but the big point is that the layout rewards thinking in hardware terms.
Drawing those addresses out by hand paid off here. A screen address isn't just a number - it's a location inside a structure with quirks, and those quirks show up everywhere you draw, erase, or move pixels.
Making the Effects Fit
Scroll text: move the bits yourself
On an Amiga, dedicated hardware can handle a lot of the graphics movement for you. On the Spectrum, the Z80 has to do it. For the scroller, I used the rotate-through-carry instruction, RL, to shift pixel data across a line.
The basic approach: start at one end of a row and rotate each byte. The bit that falls off one byte enters the next byte through the carry flag, so the lit pixels march across the row. Repeat across the 32 character cells, wait for a frame, and do it again. HALT lets the program wait for the next interrupt, which gives the effect a predictable update rhythm without racing the CPU.
My first experiment wasn't a "proper message" yet - it moved a dot. If I printed a new character at the edge and then shifted everything, it looked abrupt. What I wanted was a character entering one pixel column at a time. So instead of asking the ROM to draw a complete character, I read the font data directly. For each character, the code locates its eight-byte glyph, loads the scanlines into a buffer, then feeds those bits into the scrolling line over successive updates.
It's easy to underestimate the workload. A smooth scroll means updating hundreds of pixel positions repeatedly. Assembly makes that possible on this kind of machine, but every extra operation still competes with the music and the other effects for the same frame time.
Starfield: track the stars, not the whole screen
My first starfield idea was to shift complete rows of pixels at different speeds to fake parallax. It was easy to picture and expensive to run. Shifting an entire row moves everything there - not just the stars - and the number of operations quickly blew up my frame budget. The music slowed down too; the arpeggio chords became slow enough to hear as separate notes.
So I switched to tracking individual stars. Even then, storing X and Y coordinates and converting them to screen addresses every frame would've been wasted effort. I changed the representation: each star stores the screen byte it occupies, a bit mask for its pixel, a pointer to an occupancy table, and a speed value.
This shows up repeatedly: store data in the form the next operation needs. If a routine needs a screen address, don't force it to recalculate one from coordinates every frame unless you're getting something valuable in return.
The star positions come from a small Ruby script. It generates a fixed set of coordinate pairs, avoids duplicate Y positions, and keeps the stars clear of the logo and guide bars. This is less "random" than generating positions on the Spectrum itself, but it helped. I could inspect the layout, keep it consistent across runs, and avoid collisions without extra handling.
To make sure stars didn't draw through the text, the scrolling-text routine marks occupied character cells in a separate table. Before drawing, the star routine checks the matching entry. If the cell is occupied, it skips the draw but keeps the star moving. That turns what would've been an expensive redraw-and-erase problem into a quick check.
Parallax is just different movement speeds per star. Because the routine updates only the cells that actually contain stars (instead of touching every byte in multiple screen rows), the effect costs far less than the early "shift whole rows" approach.
Music: fit four tracker channels into three
The Amiga music I wanted to adapt was written for a four-channel tracker. The Spectrum's AY chip only has three tone channels, so a straight note-for-note conversion wasn't going to work.
I used Vortex Tracker II to arrange and refine the music, working from the original module in OpenMPT. An AY tracker module doesn't store recorded audio samples in the same way an Amiga-style setup might - it describes how the chip should generate sound using tone, noise, and parameters that change over time. Vortex Tracker ornaments can also alter pitch quickly. For example, an ornament that alternates between a note and another four semitones higher creates the impression of two notes sounding together. Used carefully, that can imply a fuller chord while still leaving a channel for another musical part.
I started with a basic sound just to confirm the notes and timing made sense. Then I replaced it with more detailed tone/noise combinations, adjusting the parts until the three-channel result felt close enough to the original to be recognizable.
That's compromise work, not a perfect conversion. The target hardware has different capabilities, and the arrangement has to respect that reality.
For playback, I used a precompiled Vortex Tracker player. The available source didn't assemble cleanly with Pasmo, and the supplied binary assumed it would live at $C000 with absolute addresses, which made relocating it awkward. I loaded the player into that fixed location, initialized it with the module address, then called its playback routine once per frame.
That last detail matters: if the visuals run too long, playback timing suffers. A demo isn't a set of independent features - it's a shared frame budget.
Memory, Trade-Offs and the Finished Intro
The VU meters and logo twinkles are smaller effects, but they still follow the same rule: use what the hardware already exposes, and keep each effect proportional to the time available.
The AY chip's amplitude registers can drive simple meter bars. I didn't build a full audio visualizer. Instead, I used the chip's readings to make a set of bars bounce with the music. A full spectrum analysis would have added complexity and eaten time I needed elsewhere. The simpler meter communicates the idea without turning an incidental effect into a second project.
For the logo, I picked a small set of pixel locations and animated each twinkle by growing and shrinking a star. I didn't need a sophisticated random-number generator for the sequence. A small routine reads bytes from the Spectrum ROM and produces a pseudo-random-looking order. It's a shortcut for a cosmetic effect, not a general-purpose randomness solution.
When the program ran out of room
Midway through development, the code hit a predictable memory problem. I kept the main program and assets together, then copied data into the places where it needed to run - so some assets existed twice in memory. On top of that, the player needed to live at its fixed $C000 address, which left less space for everything else.
The fix was to stop treating the intro like one fully self-contained block. A short BASIC loader clears the relevant memory, loads the main program, loads the player directly to its required address, and loads the screen image straight into screen memory. Then the main code starts at $8000.
It's less elegant than a single self-contained binary, but it uses memory more sensibly and let me finish. The background shows during loading instead of only after everything is ready. For a tape-based demo intended to run in an emulator or on a networked Spectrum, that trade-off was acceptable.
I could've kept refining the loader, chasing border effects, or adding more polish. But each additional feature had a cost: more code, more testing, and another chance to destabilize something that was already working. Finishing meant choosing where to stop - not proving that improvements were impossible.
What I learned by working close to the hardware
This project reminded me that low-level programming changes how you think. Instead of reasoning in terms of objects or high-level operations, you end up following registers, flags, memory addresses, and individual bits. A routine that feels obvious in a modern language takes actual bookkeeping when every instruction has a cost.
A lot of the "big" breakthroughs didn't happen at the keyboard. I'd draw out screen memory, write down register values, and walk through a loop on paper. Evening walks with my dogs turned into a kind of rubber-duck debugging - except my "audience" didn't have opinions about the HL register pair.
I also gained a new respect for the people who built demos and games on this hardware decades ago, often with fewer references and less mature tooling. Today, emulators, disassemblies, tutorials, and code repositories make it much easier to investigate what the machine is doing. They don't remove the hard parts, but they shorten the distance between a question and a useful experiment.
The final intro is one of my favourite side projects because it wasn't "efficient" in the modern productivity sense. It took months of on-and-off work, had several frustrating dead ends, and ended with a binary I can account for in most places. That kind of constraint is demanding, but it also makes the result feel concrete.
You can download the intro and read the original project notes. The included .tap image can be loaded in a compatible emulator. The source post also links to a video capture, the demoscene release page, and a browser-based way to run the production.