← Experimental Atari Multiplayer Arcade

How Joust 4-Player Was Made

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

Contents
  1. What this is
  2. The goal and the starting point
  3. Finding which bytes are code: a coverage emulator
  4. Source code for a cartridge that had none
  5. Checking that the source can move
  6. Two copy-protection traps
  7. How the original works
  8. A 32K program, three formats, every build kept
  9. Step 1: twelve riders
  10. Step 2: four players and two modes
  11. The scoreboard
  12. Speed
  13. Automated testing
  14. Bugs, including the agent's own
  15. Version 1.1: drop-in rejoin
  16. Timeline and numbers
  17. Known limits and open questions
  18. Lessons
  19. Credits

1. What this is

Joust 4-Player is an experimental modification of Atari's 1983 Joust cartridge for the Atari 400/800 (the game itself is © 1982 Williams). Up to four people can play at the same time, each on their own joystick port, in two modes: free for all and teams. The modification was made by AI coding agents (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 built version 1.0. The section on version 1.1 is drawn from the project's development log and notes. "The agent" means the session doing the work being described.

2. The goal and the starting point

The request was a four-player simultaneous version of Joust with two multiplayer modes: free for all, and teams ("1 vs 2" or "2 vs 2"). Four-player versions of Wizard of Wor and Pac-Man for the Atari 800 had been made earlier.

What there was to work with:

Title screen of the original Joust cartridge: the JOUST logo in a striped frame, DIFFICULTY: EXPERT, PLAYERS: 1, and the lines COPYRIGHT ATARI 1983 and COPYRIGHT WILLIAMS 1982.
The original title: SELECT sets the difficulty, OPTION 1 or 2 players.
The original game in play: floating rock ledges, a grey buzzard rider at the upper right, and the score 10150 in the left box inside the bottom ledge; the right box is empty.
The original in play. The score boxes for players 1 and 2 sit inside the bottom ledge.

A first look settled one point: the screen shows more riders than the Atari has hardware sprites (four players plus four missiles), so the original already multiplexes its sprites. The open question was how far that mechanism could be stretched.

3. Finding which bytes are code: a coverage emulator

A tracing disassembler follows jumps and branches from the entry points, but it cannot see through jump tables, interrupt vectors that are set up at run time, or code that is reached only through pointers. Instead of guessing, the agent measured. It built a private copy of the project's atari800 emulator (the shared emulator stayed untouched) and added a counter for every executed instruction address, with a socket command that dumps all 64K counters.

A coverage script ran the original under it: the attract mode, the option keys, and five random 1- and 2-player games. Coverage settled at 4,735 executed opcode addresses. Each of them is certainly code, and the tracer starts from all of them. The parts that never ran (later waves, the pterodactyl, wave bonuses) were found by the tracer from there, and the listing marks them as never run.

The private emulator later received more features, each one added when a problem needed it:

4. Source code for a cartridge that had none

A source generator turns the cartridge into a ca65 assembler source, and a separate symbol script holds everything a program cannot work out on its own: names, data formats, pointers. Every instruction line keeps its original address and bytes in a comment:

JOUST:
        lda  OBJ_DEAD,x                 ; 8C4A  BD 8C 10
        bne  L8C70                      ; 8C4D  D0 21
        ...
        jsr  BOUNCE                     ; 8C5E  20 3C 8D

For a source that can be changed, every ROM address must be a symbol, not only the obvious ones in JSR and LDA operands. The places they were hidden in Joust:

The generator supports these as data formats (words, records, byte expressions). By the end the symbol script named 111 routines and tables and 73 RAM variables and arrays. The first complete source reassembled byte-identical to the cartridge, and a byte-identical rebuild check confirms that every time it is run.

5. Checking that the source can move

A byte-identical result only proves the addresses came out the same. A missed pointer still assembles to the right byte and breaks only when the code moves. So a relocation test assembles the source twice, at $8000 and at $4000, fills the unused 16K of each with $02 (an opcode that halts the 6502, so any stale jump or read shows), boots both as program files and compares their RAM every 500 frames under identical scripted joystick input.

Getting two emulator runs to agree to the cycle took several rounds:

  1. The random seed and the free-running frames before the test connected (the emulator changes in section 3).
  2. The two program files finished loading at slightly different points in the frame. The fix: the start stub waits for frame 255 and scanline $40, then does STA WSYNC, so both start the game on exactly the same cycle.
  3. The input had to be a pure function of the frame number, so that a bisecting search could rewind with saved states, replay exactly the same input and find the first frame where the two runs differ.
  4. A real quirk of the original: the title setup copies 512 bytes from ROM to RAM, and the last 19 are code bytes. The title's colour cycling then branches on bit 7 of those bytes, so its timing depends on where the code sits. The test zeroes those 19 bytes in both builds.
  5. The two copy-protection traps (next section).

