Scorched Earth v1.50 — faithfully reverse-engineered from the 415KB DOS binary and rebuilt for the web.
22 modules, 5,925 lines of JavaScript, zero dependencies. Every game mechanic — physics constants, weapon behaviors, AI solver, palette layout, damage formulas — was extracted from the original SCORCH.EXE binary via disassembly and reimplemented in vanilla JS with ES modules.
| Metric | Value |
|---|---|
| JS modules | 22 files |
| Total lines | 5,925 |
| External dependencies | 0 |
| Build step | None — raw ES modules |
| Resolution | 320×200 (VGA Mode 13h) |
| Rendering | WebGL (GPU) or Canvas2D (CPU fallback) |
| Weapons implemented | 57 (all from EXE weapon table) |
| AI types | 7 (Moron through Spoiler) |
main.js ─────────────────────────────────────────── 457 lines ├── game.js (state machine, turn logic) ──────── 922 lines │ ├── physics.js (projectile simulation) ───── 358 lines │ ├── behaviors.js (weapon dispatch) ────────── 550 lines │ ├── ai.js (trajectory solver) ─────────────── 331 lines │ ├── explosions.js (craters, damage) ───────── 326 lines │ ├── shields.js (deflection, absorption) ──── 225 lines │ ├── score.js (scoring modes) ──────────────── 75 lines │ ├── shop.js (buy/sell UI) ─────────────────── 219 lines │ ├── talk.js (speech bubbles) ──────────────── 196 lines │ └── sound.js (PC speaker emulation) ───────── 84 lines ├── framebuffer.js (VGA VRAM emulation) ───────── 212 lines ├── palette.js (256-color VGA DAC) ────────────── 300 lines ├── terrain.js (landscape generation) ─────────── 342 lines ├── tank.js (player/tank management) ──────────── 309 lines ├── hud.js (status bar) ───────────────────────── 126 lines ├── menu.js (config screens) ──────────────────── 271 lines ├── font.js (pixel font renderer) ─────────────── 237 lines └── input.js (keyboard handler) ───────────────── 31 lines Shared utilities: config.js ─── game settings (57 lines) utils.js ──── math helpers (49 lines) weapons.js ── weapon data table (248 lines)
The original game renders to VGA Mode 13h: a linear 320×200 framebuffer at segment A000h, where each byte is a palette index (0–255) that maps through the VGA DAC to an RGB color. The web version faithfully replicates this architecture:
// The indexed pixel buffer (VGA VRAM equivalent) const pixels = new Uint8Array(320 * 200); // 64,000 bytes // Set a pixel: just store a palette index pixels[y * 320 + x] = colorIndex;
In the original game, VRAM is the world model. There is no collision mesh, no spatial hash, no physics geometry. Collision detection reads pixel colors directly from the framebuffer:
| Pixel Value | Meaning | Action |
|---|---|---|
| 0 | Black/background | Pass through |
| 1–79 | Tank body (floor(pixel/8) = player index) | Hit tank |
| 80–103 | Sky gradient | Pass through |
| 104 | System black (HUD) | Pass through |
| 105–149 | Terrain | Hit terrain |
| 170–199 | Explosion fire | Pass through |
The blit() function converts palette indices to RGB for display, with two backends:
WebGL (primary) — uploads the 320×200 index buffer as a LUMINANCE texture. A 256×1 RGBA palette texture acts as the VGA DAC. The fragment shader performs the lookup on the GPU:
// Fragment shader: VGA DAC lookup float i = texture2D(u_idx, v_uv).r; gl_FragColor = texture2D(u_pal, vec2((i*255.0+0.5)/256.0, 0.5));
Canvas2D (fallback) — CPU loop maps each palette index through a Uint32Array lookup table to an ImageData buffer:
for (let i = 0; i < 64000; i++) {
buf32[i] = palette32[pixels[i]];
}
ctx.putImageData(imageData, 0, 0);
The game loop runs via requestAnimationFrame, dispatching to the current state each frame. The state machine mirrors the original EXE's play.cpp dispatch at file offset 0x2F78A:
| State | Purpose | Transitions to |
|---|---|---|
| TITLE | Title screen | CONFIG |
| CONFIG | Game settings | PLAYER_SETUP |
| PLAYER_SETUP | Player names & AI types | ROUND_SETUP |
| AIM | Human/AI aim adjustment | FLIGHT |
| FLIGHT | Projectile physics simulation | EXPLOSION |
| EXPLOSION | Crater animation + damage | FALLING / NEXT_TURN / ROUND_OVER |
| FALLING | Unsupported tanks drop | NEXT_TURN / ROUND_OVER |
| NEXT_TURN | Wind update, advance player | AIM / SCREEN_HIDE / SYNC_AIM |
| ROUND_OVER | War quote + scoring | SHOP / GAME_OVER |
| SHOP | Buy/sell equipment | ROUND_SETUP |
| ROUND_SETUP | New terrain + place tanks | AIM / SYNC_AIM |
| SYNC_AIM | All players aim (sync/simultaneous) | SYNC_FIRE |
| SYNC_FIRE | Batch launch all shots | FLIGHT |
| SCREEN_HIDE | "NO KIBITZING" interstitial | AIM |
The core gameplay cycle: AIM → FLIGHT → EXPLOSION → FALLING → NEXT_TURN, repeating until one player survives.
DT = 0.02 // timestep (EXE default, calibrated via CPU MIPS benchmark) GRAVITY = 4.9 // px/sec² downward WIND_SCALE = 0.15 // wind config → horizontal accel MAX_SPEED = 400 // px/sec at power=1000
Matches EXE simulation loop at file 0x21A80–0x21D09:
x += vx*dt, y -= vy*dt (screen Y inverted)v *= (1.0 - viscosity/10000)vy -= GRAVITY * dtvx += wind * WIND_SCALE * dt (horizontal only)| Type | Behavior |
|---|---|
| None | Projectiles fly offscreen |
| Wrap | Left edge wraps to right |
| Padded | Reflect with 0.5× velocity |
| Rubber | Reflect with 0.8× velocity |
| Spring | Reflect with 1.2× velocity |
| Concrete | Detonate on wall impact |
| Random | Resolved once per round |
| Erratic | Resolved each turn |
Pure pixel-color sampling from the framebuffer — no geometry calculations. Each physics step calls getPixel(x, y) and checks: >0 && <80 = tank hit, ≥105 = terrain hit, anything else = sky (pass through).
57 weapons extracted from the EXE's struct array at file offset 0x056F76. Each weapon has a 52-byte struct with price, bundle quantity, arms level, behavior type code, and blast radius.
13 behavior handlers, dispatched by BHV type code (matching the EXE's far call through weapon struct pointers):
| BHV | Handler | Weapons |
|---|---|---|
| 0x0021 | Standard — simple radius blast | Baby Missile, Missile, Baby Nuke, Nuke |
| 0x0002 | Tracer — no damage, shows path | Tracer, Smoke Tracer |
| 0x0003 | Roller — flight then terrain-follow | Baby Roller, Roller, Heavy Roller |
| 0x0006 | Bounce — LeapFrog reflections | LeapFrog |
| 0x0239 | MIRV — split at apogee (vy sign flip) | MIRV, Death's Head |
| 0x01A0 | Napalm — fire particle spread | Napalm, Hot Napalm, Ton of Dirt |
| 0x0009 | Dirt — add terrain (inverse crater) | Heavy Sandhog, Dirt Clod, Dirt Ball, Dirt Tower |
| 0x000A | Tunnel — dig through terrain | Diggers, Sandhogs, Heavy Riot Bomb |
| 0x000D | Plasma — speed-based variable radius | Plasma Blast, Riot Charge |
| 0x03BD | Riot — earth-moving explosion | Riot Blast, Riot Bomb |
| 0x0004 | Disrupter — force suspended dirt to fall | Earth Disrupter |
| 0x0081 | Liquid — napalm-style dirt spread | Liquid Dirt |
| 0x013E | Dirt Charge — explosion + dirt fill | Dirt Charge |
Special cases: Funky Bomb (BHV=0x0000 but handler segment 0x1DCE) scatters 5–10 sub-bombs from screen top. Popcorn Bomb has no struct data at all.
7 AI types from Moron (wildly inaccurate) to Spoiler (strategically chaotic). Cyborg and Unknown randomize to types 1–6 each turn.
The web AI uses iterative trajectory simulation: for each candidate angle (coarse sweep, then fine-tune), simulate the trajectory accounting for gravity and wind, and pick the angle/power combo that lands closest to the target.
Instead of uniform random noise, the AI uses multi-harmonic sine waves to produce smooth, repeatable aim wobble. The original EXE generates 2–5 harmonics with rejection-sampled frequencies:
noise = amplitude * (sin(t*3.7)*0.5 + sin(t*7.3)*0.3 + sin(t*13.1)*0.2) * 0.15
| AI Type | Angle Noise | Power Noise | Weapon Noise | Character |
|---|---|---|---|---|
| Moron | 50 | 50 | 50 | Wildly inaccurate |
| Shooter | 23 | 23 | 23 | Accurate marksman |
| Poolshark | 23 | 23 | 23 | Accurate (same as Shooter) |
| Tosser | 63 | 23 | 23 | Wild angle, good power |
| Chooser | 63 | 63 | 23 | Wild aim, smart weapons |
| Spoiler | 63 | 63 | 63 | Maximum chaos |
256-color palette matching the original VGA DAC layout. Each entry is 6-bit RGB (0–63), upscaled to 8-bit for display.
| Range | Entries | Purpose |
|---|---|---|
| 0–79 | 80 | Player colors (10 players × 8 gradient slots: dark→light body, full color, white flash, smoke) |
| 80–103 | 24 | Sky gradient (7 types: Plain, Shaded, Stars, Storm, Sunset, Cavern, Black) |
| 104 | 1 | System black (HUD background) |
| 105–119 | 15 | Unused |
| 120–149 | 30 | Terrain gradient (6 types: Blue Ice, Snow, Rock, Night, Desert, Varied) |
| 150 | 1 | Wall color (gray) |
| 170–179 | 10 | Explosion: Dark Red fire |
| 180–189 | 10 | Explosion: Orange fire |
| 190–199 | 10 | Explosion: Yellow fire |
| 253 | 1 | Laser sight green |
| 254 | 1 | Laser sight white (Plasma) |
The 10 player base colors (VGA 6-bit, from DS:0x57E2): Red, Lime Green, Purple, Yellow, Cyan, Magenta, White, Orange, Sea Green, Blue.
PC speaker emulation via Web Audio API. The original game programs the PC speaker timer (port 42h) for square wave tones. The web version uses OscillatorNode with frequency sweeps:
| Sound | Start Hz | End Hz | Duration | EXE Source |
|---|---|---|---|---|
| Fire | 400 | 800 | 0.15s | INT 61h timer programming |
| Explosion | 200 | 40 | 0.1–0.8s (scales with radius) | Port 42h sweep |
| Flight proximity | 1000+ | varies | 0.03s per frame | Distance-based pitch |
| Lightning | 2000 | 200 | 0.1s | Hostile environment |
| Tank death | 80 | 20 | 0.3s | Low frequency thud |
All sounds use square wave oscillators (type: 'square') to match the harsh timbre of the original PC speaker. Audio context is lazy-initialized on first user gesture to comply with browser autoplay policies.