← Experimental Atari Multiplayer Arcade

How Demon Attack 4-Player Was Made

A record of an experiment in using AI coding agents to modify a vintage game · 6 October 2026

The original one-player Demon Attack in the emulator: a score of 30 at the top, two winged demons, a scattered cluster of coloured dots, a few short pink shots, and one reddish-brown laser cannon at the bottom left above a blue ground.
The original: one cannon.
A four-player game in version 1.1: two scoreboard lines at the top with four scores in reddish brown, blue, green and yellow, each followed by a cannon icon and the cannons in reserve; demons and pink shots above; on the ground three cannons overlapping on the left (green, blue, reddish brown) and a yellow cannon to the right.
Version 1.1: four cannons, four lasers, four scores.
Contents
  1. What this is
  2. The goal and the starting point
  3. Source code from a cartridge
  4. Traps, and an OS address
  5. How the original draws the screen
  6. Where do four cannons fit?
  7. The new bottom of the kernel
  8. Four lasers
  9. Four players in the game logic
  10. Scoreboard and set-up
  11. Bringing back the explosion
  12. Fitting it into a frame
  13. Automated testing
  14. Bugs, including the agent's own
  15. Timeline and numbers
  16. Known limits and open questions
  17. Lessons
  18. Credits

1. What this is

Demon Attack 4-Player is an experimental modification of Imagic's 1982 Demon Attack cartridge for the Atari 400/800. Up to four people play at the same time, each with their own laser cannon on their own joystick port, against the same waves of demons. The modification was made by an AI coding agent (Claude Code) working from the original program, the machine code in the cartridge, with no source code available. All testing was done automatically in an emulator.

This page is adapted from the working report written by the agent that did the work, with facts checked against the project's development log and notes. "The agent" means that session.

2. The goal and the starting point

The request was a four-player simultaneous version of Demon Attack, the fourth game in the series after Wizard of Wor, Pac-Man and Joust 4-Player.

The starting point was the original cartridge image: an 8K Atari 400/800 cartridge (Imagic, 1982). A first look at the bytes showed that the program and its graphics fill only the top 4K, $B000-$BFFF; the lower 4K are all $FF. So there was room for new code inside the same cartridge size. The finished game is still a standard 8K cartridge that runs on a 16K Atari 400.

No source code or disassembly of the Atari version was available, and none from anyone else was used: everything below was worked out from the cartridge itself.

The original is a one-player game with two-player variants: in some, two players take turns with one cannon, and in two of them they share one cannon cooperatively. "Four-player simultaneous" meant four cannons on the ground at the same time, on joysticks 1–4, which only the Atari 400 and 800 have (the later XL and XE models have two joystick ports).