The end result: the source assembled at $4000 ran cycle for cycle identical to the original for 12,000 frames (demo, title, a full 1-player game to game over and the demo again), and the read watch showed no data read of the old address range.

6. Two copy-protection traps

Both traps act only when the program runs from RAM. The 4-player version has to run from RAM (as a disk or a program file), so both had to be found.

Trap 1: the poke list

At power-on the game copies 16 bytes from ROM to $04F0: a short list of five addresses and five values. Every time a score or a life is drawn, a routine "pokes" the five values into the five addresses. When a game starts, the high byte of the third address is changed to $A7, and that entry then writes $FF to $A773: the operand of an LDX #$04 in the poke routine itself. In ROM nothing happens. In a RAM copy the instruction becomes LDX #$FF, the loop writes over memory and the game crashes. As a second line of defence, the joystick routine reads the sticks only while that high byte is still $A7.

Trap 2: an RTS over the physics

L92F3:  lda  #$60                       ; RTS
        sta  TASK_PHYS                  ; $8946: the object update task

When a knight finishes materialising at a spawn pad, the game writes an RTS over the start of its object update. In ROM nothing happens. In RAM every rider on the screen freezes, and the player cannot move.

A RAM copy of the original with the second trap active: three brown buzzard riders stand on ledges and the yellow knight stands on the bottom ledge, all frozen in place.
Trap 2 in a RAM copy: the riders stop moving for good.

Both targets are now symbols (TRAP_HI, TRAP2_ADDR). The 4-player version sends the two writes to $C773 and $C946: unmapped memory on the 800, OS ROM on an XL/XE.

Trap 2 was found late. The first relocation runs "passed" while both builds froze after about 2,500 frames: they were compared with each other, and both were frozen in the same way. Since then the tests also check that the player actually moves and that the game reaches game over.

7. How the original works

The original is a 16K cartridge for a 16K machine and uses every byte of RAM $0000-$3FFF:

RAMUse
$0500-$065Fmultiplexer: 12 sprite slots, 4 channel lists, DLI event lists
$0A00-$0BFFtitle text (copied from ROM)
$0F00-$0FFFsprite shapes (12 bytes each)
$1000-$12FFobject tables: the unused low part of single-line sprite memory
$1300-$17FFmissiles and players 0-3
$1800the game's display list; $1900/$1A00 screen line address tables
$1BE0-$1FCF21 lines of rock and lava under the playfield
$2010-$3FEFthe playfield: ANTIC mode E, wide (48 bytes per line), 170 lines

The agent wrote what it learned into a notes document as it read, so the knowledge would survive if its working context was compacted.

8. A 32K program, three formats, every build kept

