Play it at smashtv.atomic14.com — on a desktop, with a gamepad, or on your phone.
A port of the 1990 arcade game Smash T.V. to the browser, written in TypeScript with Vite. It is not an emulator: the game logic has been rewritten from scratch, while the graphics, sound, music, level layouts and enemy waves are read straight out of the original arcade ROMs.
Smash T.V. was created by Eugene Jarvis and Mark Turmell and released by Williams Electronics Games, Inc. in 1990. Everything that makes it Smash T.V. — the characters, artwork, music, sound effects, speech, level design and the game itself — is their work. The Williams/Midway catalogue, including Smash T.V., is now owned by Warner Bros.
This is a non-commercial fan project made out of admiration for the original. It is not affiliated with or endorsed by Williams, Midway or Warner Bros. All trademarks and copyrights in Smash T.V. belong to their respective owners. If you enjoy this, play the real thing.
The port leans heavily on:
- MAME and its developers. MAME's
midyunitdriver documents the Williams Y-unit hardware, and MAME's Lua scripting and debugger made it possible to watch the original game run: its blitter, palette, sound commands, process lists and RAM. - Whisper (via faster-whisper) for working out which speech clip is which.
- Vite and TypeScript.
| Move | Fire | Start / pause | |
|---|---|---|---|
| Keyboard | WASD | Arrow keys (or IJKL) | Enter / P or Esc (M mutes) |
| Gamepad | Left stick / d-pad | Right stick, or face buttons | Start |
| Touch | Left thumbstick | Right thumbstick | Tap |
CPU player 2: Smash T.V. is hard alone, so by default a computer-controlled player 2 (the red contestant, in the original's player 2 palette) fights alongside you. It stays near you, dodges crowds, mines and enemy fire, grabs pickups when it is safe, and shoots whatever threatens you most. It has no lives to lose: when it dies it rejoins a few seconds later. Press 2 on the title screen (or Select on a gamepad) to switch it off or on.
On phones, portrait shows the screen on top with the thumbsticks below; landscape puts the sticks beside the screen. The first tap goes fullscreen where the browser allows it (Android); on iPhone, use Share → Add to Home Screen to play fullscreen.
The aim was a faithful port without running an emulator in the page, so the original ROM set is treated as two things: a data source (art, sound, levels, waves), and, run under MAME, a reference to measure the new code against.
- Graphics. The Y-unit has no sprite hardware. A DMA blitter copies images out of the
graphics ROMs, and the program ROM holds an image header for each one (size, anchor,
graphics address, palette).
tools/extract.tsscans the program ROM for those headers, decodes every image with its palette into a sprite atlas, andtools/catalog.tsnames them: player legs and torsos, enemy direction sets, bosses, fonts, pickups. - Arenas. Each room background is assembled at run time from blocks, so the room builder's code was disassembled to recover how blocks, flips and palettes combine. Every arena, the TV studio and the title screen render pixel-for-pixel from the ROM.
- Sound. The sound board is driven by one-byte commands. A MAME script plays each command on the sound board alone and records it, giving every effect, speech clip and music track (with loop points found by analysis). Whisper put names to the speech.
- Game logic. Movement, shooting, collisions, pickups, the HUD and the room flow were written fresh in TypeScript, at a fixed 60 Hz step.
- World maps. Tables in the ROM give each world's grid of arenas and the corridors
between them, so the branching routes,
$rooms and world 3's gauntlet are the original's, along with the map screens. - Bosses. A MAME bot plays the original with its lives pinned, walks the room graph to each boss and records every blitter command during the fight. Mutoid Man, Scarface, Cobra Death and the host were rebuilt from those recordings: how their mirrored parts fit together, their attacks and their damage states.
- Waves. Following the code that spawns grunts led to the room director and its per-room tables. Every room's waves come from the ROM: which enemy types, how many, when each wave starts, how often it spawns, how many may be alive at once, grunt speed tiers, squads through a door, and when the exits open.
- Behaviour. A second recorder logs every game process and its object (position,
velocity, image) every frame while the bot plays all three worlds. That identified each
wave type's enemy and measured how it behaves, which corrected a lot of guesswork:
- the arcade runs at 54.7 Hz, not 60, so every speed and timer from the ROM is converted at that rate;
- grunts commit to a heading for ~0.8s (often several seconds) instead of homing in every frame, and some wait in the doorway first;
- Mr. Shrapnel wanders, turns slowly towards the player, and only bursts into a fan of shrapnel when lined up (velocity tables read from the ROM);
- tanks sit still most of the time and drive in short bursts;
- the player's gun fires every 8 frames at 10 px/frame;
- the continue screen plays "No way!" and its sting, from a log of sound commands.
Still approximate: some enemy details (how often wall guns and tanks fire, the orb swarm's exact motion), power-up durations, scoring, and boss timings and hit points. Player 2 is computer-controlled only; there is no local two-player mode yet.
The data extracted from the ROMs is checked in under public/assets/, so playing and
building only need Node:
npm install
npm run dev # http://localhost:5173 (also on your LAN)
npm run build # static site in dist/To regenerate the data from the arcade ROM set (smashtv.zip, as used by MAME):
npm run extract -- /path/to/smashtv.zip # graphics, arenas, maps and waves
tools/record-audio.sh /path/to/rom-dir # sound: needs mame, ffmpeg and uvPython tooling runs through uv.
The hardware is the Williams Y-unit: a TMS34010 CPU (bit-addressed) and a DMA blitter. Addresses below are TMS34010 bit addresses.
- Graphics ROM: three groups of chips (u111–113, u95–97, u106–108) each give 2 bits of
a 6-bit pixel, four pixels per byte. Address =
(bitaddr − 0x02000000) >> 3, rows padded to a multiple of 4 pixels. - Image headers (program ROM):
w, h, xoff, yoff, gfx_addr, palette_ptr(16 bytes), or 12 bytes without the palette for the player parts, which use the fixed system palette atFFFC6160. Sprites are drawn atleft = x − xoff,top = y − yoff, mirrored asx − (w − 1 − xoff). - Palettes: a u16 count followed by xRRRRRGGGGGBBBBB words.
- Backgrounds: room descriptors (
w, h, count, layout*, block_table*, palette_list*). Layout records areflags, x, y, block. The palette islist[(flags & 0xF) + 16·(block >> 12)],0x10/0x20are flips,0x40means transparent and the high byte is draw priority (room builder atFFE0CD00). The floor colour is the autoerase colour (palette entry 0x3D). - Animations / directions: lists of header pointers, e.g. the grunt direction table at
FFE4DD00and the player leg cycles atFFE24A00. - Fonts: ASCII tables at
FFE64630(two fonts, 93 glyphs each). - World maps:
FFE72650points at one list per world of(room descriptor, x, y). Arenas sit on a ~472×332 grid; corridor pieces (FFF6C720horizontal,FFF6C690vertical,FFF6C7B0from the studio) say which rooms connect. RAM: current list010DD4E0, camera01000240/260, lives01086360. - Bosses: each boss's parts share one anchor and are drawn as mirrored pairs. Cobra Death and Scarface mirror their right half 5 px left of the generic flip rule, the host's torso 1 px, and Mutoid Man's chassis not at all.
- Rooms and waves: rooms are numbered per world (RAM
010EB630); the records atFFECCE80[world]give each room's exits, and so its grid cell. The room director (FFE18150) reads 6-word entries fromFFE13790[world]:[type, count, p1, interval, p2, delay], timed in 3-frame ticks. Types with an init routine (FFE13380) createp2at room start; each slot then counts its delay down and calls the type's spawn routine (FFE13580). Per-room byte tables cap how many of a kind may be alive (minus a margin for one player, plus a difficulty bonus: −1 at the default setting). Grunts walk at0.5 + tier/8px/frame (1/16 slower vertically, 0.8x on diagonals), and 14% of grunt spawns are a squad of 7–16 through one door. When a room's main wave is in, the game waits 120 frames, stops the other spawners, and opens the exits once the enemies are dead.tools/waves.tsdecodes this intoworlds.json. - Processes and objects: the active process list is at
01000000(process:+0link,+20id,+C0object) and the gameplay objects are in the lists at01000040/01000060(object:+20/+40velocity and+60/+80position, both 16.16,+130image header). Ids seen: gruntEC, tankEF, Mr. ShrapnelF0, saucerEE, crab114, wall gun10A, floor mine100, orbs116/900/902/903, snake11C. - Sound: the main CPU writes
0xFD00|cmdthen0xFF00|cmdto01E00000. Commands01–10are music,11–2E/60–7Eare volume, and the rest are effects and speech.
tools/extract.ts: buildspublic/assets/from the ROM zip (sprites, anims, rooms, worlds with waves).tools/mame/sndrec.lua,tools/record-audio.sh,tools/cut-audio.ts,tools/music-loops.py: the sound capture pipeline.tools/mame/bossrun.lua: plays to each boss and records the fights.tools/mame/waverun.lua: plays every reachable room and records processes and objects (TRACK=1for per-frame tracks);PROBE=loads one-off scripts such astools/explore/sndlog.lua.tools/explore/procs.ts,behaviour.ts: summarise those recordings per enemy type.tools/contact-sheet.ts,tools/replay.ts, and the rest oftools/explore/: one-off ROM probes and viewers.