PlayerCannonJoystickScoreboard
1red (the original's player 1)port 1line 1, left
2blue (the original's player 2)port 2line 1, right
3greenport 3line 2, left
4yellowport 4line 2, right

3. Source code from a cartridge

The method and most of the tools came over from Joust 4-Player, built the day before: a headless test harness, a 6502 disassembler, and a private build of the emulator with coverage counters.

A coverage emulator

The coverage emulator is a private build of the project's atari800 emulator (the version with a control socket that the headless test harness drives). It counts every executed instruction per address, can fix POKEY's random seed, and writes out the counters on request. Eight games of the original, about 20,000 frames each with random joystick input, were played and their counts merged: 1,408 instruction addresses in the ROM ran. Everything that ran is code; the rest is data, or code those games never reached.

The agent's mistake, found later. To cover the different games, the coverage runs pressed SELECT, but the Atari version picks its game with OPTION and ignores SELECT. So all eight runs played game 1, and the code for the advanced games (splitting demons, the diving bird) and for the game selection was marked "never ran". It was still disassembled correctly (the generator follows branches from the code it knows), but it was the code the relocation test could not exercise, and it was exactly where two hard-coded addresses were hiding (see "Proving the source can move" below).

A source generator

A generator script disassembles from the reset vector, the VBI and DLI addresses and every covered address, and writes source for the ca65 assembler: 2,552 lines, 1,774 instructions, each with its original address and bytes in a comment, and "(never ran)" where the coverage says so. A symbol table holds the names the agent gave while reading the code (KERNEL, KBAND, VBI, MOVECANNON, LASERHIT, NEXTWAVE, CANNONSHAPE…) and marks every immediate operand that is half of an address (lda #<KERNEL, lda #>DLIST) so the code can move.

Assembled and compared with the cartridge, the generated source was byte-identical.

Proving the source can move

Byte-identical only proves that the source reproduces the cartridge in place. A relocation test assembles it twice, at $A000 and 8K lower at $6000, runs both in two emulators with the same random seed and the same scripted joystick input, and compares their RAM every 500 frames. Getting the two runs onto the same cycle took two fixes: a start stub that waits for a fixed frame and scanline, and an empty display list of its own (the OS screen memory differed between the two load layouts, which changed ANTIC's DMA and so the timing). After that the two runs stayed identical for 6,000 frames.

What the test could not see: the display list's jump-back address was a literal $BF70 in the data, and the tracer variant loaded the low byte of a joystick table as a literal $E3. The first is harmless in practice (the OS reloads the display list pointer every VBI, which is also why the test could not notice it); the second sat in the never-run game selection. Both became symbolic (<DLIST, <(JOYTAB+3)) when the 4-player code replaced those parts.

4. Traps, and an OS address

Like Joust, the cartridge protects itself against being copied to RAM:

In the 4-player source the traps are gone, with the timing kept: the kernel's 8-cycle read became LDA #$07 : SEC : NOP : NOP. So the program-file (XEX) and boot-disk versions, which run from RAM, work.

One more surprise: the VBI ends with JMP $E7D1, an address inside the 400/800's OS-B ROM. On an XL the original cartridge simply hangs (checked in the emulator). JMP SYSVBV ($E45F, the official vector) fixes it; the 4-player version runs on an XL with one or two players.

That was build b0001: the unchanged game, reassembled into the new 8K layout (new code at $A000, the original at $B000, where it must stay page-aligned because its graphics are addressed with a fixed high byte), produced as a cartridge, a program file and a boot disk. The disk and the program file use a small loader taken over from Joust that copies the image to $A000-$BFFF and starts it (48K, BASIC off). From then on every build was kept, together with the sources that made it.

5. How the original draws the screen

Demon Attack is an Atari 2600 game at heart, and its Atari 800 version draws like one. The display list is almost empty: some blank lines, one text line (the score), and a jump back. Everything below that is player/missile graphics written by the CPU, line by line, in a display list interrupt (DLI):

Hits are found with the collision registers: missile 2 against players 0/1 for the demons, player 3 against player 0 for bullets and for the bird hitting the cannon.

6. Where do four cannons fit?

The Atari has four players and four missiles. The original uses players 0/1 for the demons, player 0 also for the bullets, player 3 for the cannon and missile 2 for the laser. Four cannons and four lasers need everything:

LinesOriginal4-player
demon bandsplayers 0/1: demon halvesthe same
below the demonsplayer 0: bullets or the birdthe same, down to line $BA
$BBbulletsplayer 0 switched from bullets to cannon 1
$BC-$C7player 3: the cannon; player 0: bulletsplayers 0–3: the four cannons
everywheremissile 2: the lasermissiles 0–3: four lasers, one colour (PRIOR "fifth player")

The price: bullets and the bird are no longer drawn over the cannons, because there is no player left for them in those 12 lines. They vanish just above the cannon tops, and because the hardware can no longer see them overlap a cannon, their hits are computed in software, using the same pixels the collision registers used to see.

The cannon as the kernel draws it (red-brown), and the laser waiting in it (pink, lines $BE-$C5, one pixel at x+3). The bullet column is drawn in cells of 8 lines: the cell over lines $BD-$C0 meets the barrel rows (together $6C), the cell over $C5-$C8 the base ($C6; with the continuous "laser" bullets of some waves the cells are longer and the base mask is $EE). A hit is (bullet byte shifted by the distance) AND mask ≠ 0, for each cannon.

One consequence the agent first took for a bug: a bullet exactly in the gap at x+3, where the laser sits, passes through. The original does the same (its collision check saw cannon pixels only).

7. The new bottom of the kernel

The kernel is timed to the cycle on every line (each line ends with STA WSYNC, and about 104 CPU cycles are usable), so every change was counted:

KC_ROW: lda  CSH,y          ; cannon 1, row y      (loaded before the line)
        ldx  CSH+16,y       ; cannon 2
        sta  WSYNC
        sta  GRAFP0
        stx  GRAFP1
        lda  CSH+32,y       ; cannon 3
        sta  GRAFP2
        lda  CSH+48,y       ; cannon 4
        sta  GRAFP3
        dey
        bne  KC_ROW
Close-up of the bottom of the screen: four laser cannons side by side on the ground, reddish brown, blue, green and yellow, each with a pink laser standing in the gap of its barrel.
Lines $BC-$C7: four cannons on players 0–3, each laser waiting in its barrel.

8. Four lasers

The laser is the only object drawn by DMA: 8 lines of missile memory at $1300. The original wrote $20, missile 2's left pixel, over whole bytes. Four lasers share those bytes, so the new drawing and erasing routines (LDRAW4/LERASE4) set and clear only their own bit ($02, $08, $20, $80: the left pixel of missiles 0–3).

Missiles normally take their player's colour, and the players change colour every line (the demon rows). PRIOR bit 4 ("fifth player") gives all four missiles the colour of playfield 3. The kernel sets that to the original laser colour $4D at line 44, right after the scoreboard, where playfield 3 is player 4's score colour. The OS restores it every VBI.

The lasers' horizontal positions are written at line 9, long before any laser can be on screen.

9. Four players in the game logic

The original's single-player code (explosion, cannon hit, movement, laser, fire) sat in the VBI. It was replaced by PLAYERS4, which does the same for each player in the game, and which runs in the DLI in the 36 lines the original spent waiting for the kernel to start (section 12 explains why it moved there). Per player:

After the frame, LASERS4 finds each laser's first band in HB0-HB3 and runs the original's hit logic for that player (normal demon, split demon half, or the bird, which is tested in software by BIRDLAS), adding the points to that player's score.

The rest follows the original's rules, per player: 3 cannons in reserve, one more at the next wave for every player not hit during the wave (BONUS4, 6 at most), out after the last cannon, game over when nobody is left. The demons aim at the nearest living cannon (NEARCX; in the original, "the cannon") and dodge the lowest flying laser. The demons hold their fire while any cannon explodes, as the original did during its one explosion, and the next wave waits until all explosions are over.

A four-player game of game 3: winged demons above, a small green-and-brown bird shape low on the left diving toward the blue cannon below it, pink shots, and the four scores at the top.
Game 3: a demon's last half dives at the nearest cannon (left).
Close-up of the ground: green, blue and reddish-brown cannons drawn on top of each other in one cluster, a yellow cannon to the right, and a pink shot above them.
Cannons may overlap: they are separate hardware players.

10. Scoreboard and set-up

The display list got a second text line (the same 10 bytes, now $80,$70,$70,$46,<TXT1,>TXT1,$06,$41,<DLIST,>DLIST). In ANTIC mode 6 the top two bits of a character select one of four playfield colours, so each player's block is in that player's colour: COLOR0-3 = red, blue, green, yellow. A block is 10 characters: the score, a cannon icon, and the number of cannons in reserve. The icon needs a custom character, so the ROM font is copied to $0800 at power-on with the cannon in place of the exclamation mark. One block is redrawn per frame. The original's bunkers on the ground, which showed its lives, are gone; the scoreboard shows the reserves instead.

Close-up of the two scoreboard lines: on the first line a reddish-brown 20 with a cannon icon and 4 on the left, and a blue 40 with a cannon icon and 4 on the right; on the second line a green 40 and a yellow 10, each with a cannon icon and 4.
Players 1 and 2 on line 1, players 3 and 4 on line 2.

SELECT, which the original ignores, chooses one to four players (two on an XL/XE; the 400/800 operating system is recognised by its RESET vector at $D800 or higher). OPTION chooses game 1–4, mapped to the original's one-player games 1, 3, 5 and 7 (normal, tracer, advanced, advanced tracer). The original's two-player alternating and cooperative variants have no use with four cannons. START or the button of joystick 1 starts a game. The demo before the first game shows the original's copyright line and a version line.

The demo screen: (C) 1982 IMAGIC in reddish brown and 4-PLAYER V1.1 B0011 in blue at the top, three winged demons and a small cluster of coloured dots below them, and no cannons on the ground.
The demo: the original's copyright line and the version line.
The set-up screen: GAME 1 NORMAL in reddish brown and 4 PLAYERS in blue at the top, followed by four small cannon icons in reddish brown, blue, green and yellow; the rest of the screen is empty above the blue ground.
After SELECT or OPTION: the set-up, with a cannon for each player.

11. Bringing back the explosion

The original's explosion is a spray of coloured debris, drawn with players 0/1 on either side of the cannon. Build b0002 replaced it with a blinking cannon, because four explosions cannot each have two players. Looking at the original again (b0008) showed a way: during an explosion there are never any bullets (they are cleared at the hit, and no new volley starts while a cannon explodes), and the bird was now also held back. So players 0/1 are free above the cannons, and KEXPL4 draws the original's debris (the same routine, the same shapes and colours) for one exploding cannon, 16 lines higher so that it ends above the cannon rows. That cannon disappears as in the original; a second cannon exploding at the same moment blinks.

The original game during an explosion: a ring of coloured debris dots at the bottom left where the cannon was, three small bunker shapes on the ground below it, winged demons above and the score at the top.
The original.
A four-player explosion: a ring of coloured debris dots in the middle of the screen above the ground, reddish-brown and green cannons on the ground with pink lasers in their barrels, demons above, and all four scores at 0 with 3 cannons in reserve.
4-player: cannon 2's debris; cannon 4, hit at the same time, blinks (off in this frame).

12. Fitting it into a frame

The kernel occupies lines 44–211 completely. Everything else has to fit in what is left, and nothing may run late: the DLI's logic must end before the VBI at line 248 (the VBI would interrupt it), the VBI must end before the DLI at line 8, and the players' code must end before the kernel starts at line 44. Three profile bytes in RAM (PROFV, PROFD, PROFT) record the latest end of each part, and the soak test fails if one is over budget.

The first 4-player build worked, but its worst frames ended at line 40 (kernel at 44) and 240 (VBI at 248). The changes, measured one by one:

BuildChange
b0003After a hit, the laser goes back into its cannon in the next frame's player code instead of after the kernel (it is not on screen in between); the bird test rejects far cannons at once.
b0004The laser's step is a subtraction (it was a loop of 3–6 steps); the bullet test rejects a far bullet column at once.
b0005The flying lasers are erased in the VBI (in the place of the removed trap's three NOPs) and redrawn before the kernel: half of the lasers' work moved out of lines 9–44.
b0006, b0009The scoreboard block moved to wherever there was room (in the end: after the kernel).
b0009The bird-against-cannon test: see the bug in section 14.

Frames per end line of the players' code, with the same game set-up and the same autopilot (game 3, four players): b0004 (first) and b0005 (second). The kernel starts at line 44.

Final worst frames over eight soak games: the players' code ended by line 34–38, the logic after the kernel by 238–242, and the VBI 12 lines after 248.

13. Automated testing

All tests ran headless: the emulator with dummy video and audio, at turbo speed, on a private control socket, with no windows and without touching any other session's emulator. The tests used an emulated Atari 800 with Atari's OS-B ROM, and an emulated XL for the XL checks.

TestWhat it checks
Smoke testThe cartridge, the program file and the boot disk boot on an Atari 800 with OS-B, and the cartridge on an XL; a game starts, demons appear, the score rises.
Event tests (31 checks)Set-ups made by writing RAM: the SELECT/OPTION cycles (and one or two players on the XL); each player's laser scores for that player only; a bullet 8 pixels away misses; a bullet on a cannon hits only that cannon; the debris and the blink; a cannon less after the explosion; out after the last cannon; the bird diving into a cannon; the bird shot by player 2 (points to player 2 only); bonus cannons per player; game over with the lasers gone and the final scores kept; a one-player game.
Soak testAn autopilot plays a game to the end: every cannon holds its fire button down and steers under a demon, stepping aside from the bullet column. The test checks that scores only grow, that lives stay between 0 and 6, that no laser is left after game over, and that the frame budget holds.
Parallel soakEight soak games at once: games 1–4 with one to four players.
Source checksThe byte-identical rebuild and the relocation test of the generated source, rerun along the way.
Game over after an autopilot game: the four final scores at the top, 465 in reddish brown and 260 in blue on the first line, 80 in green and 1055 in yellow on the second, above an olive-green ground and an otherwise empty screen.
An autopilot game over: the final scores stay on screen.

14. Bugs, including the agent's own

One bullet, two cannons. With two cannons on the same spot, a bullet destroyed only the first one tested: the hit cleared the bullets before the second cannon was tested. Now all cannons are tested first, and the bullets are removed after the loop (b0007).
The bird over stacked cannons. Single soak runs were fine; the parallel run of eight found one frame in which the players' code ended at line 56, 12 lines into the kernel. The autopilot had stacked three cannons under a low diving bird, and the test compared six bird rows per cannon with a shifting loop. The fix keeps the test pixel-exact: the cannon has only four different rows ($28, $6C, $EE, $C6), so once a frame the bird's rows are ORed into four groups by the cannon row they meet, and each cannon needs at most four byte tests (b0009).
Tests that were wrong. Most of the first run's 12 event-test failures were the tests' own: bullets aimed at x+3, the barrel's gap (section 6); a bird test that gave the slow bird (about one line down per four frames) too few frames; a score test that started while the previous player's laser was still flying; and one expectation off by one (4 − 1 is 3, not 2). Fixing them is what exposed the real bug above.
A shell loop. A loop meant to start six soak runs, each with its own settings, passed each run's settings as a single argument, because the zsh shell does not split words the way other shells do, and all six runs failed at once. The same mistake had happened in an earlier session; the parallel soak runs are now started from Python.
The coverage runs pressed the wrong key (section 3), and two literal addresses survived in the never-run code until it was replaced.
Found while the development report was being written: frozen debris. The first screenshot of the set-up screen taken for the report showed explosion debris hanging at the bottom of the screen. SELECT had been pressed while the demo's cannon was exploding: the set-up screen stops the players' code, so the explosion timer never ran out, and the kernel kept drawing the debris. Version 1.1 (b0011) clears every explosion when a game stops, and the event test now presses SELECT in the middle of an explosion.
Smaller ones: the first build failed because the new code used the version-text macro without including the file that defines it; moving the explosion code pushed two kernel branches out of range (the code was reordered); and the very first profile showed the VBI "ending at line 110": that was the game-start frame, where the original itself switches all interrupts off for a while (the tests now reset the profile after the start).

15. Timeline and numbers

Time (6 Oct)BuildStep
19:25—Project set up; tools taken over from the Joust 4-Player work
19:30–19:57—Coverage, generated source (byte-identical), relocation test
20:02b0001The original in the 8K 4-player layout, traps removed, runs on an XL
21:59b0002Four cannons, four lasers, scoreboard, SELECT/OPTION
22:04–22:15b0003–b0006Timing
22:23b0007One bullet, two cannons
22:52b0008The original's explosion debris
22:57b0009The bird over stacked cannons
23:03b0010Version 1.0
23:34b0011Version 1.1: no frozen debris after SELECT/OPTION during an explosion (found while the report was being written)
Cartridge8K: new code 2,695 of 4,096 bytes; the original's part 3,296 bytes (it was 4,081: the single-player code it no longer needs was removed)
Source1,236 lines of new assembly in one new module; the generated source of the original, forked, with 65 lines marked as changed (; 4P) by a recorded patch script; a separate RAM map
Builds11, every one kept (cartridge, disk, program file and the sources that made it)
Testssmoke (4 runs), 31 event checks, 8 soak games per run

16. Known limits and open questions

17. Lessons

Credits