vohzd.com

Flash games in 2026, courtesy of Ruffle

7 min read

A folder of SWF files has followed me between machines since about 2003. Emogame, Gonads and Strife, the usual early-2000s haul. Every so often I try to open one and remember the runtime they were built for no longer exists. This week I put them back on the site.

Why an SWF is a brick now

Adobe ended Flash Player at the end of 2020 and then shipped a build that refuses to play content. The browsers had already removed the plumbing. NPAPI went from Chrome back in 2015, Flash limped on there through PPAPI until Chrome dropped that too, and Firefox removed plugin support outright. No extension point is left to hook a player into, which was deliberate.

That leaves the file itself. An SWF is not a document the browser can fall back to rendering badly. It is a binary tag stream: a short header, then length-prefixed tags defining shapes, sprites, sounds, fonts and compiled ActionScript bytecode. Nothing in a browser knows what a DefineShape4 tag is. Point an <img> or an <object> at one today and you get nothing.

The two files I committed are old enough to be interesting. Both start with 46 57 53, FWS, so they are uncompressed rather than the zlib CWS or LZMA ZWS variants. The fourth byte is the SWF version: 5 for Emogame, 4 for Gonads. Both are pre-ActionScript 3, which matters in a minute.

Ruffle

Ruffle is a Flash Player reimplementation written in Rust and compiled to WebAssembly. It is not a converter and does not decompile anything. It reads the SWF tag stream and executes it, the way the real player did.

You can see its shape in the symbol table of the wasm binary. There is ruffle_core::avm1 and ruffle_core::avm2, the two ActionScript virtual machines, with AVM1 carrying globals such as system_capabilities::get_has_mp3 and external_interface::get_available. There is a backend layer, ruffle_core::backend::{audio, render, navigator, storage, ui, video, locale, log}, which is how one core targets a browser and a desktop build. Drawing is ruffle_render_webgl and ruffle_render_canvas over lyon_tessellation, lyon_path and lyon_geom, because Flash shapes are filled vector paths and WebGL wants triangles. Audio comes out through a WebAudioBackend on a normal AudioContext.

AVM1 was the part furthest along, and both my files are AVM1. That is most of why this worked first try.

Vendoring the build

Ruffle ships a selfhosted package for sites with no bundler. I took that route and committed the built files into public/:

public/ruffle.js                        82,659 bytes
public/ruffle.js.map                   290,210 bytes
public/c7941aaf14109c45bb0b.wasm     5,866,343 bytes
public/LICENSE_APACHE
public/LICENSE_MIT
public/README.md

No npm dependency, no build step, no bundler that has to learn what a .wasm import means. The tradeoff is a version frozen at whatever I downloaded. The bundle tells on itself: an About entry reading nightly 2021-09-12, and a debug block with Version: 0.1.0, Channel: nightly, Built: 2021-09-12T00:12:14.036Z. A five year old nightly. It works, but I should not pretend it is current.

The interesting trick is how the wasm gets found. The loader reads its own script tag at parse time. Unminified, that is:

let B = "";
try {
  if (document.currentScript != null && "src" in document.currentScript && document.currentScript.src !== "") {
    B = new URL(".", document.currentScript.src).href;
  }
} catch (e) {
  console.warn("Unable to get currentScript URL");
}

So the base path comes from wherever ruffle.js was served, and the hashed wasm resolves next to it. Serving both out of public/ is all the configuration needed. The vendored README warns that the .wasm has to be served properly, that some servers get that wrong out of the box, and that you will see an Incorrect response MIME type error when they do. Nitro handles the public directory itself and the app runs under the bun preset from .output/server/index.mjs, so there was nothing to configure.

Wiring it into Nuxt

Auto-imports are off in nuxt.config.ts (imports: { autoImport: false }), and app.head.script holds only the analytics tag, so each page loads Ruffle itself. Here is emogame.vue, minus the template:

import { onMounted } from "vue";

