SearchA-ZG › Gauntlet

Gauntlet

1985 Arcade Source available Online

Gauntlet (Atari Games, 1985) is the arcade game that popularised four-player cooperative play: up to four heroes - Warrior, Valkyrie, Wizard and Elf - fight through top-down mazes swarming with ghosts, grunts and demons while a digitised voice narrates ("Warrior needs food, badly"). This build runs the original Atari "Gauntlet" board in the browser - a Motorola 68010 main CPU, a MOS 6502 sound CPU and the YM2151, POKEY and TMS5220 speech chips, compiled from C++ to WebAssembly - and boots straight into attract mode. It is wired to the emulators.org in-frame debugger so you can single-step the 68000, read and write its registers and memory, and set execution breakpoints and write watchpoints.

Visit the source repository ↗

Visit the official site ↗

Runs on: Web browser

Gauntlet Online Emulator

Play Gauntlet using JavaScript directly in your browser.

Configurations

ConfigurationEmulatorMachineOSLegal
GauntletGauntletGauntletgreyOpen ⛶
Gauntlet (2 Players)GauntletGauntletgreyOpen ⛶
Gauntlet IIGauntletGauntletgreyOpen ⛶

Machine emulated

The Atari "Gauntlet" board (Atari Games, 1985) - a Motorola 68010 main CPU at ~7.16 MHz, a MOS 6502 sound CPU at ~1.79 MHz, and a trio of sound chips: a Yamaha YM2151 FM synthesiser, an Atari POKEY, and a TMS5220 speech synthesiser for the game's spoken warnings. Video is a scrolling tile playfield plus an alphanumeric layer and a motion-object (sprite) engine scanned as a 336×240 raster, and Atari's slapstic PAL scrambles the 68000 ROM addresses as copy protection. The same board hosts Gauntlet II (1986).

Chips

Notes

Embedding

The core is FinalBurn Neo (FBNeo) - a mature, accurate arcade emulator - compiled to WebAssembly with Emscripten and driving its atari/d_gauntlet driver. FBNeo normally ships as a libretro core or an SDL app; instead of pulling in either frontend we wrote a tiny standalone Emscripten frontend, gauntlet_wasm.cpp, that talks to FBNeo's OWN C++ burn-library API (BurnLibInit / BurnDrvSelect / BurnDrvInit / BurnDrvFrame) and exposes it to JavaScript through a handful of flat functions. The module is built with -s MODULARIZE=1 so gauntlet.js is a factory you instantiate. We self-host everything (no CDN): the rebuilt gauntlet.js / gauntlet.wasm and each game as a standard FBNeo/MAME romset ZIP.

// gauntlet.js defines a global Module factory; instantiate it, stage game name + romset, boot.
var g = await Module({ locateFile: f => SRC + f });     // finds gauntlet.wasm
var np = g._wasm_game_buf(name.length); g.HEAPU8.set(name, np); // "gauntlet"
var rp = g._wasm_rom_buf(rom.length); g.HEAPU8.set(rom, rp);   // stage the romset ZIP
g._wasm_start();                                      // select driver, load ROMs, BurnDrvInit, reset

Rendering is manual. FBNeo rasters into its own bitmap (pBurnDraw); the frontend converts the visible 336×240 window to RGBA in the WASM heap. Each frame we blit that buffer to an off-screen canvas and draw it scaled to the visible canvas:

var rgba = new Uint8ClampedArray(g.HEAPU8.buffer, g._wasm_fb_ptr(), 336*240*4);
backImg.data.set(rgba); bctx.putImageData(backImg, 0, 0);
ctx.drawImage(back, 0,0, 336,240, 0,0, canvas.width, canvas.height);

Input is the Gauntlet player latch. Each of the four heroes has an 8-way stick plus a Fire and a Magic button, and there is a per-player Coin slot (Gauntlet has no Start button - inserting a coin drops a hero straight into the maze). We keep the pressed bits in JS and push them once per frame; the frontend maps them onto FBNeo's per-input pVal bytes (found by name through BurnDrvGetInputInfo) that the 68000 reads.

MemberKindWhat it does
_wasm_game_buf(n) / _wasm_rom_buf(n)exportAllocate + return a staging pointer for the driver short-name and the romset ZIP.
_wasm_start()exportSelect the Gauntlet driver by name, install a zip-backed BurnExtLoadRom, BurnDrvInit, wire input, reset.
_wasm_tick()exportRun exactly one video frame (BurnDrvFrame) and convert it to RGBA. Our frame-step and the run loop's advance.
_wasm_fb_ptr()exportPointer to the 336×240 RGBA framebuffer in the heap.
_wasm_set_input(player,bits)exportWrite a player latch (bit0 Up, bit1 Down, bit2 Left, bit3 Right, bit4 Fire, bit5 Magic; pressed=1).
_wasm_set_sys(v)exportCoin bits (bit0 P1 … bit3 P4) - needed to credit the game.
_wasm_dbg_*exportThe debug sampling API (registers, memory, step, reset) - see below.
_wasm_vw() / _wasm_vh()exportVisible picture size (336×240).