The ROM was full (it ends in 93 bytes of leftover junk from the original developer's assembler), and the RAM was full. The 4-player version therefore became a 32K program for a 48K machine:

A build script produces, from one link:

As in the other projects, every build was kept together with the source that produced it, files were archived before every edit, and the development log is append-only. The generated source was forked into the 4-player program, with every edit marked ; 4P. The larger rounds of edits were applied by four small recorded patch scripts that check every line they change, so each step can be reproduced and reviewed.

9. Step 1: twelve riders

Four players and eight enemies means 12 objects instead of 10. Rather than squeeze them into the old space, the agent moved the object tables out of the sprite memory gap into LO, with 16 bytes per array. Because the generated source names every array (OBJ_X, OBJ_STATE, ...), moving them was a change of 31 equates. Then:

After this step, 1- and 2-player games played as before, which was the check that the widening had not broken anything.

10. Step 2: four players and two modes

Title screen

The console has only three keys. SELECT keeps the difficulty; OPTION now cycles 1, 2, 3, 4 players (free for all), then 3 players teams and 4 players teams. The title got a new display list with two more text lines: 4-PLAYER V1.0 B0017 under the logo, and a MODE: line. On an XL/XE, which has only two joystick ports (detected by its OS reset vector), OPTION offers 1 and 2 players only.

The version 1.0 title screen: the line 4-PLAYER V1.0 B0017 under the logo, then DIFFICULTY: SKILLED, PLAYERS: 4 and MODE: TEAMS 1+2 VS 3+4.
The 4-player title screen of version 1.0.

The teams

The agent read "(1 vs 2) or (2 vs 2)" as the two ways teams can be formed with three or four players. Players 1 and 2 are always team 1; player 3 (or players 3 and 4) is team 2. The title spells it out: TEAMS 1+2 VS 3, TEAMS 1+2 VS 3+4. On the scoreboard the teams are simply its two rows.

The rule change itself is small. Where two riders meet, the original's test "are both enemies? then just bounce" became a call to JOUSTOK4, which also returns "just bounce" for two players of the same team. In free for all every player has their own team number, so anyone can unhorse anyone, as in the original 2-player game.

The rest of the game, for four

Start of a 4-player game: yellow, green, light blue and pink knights side by side on the bottom ledge, a white rider materialising on the top ledge, HI 0 and WAVE 1 in the ledge boxes, and four scores of 0 with 4 men each on the two scoreboard lines.
A 4-player game starts: four knights on the bottom ledge.
Game over with three players: THY GAME IS OVER and PLAYER 3 WINS in the middle of the screen; the scoreboard shows 6000 in yellow, 7750 in green and 12500 in light blue, and the ledge boxes show HI 12500 and WAVE 3.
Game over with three players, decided by score.

11. The scoreboard

Four scores had to go somewhere, and each player should be able to find theirs by colour. The playfield is ANTIC mode E: three colours plus background per line, and changing colours in the middle of the playfield would need display-list interrupts that collide with the multiplexer's. So the scoreboard went under the playfield, where only the game's own two status interrupts run:

Close-up of the bottom of the screen: the ledge boxes read HI 4300 and WAVE 1; below them two text lines show each player's score, a small knight and the men in reserve, in yellow and green on the first line and light blue and pink on the second.
The ledge boxes (high score, wave) and the two scoreboard lines: players 1 and 2, then 3 and 4.

The first version used all 24 columns of the wide line, and the outer columns turned out to be outside the visible picture, so it was laid out again in the middle 20. The two boxes inside the bottom ledge, where the original showed players 1 and 2, would have been left empty; they now show the session high score and the wave, or in teams the two team totals, drawn in the original's grey 5x5 digit style.

A teams game: the ledge boxes show T1 4300 and T2 7000; the scoreboard shows 3100 and 1200 for players 1 and 2 on the first line and 1650 and 5350 for players 3 and 4 on the second, with knights and buzzard riders in the air above.
Teams: T1 = players 1 + 2, T2 = players 3 + 4.

One more safety fix in this area: the original's screen line table points lines below the playfield to $3FF0 and up, which on a 16K machine is nothing, but on a 48K machine is now the new code. Those entries point to a dummy line.

12. Speed

The original already slows down when the screen is full: its tasks simply run late. The agent measured this with the coverage counters: in wave 12 with two players and about 6.7 enemies, the physics task ran on 761 of 1,000 frames. Twelve riders and fourteen sprite slots cost more per frame than ten and twelve. A profile over 500 busy frames (cycles per routine, from the executed-instruction counts) showed where the time goes:

PartShare of the CPU (4 players, wave 12)
sprite multiplexer, all partsabout 45%, of which copying the shapes 15%
platform collision tests15%
physics and rider-against-rider collisionsabout 13%

Two changes, both with exactly the same results as before:

Result in the same kind of heavy scene with four players: physics on 831 instead of 675 of 1,000 frames (two different runs, with 4.6 and 5.2 enemies on average, so the figures are only an indication), and the main loop now idles about 16% of the time. A full late wave still runs below full speed, as in the original.

13. Automated testing

Everything was tested headless on an emulated Atari 800 with OS-B (only the 800 has ports 3 and 4), with no window, on private sockets, and mostly with the fixed random seed so that every failure could be replayed.

TestWhat it checks
Smoke testthe cartridge, the program file and the boot disk boot, start a game and play
Event testsPvP in free for all; team mates only bounce; opponents joust; game over announces PLAYER 2 WINS and TEAM 1 WINS; the team-wave bonus (6 tests)
Multiplexer test12 riders frozen in three layouts: every rider gets a sprite at least every other frame
Soak testsan autopilot plays a whole game in any mode to game over and back to the demo; fails on a stall or crash
Wave testjumps to a given wave and logs the special events: team, gladiator, survival, egg and pterry waves, bonuses, the pterodactyl, the troll
Load and profile testsCPU load and profile, original against 4-player
Drop-in tests (v1.1)see section 15: 5 scenarios, 57 checks

The autopilot reads the object tables, picks the nearest enemy, flaps until it is above it and glides onto it. It is reckless, but it scores about 30,000 points and reaches wave 4. To test later waves, a skip-to-wave helper sets the wave number and lets the next enemy that disappears end the wave.

Two more checks: an XL (only 1-2 players offered, the game plays), and coverage of the 4-player build over the whole test set. Every new routine ran except the title's OPTION handler (the tests set the options directly; it was tried by hand). The bonus man, egg catching and hatching, pterodactyl hits, the troll's grabs and the wave bonuses all ran with four players.

Wave 7 with four players: the message PTERRY WAVE in the middle of the screen, the upper platforms crumbling into scattered dots, knights and buzzard riders in the air, and all four players' scores on the scoreboard.
Wave 7 with four players: "PTERRY WAVE", crumbling platforms.
A teams game played by the autopilot: knights and buzzard riders spread across the screen, team totals T1 and T2 in the ledge boxes.
A teams game under the autopilot.

14. Bugs, including the agent's own

Players 3 and 4 left sparkles hanging in the air. Grey squares stayed on the screen after riders died. No egg was active, so they were not eggs. The agent ran the original under the same autopilot: no squares. The cause was the dying knight's sparkle effect, which keeps its state in 2-entry arrays at $12E2-$12E9 and animates only players 1 and 2. Players 3 and 4 drew their sparkles (with XOR), never erased them, and wrote into player 1's bytes. Every other 2-player array had been found by searching the code; this one was hidden behind plain addresses. Fixed with 4-entry arrays.
The sparkle bug: about nine small grey squares hang in the air between the ledges after riders died.
The bug: leftover sparkles.
The original game under the same autopilot: no grey squares anywhere on the screen.
The original, same autopilot: no sparkles left.
The fixed 4-player build: no grey squares in the air.
Fixed.
Dead joysticks, caused by an error in the agent's own symbol script. When turning the trap's joystick check into a symbol, the agent had given the address of its CMP #$A7 as $A6CA; it is at $A6CC. With the trap moved, the comparison failed and the joysticks were never read, in the early builds and in the first relocation runs, which still "passed" because both builds ignored the joystick in the same way. Since then the tests also check that a player actually moves.
"Not playing" became "playing". The all-players-out test compared the men counters with exactly $FF. A test pushed one to $FE, and the game would never have ended. It cannot happen in a real game, but the test now checks the sign bit.
Smaller ones, caught before they shipped:

Two "bugs" that were not: player 2 "won" a joust that a test meant player 1 to win (gravity had moved them; the game was right and the test's geometry was wrong), and a scoreboard digit that looked like an inverted block in a scaled screenshot was a normal slashed zero in the raw screen buffer.

15. Version 1.1: drop-in rejoin

In the online Atari Multiplayer Arcade, where four people play together over the network, a player who had lost all their men had to watch until everyone else was out as well. The request for a change came through the agent working on the arcade, and the design chosen was: a player who is out can rejoin, their score restarts at 0, and a title-screen option, ON by default, controls it. The arcade drives the title screen with OPTION and SELECT and cannot send keyboard keys, so in the arcade drop-in is always on. A second agent session did this work shortly after midnight on 6 October.

Constraints

How it works

The version 1.1 title screen: 4-PLAYER V1.1 B0021 under the logo, then DIFFICULTY: SKILLED, PLAYERS: 4, MODE: FREE FOR ALL, and a new line DROP-IN (KEY D): ON.
The version 1.1 title with the drop-in line.
Drop-in during a game: player 2 is out and the words PRESS FIRE appear in green in player 2's place on the scoreboard, while the yellow, light blue and pink knights stand on the bottom ledge.
Player 2 is out and can rejoin: PRESS FIRE in player 2's place on the scoreboard.

Builds and tests

16. Timeline and numbers

TimeStep
5 Oct, 20:35the original cartridge image arrives; project set-up, test harness, first look
20:40coverage emulator, coverage runs (4,735 code addresses)
20:45-21:10source generator, byte-identical; relocation test, trap 1, reproducible emulator
21:10-21:20trap 2, names for everything, the fork, the 32K build (builds 1-3)
21:20-21:45step 1 (twelve riders) and step 2 (four players, modes, scoreboard) (builds 4-7)
21:50-22:17the sparkle bug, speed, the ledge boxes, tests, user manual; v1.0 = build 17
6 Oct, 00:05version 1.1 begins: files archived, plan, baseline test run on v1.0
00:05-00:40drop-in code, new tests, documentation (builds 18-21)
00:40v1.1 = build 21
MeasureValue
Generated source of the originalabout 7,200 lines, byte-identical
Edits to the original (v1.0)222 lines marked ; 4P
New code (v1.0)1,326 lines (about 660 instructions plus macros)
Memory used (v1.0)HI 15,955 of 16,256 bytes; LO about 4.9 KB of 16 KB
Drop-in code (v1.1)619 bytes in a new segment of LO; HI unchanged in size
Builds17 to v1.0, 21 to v1.1, all kept

17. Known limits and open questions

18. Lessons

Credits