function init() {
  const ruffle = (window as any).RufflePlayer.newest();
  const player = ruffle.createPlayer();
  player.config.backgroundColor = "#05020a";
  const container = document.getElementById("emogame");
  container?.appendChild(player);
  player.load("/Emogame1.swf");
  player.style.width = "100vw";
  player.style.height = "calc(100vh - 64px)";
  player.style.top = "64px";
}

onMounted(() => {
  const script = document.createElement("script");
  script.src = "/ruffle.js";
  script.onload = () => { init(); };
  document.getElementsByTagName("head")[0].appendChild(script);
});

newest() picks the highest-versioned source out of Ruffle's version registry, which exists so a browser extension and a self-hosted copy can coexist. It compares semver precedence, not registration order. createPlayer() defines a ruffle-player custom element and returns an instance, and that element attaches an open shadow root holding a #container, a #play_button and an #unmute_overlay. load() takes a config object or a plain string, which it normalises to { url }. Everything sits in onMounted, so server rendering is untouched.

One side effect: loading the script installs a fake plugin. The negotiation step calls pluginPolyfill(), which redefines navigator.plugins and navigator.mimeTypes so anything sniffing for application/x-shockwave-flash finds a plugin describing itself as Shockwave Flash 32.0 r0. Old content that checks before it runs is satisfied.

The snags

Autoplay is the first one. No page of mine sets config.autoplay, so all four get the default auto, and the auto branch only calls play() if the AudioContext is already running. On a cold load it is suspended, so Ruffle takes the other branch and shows its play button. The unmute overlay is the forced on path: play() runs, the context is still suspended, so the overlay appears with a one-shot click handler on the container, and unmuteOverlayClicked resumes and hides it. Either way the first thing a visitor sees is a Ruffle control over a stopped game, and I have not written a word anywhere explaining that.

Sizing is the second, and here my code is wrong rather than awkward. The :host rule in the shadow root is display: inline-block; position: relative; width: 550px; height: 400px, with a comment saying user styles will override it. On Emogame and Gonads mine do, but with stale numbers. top: 64px and calc(100vh - 64px) were written for a 64px top bar. The site has a 280px fixed sidebar now, dropping to a 128px bar below 1300px, so the offset lines up with nothing. Emogame 2 and 3 set no styles at all, so there the default stands. The container class is stale either way:

.flash-element {
  width: calc(100vw - 280px);
  left: 280px;
  top: 0;
  height: 100vh;
}

The 280px matches the sidebar, which is promising, except the element is position: static, so left and top do nothing. On Emogame and Gonads the div ends up narrower than the 100vw player it holds. On the other two it is the reverse, a 550 by 400 player in the corner of a box most of the screen wide. Ruffle itself scales the movie with showAll, the Flash default, so the game letterboxes correctly inside whatever box it gets. My boxes are the wrong shape.

Third, the script is injected on every mount with no check for an existing tag. Navigate from Emogame to Gonads without a full reload and a second copy of the bundle runs. It survives: the registration helper finds ruffle-player already defined and falls back to ruffle-player-1, then -2, and re-registering the source keeps newest() working. Still two head tags and two module instances for no reason.

The part I got wrong

emogame2.vue and emogame3.vue call player.load("/Emogame2.swf") and player.load("/Emogame3.swf"). Neither is in the repository. Only Emogame1.swf and Gonads.swf are committed. Both are linked from the index, both fetch a URL that resolves to nothing, and Ruffle does what it should: the failure is flagged as a SWF fetch error and the player replaces itself with the panic box reading Something went wrong :( and a View Error Details link. Two of the four pages are broken and I would rather say so than quietly unlink them.

That is also the list of what needs doing. Find the missing files or drop the pages. Move the loader into one composable that injects the script once and returns a player. Replace the 64px numbers with the layout the site actually has. Decide whether a 5.6 MiB wasm download deserves caching. Refresh the nightly, and stop shipping a 290 KB source map while I am at it.

More of the archive is on its way.