Debugger integration

This is a WebAssembly core, yet it gets the same live debugger as the pure-JavaScript machines - real registers, real memory, a real single-instruction step, plus execution breakpoints and memory watchpoints. FBNeo's 68000 keeps its register file in a plain C context reachable through m68k_get_reg / m68k_set_reg, and SekRun(0,1) runs exactly one instruction. We expose these through a small sampling API from our standalone frontend.

1 · Exactly what was added (the only new source). A single new file, gauntlet_wasm.cpp, is the whole frontend: it drives the burn library (BurnLibInit / BurnDrvSelect / BurnDrvInit / BurnDrvFrame) and appends a block of EMSCRIPTEN_KEEPALIVE functions that read live state through FBNeo's own 68000 interface. They are sampling functions: the debugger calls them ~10×/second while its window is open, and once per Step. There is no per-instruction or per-cycle callback anywhere - the emulator hot path (BurnDrvFrame) is byte-for-byte unchanged (the golden performance rule).

// gauntlet_wasm.cpp - sampling only, no hot-path hook
u32  wasm_dbg_reg(int i){                        // 0-7 = D0-D7, 8-15 = A0-A7
  return i<8 ? m68k_get_reg(NULL, M68K_REG_D0+i)
            : m68k_get_reg(NULL, M68K_REG_A0+(i-8)); }
u32  wasm_dbg_pc(){ return m68k_get_reg(NULL, M68K_REG_PC); }
u32  wasm_dbg_read(u32 a){                     // side-effect-free: pointer-mapped pages only
  if (a <= 0x037fff || (a>=0x040000 && a<=0x07ffff)   // 68K program ROM
      || (a>=0x800000 && a<=0x801fff))            // 68K work RAM
    return SekReadByte(a);
  return 0; }                                  // slapstic / I-O windows read 0
void wasm_dbg_step(){ SekRun(0, 1); }              // REAL one-instruction step

2 · What is REAL here. Because FBNeo keeps the 68000 register file in a plain C context that m68k_get_reg exposes, this build has:

  • Real registers - D0-D7, A0-A7, PC, SR (with X N Z V C S flags) and USP/SSP, read and written live off the 68000 register file.
  • Real single-instruction step - Step i calls wasm_dbg_step() = SekRun(0,1), which executes exactly one 68000 instruction; PC and the registers change by one instruction per click.
  • Side-effect-free memory - reads resolve only the pointer-mapped program ROM and 8 KB work RAM at $800000 via SekReadByte (returning 0 for the slapstic window at $038000 and all I-O), so auto-polling the hex/disasm view never trips the copy-protection bank switch or clears a latch.
  • Execution breakpoints & memory watchpoints - implemented host-side in the run loop (see below), so they cost nothing when the debugger is closed.

3 · Breakpoints and watchpoints without a hot-path hook. When no breakpoint or watchpoint is set, the loop runs a whole frame at native speed with _wasm_tick() (BurnDrvFrame). As soon as one is armed, the loop switches to stepping the 68000 one instruction at a time with wasm_dbg_step(), comparing the live PC against the breakpoint set and the watched addresses against their last sampled value, and pausing on a hit. This is a pure host-side check that only runs while a guard is armed - the C++ core is never modified. (While single-stepping under an armed breakpoint the 6502 sound CPU and video do not advance in lockstep with the 68000, so the picture holds on its last frame until you resume.)

4 · The controllable loop. We own the frame loop so the transport can pause/step. While running, each animation frame writes the input latch, advances one frame with _wasm_tick(), and blits. Pause stops the loop; Resume restarts it; Step (frame) runs one _wasm_tick(); Step i (instruction) calls wasm_dbg_step(); Reset calls wasm_reset().

Architecture

Atari Games' Gauntlet (1985) is the arcade game that popularised four-player cooperative play; up to four heroes - Warrior, Valkyrie, Wizard and Elf - fight through top-down mazes while a digitised voice narrates ("Warrior needs food, badly"). FBNeo emulates the whole board; our build is a thin standalone Emscripten port of its atari driver. The whole machine lives in one WASM module built from C/C++:

  • Motorola 68010 - the main CPU at ~7.16 MHz (object-code compatible with the 68000, which is why the shared m68000 decoder disassembles it). Its register context is what this integration samples via m68k_get_reg; SekRun(0,1) is the run-one-instruction primitive used for single-stepping.
  • MOS 6502 - the sound CPU at ~1.79 MHz, which drives the audio chips and answers the 68000 over a command latch.
  • YM2151 + POKEY + TMS5220 - the FM synthesiser, Atari's POKEY (tones + noise), and the TMS5220 speech chip that together make Gauntlet's music, effects and the famous spoken warnings.
  • Atari custom video - a scrolling playfield tile layer, an alphanumeric layer, and a motion-object (sprite) engine composited into a 336×240 picture; the slapstic is Atari's address-scrambling copy-protection PAL mapped into the 68000 ROM space at $038000.
  • Memory map - the 68000 bus places the program ROM at $000000-$07FFFF (with the slapstic window at $038000), 8 KB of work RAM at $800000, and the playfield / motion-object / palette RAM and I-O in the $900000 region. Games load from a standard FBNeo/MAME romset ZIP that packs the program, graphics, and audio ROMs.

How to build this exact artefact. Toolchain: Homebrew emscripten 6.0.3 (emcc on PATH).

git clone https://github.com/finalburnneo/FBNeo.git fbneo
# 1. generate m68kops.c (m68kmake) and a Gauntlet-only driverlist.h
# 2. add gauntlet_wasm.cpp (burn-library frontend + wasm_dbg_* hooks) + burner_stubs.cpp
# 3. compile the burn core + atari/d_gauntlet + 68000 + 6502 + YM2151/POKEY/TMS5220
#    + atariic/atarimo/slapstic/watchdog + the shim:
emcc -O2 <burn core + atari + cpu + snd .c/.cpp> gauntlet_wasm.cpp -o gauntlet.js      -sMODULARIZE=1 -sEXPORT_NAME=Module -sALLOW_MEMORY_GROWTH=1      -sEXPORTED_RUNTIME_METHODS=ccall,cwrap,HEAPU8,HEAPU32      -sEXPORTED_FUNCTIONS=_wasm_start,_wasm_tick,_wasm_dbg_pc,...   # -> gauntlet.js + .wasm

The build is single-threaded (no SharedArrayBuffer), so it hosts anywhere. The 68000, 6502 and sound cores all run in plain C/C++; there is no dynarec, so nothing needs writable-executable memory.

Sound

Sound is real hardware audio rendered by FBNeo's emulation of the Gauntlet board's sound section: a Yamaha YM2151 FM synthesiser for the music, an Atari POKEY for tones and noise effects, and a TMS5220 speech chip for the famous digitised warnings ("Warrior needs food, badly"), all driven by the board's MOS 6502 sound CPU. Each frame FBNeo mixes those three chips into its own pBurnSoundOut buffer (BurnYM2151Render + pokey_update + tms5220_update); the standalone WebAssembly frontend now exports that buffer to JavaScript and it is played through the site's shared WebAudio sink (debugger/src/audio.js). This is the V-stub pattern (a vendored core whose DSP was computed but never surfaced to JS), the same fix applied to the sibling FBNeo cores.

Exposing the audio needed a small C patch and a WebAssembly relink, because the shipped core had two problems: the DSP buffer was never exported, and nBurnSoundRate was set after BurnDrvInit(), so the YM2151 / POKEY / TMS5220 initialised at rate 0 and stayed silent. The patched frontend adds wasm_set_sound_rate(rate) and sets nBurnSoundRate / nBurnSoundLen before BurnDrvInit(), so the chips init at the browser's AudioContext rate and no resampling is needed. Two getters, wasm_snd_ptr() and wasm_snd_len(), expose the interleaved-stereo Int16 buffer and the number of stereo pairs produced that frame.

// before boot: match the core's output rate to the AudioContext
G._wasm_set_sound_rate(EmuAudio.sampleRate | 0);
// each video frame, after _wasm_tick(): push that frame's samples
var n = G._wasm_snd_len();                                // stereo pairs this frame
EmuAudio.push(new Int16Array(G.HEAPU8.buffer, G._wasm_snd_ptr(), n*2));

The mute contract is on window.EMU_BOOT.transport: isMuted() and setMute(bool) delegate straight to EmuAudio. It starts muted because browsers block audio before a user gesture; the shell's Sound button unmutes from a real click, which resumes the AudioContext.

Playing with sound. The machine boots into attract mode. The page starts muted, so click Sound to hear it. Press 5 for Coin and drop a hero into the maze, and sound is fully supported during real play.