# How RomM Achieves In-Browser Emulator Integration with EmulatorJS and RuffleRS

> Discover how RomM seamlessly integrates EmulatorJS and RuffleRS in-browser. Learn about lazy-loading scripts and Vue 3 shells for efficient ROM management and save states.

- Repository: [The RomM Project/romm](https://github.com/rommapp/romm)
- Tags: deep-dive
- Published: 2026-07-06

---

**RomM delivers seamless in-browser emulator integration by lazy-loading EmulatorJS and RuffleRS scripts on demand, wrapping them in Vue 3 shells that handle ROM loading, save states, and fullscreen management without bloating the initial bundle.**

RomM (rommapp/romm) enables users to launch classic games directly in the browser by embedding two open-source emulators: **EmulatorJS** for console and DOS titles and **RuffleRS** for Flash content. The architecture relies on a lazy-loading pattern that keeps heavy emulator code out of the initial JavaScript bundle, injecting it only when a user initiates gameplay. This approach minimizes initial page load times while providing a full-featured gaming experience through a thin Vue 3 interface.

## Routing and Platform Selection

The integration begins in the Vue Router configuration, which maps dedicated player routes to lazy-loaded components. In [`frontend/src/v2/router/routes.ts`](https://github.com/rommapp/romm/blob/main/frontend/src/v2/router/routes.ts) (lines 35‑42), RomM defines three distinct emulator paths:

```typescript
{
  path: "rom/:rom/emulatorjs",
  name: ROUTES.EMULATORJS,
  component: () => import("@/v2/views/Player/EmulatorJS.vue"),
},
{
  path: "rom/:rom/ruffle",
  name: ROUTES.RUFFLE,
  component: () => import("@/v2/views/Player/Ruffle.vue"),
},

```

The `usePlatformPlayable` composable ([`frontend/src/v2/composables/usePlatformPlayable/index.ts`](https://github.com/rommapp/romm/blob/main/frontend/src/v2/composables/usePlatformPlayable/index.ts)) inspects ROM metadata to determine the correct platform string. This logic ensures that clicking **Play** routes the user to the appropriate emulator view based on the file extension or platform ID stored in the database.

## Lazy Loading Architecture

Both player shells avoid eager imports to prevent the main application bundle from growing unnecessarily large. Instead, they use `defineAsyncComponent` to defer loading until the player mounts.

In [`frontend/src/v2/views/Player/EmulatorJS.vue`](https://github.com/rommapp/romm/blob/main/frontend/src/v2/views/Player/EmulatorJS.vue) (lines 55‑60), the heavy v1 Player component loads only when needed:

```typescript
// Lazy so the bundle doesn't pull in the EJS shims until we actually mount the player.
const Player = defineAsyncComponent(
  () => import("@/views/Player/EmulatorJS/Player.vue"),
);

```

The same pattern appears in [`Ruffle.vue`](https://github.com/rommapp/romm/blob/main/Ruffle.vue) (lines 55‑60), ensuring that emulator-specific dependencies remain isolated from the main application loop until a user explicitly requests them.

## Loading EmulatorJS at Runtime

### Script Injection and Fallback Logic

When the user clicks **Play**, the `onPlay()` method in [`frontend/src/v2/views/Player/EmulatorJS.vue`](https://github.com/rommapp/romm/blob/main/frontend/src/v2/views/Player/EmulatorJS.vue) (lines 46‑86) executes a robust loading sequence. The system first attempts to load a local copy of the emulator, then falls back to the official CDN if the local resource fails.

The `attemptLoad()` function validates the target path by checking if the returned content is valid JavaScript via `isJsResource()`:

```typescript
async function isJsResource(url: string): Promise<boolean> { … }
async function attemptLoad(path: string) {
  const loaderUrl = `${path}/loader.js`;
  if (!(await isJsResource(loaderUrl))) {
    throw new Error(`Loader at ${loaderUrl} did not return JavaScript`);
  }
  window.EJS_pathtodata = path;
  await loadScript(loaderUrl);
}

```

If the local loader fails, the code automatically retries using the CDN path at `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data` (lines 89‑93). After successful injection, the browser exposes the `window.EJS_pathtodata` global variable, which the emulator uses to locate its data files.

### Play Handler Implementation

Once the script loads, the shell mounts the lazy-loaded `Player` component and passes the ROM, selected save/state, core, disc, and BIOS as props. This architecture keeps the UI shell lightweight while delegating complex emulation logic to the v1 Player wrapper.

## Loading RuffleRS at Runtime

### Script Initialization

RuffleRS follows a similar pattern but with a different CDN strategy. In [`frontend/src/v2/views/Player/Ruffle.vue`](https://github.com/rommapp/romm/blob/main/frontend/src/v2/views/Player/Ruffle.vue) (lines 76‑84), the component inserts a script tag pointing to the bundled version first:

```typescript
const script = document.createElement("script");
script.src = "/assets/ruffle/ruffle.js";
script.onerror = () => {
  const fallback = document.createElement("script");
  fallback.src = `https://unpkg.com/@ruffle-rs/ruffle@${RUFFLE_VERSION}/ruffle.js`;
  document.body.appendChild(fallback);
};
document.body.appendChild(script);

```

If the local asset 404s, the `onerror` handler immediately injects the nightly build from unpkg.com, ensuring maximum compatibility even if the bundled version becomes outdated.

### Player Instantiation

When the user triggers gameplay, the `onPlay()` method (lines 13‑30) creates the Ruffle player instance:

```typescript
function onPlay() {
  gameRunning.value = true;
  nextTick(() => {
    const ruffle = window.RufflePlayer.newest();
    const player = ruffle.createPlayer();
    document.getElementById('r-v2-ruffle-stage')?.appendChild(player);
    player.load({ 
      url: getDownloadPath({ rom }), 
      backgroundColor: backgroundColor.value 
    });
    if (player.fullscreenEnabled && fullscreenOnPlay.value) player.enterFullscreen();
  });
}

```

The helper `getDownloadPath` (from [`frontend/src/utils/index.ts`](https://github.com/rommapp/romm/blob/main/frontend/src/utils/index.ts)) constructs a signed download URL that authenticates the request with the backend's static file service.

## ROM Data Flow and State Management

Both shells retrieve ROM metadata via the backend API (`romApi.getRom`) and store it in a reactive `ref`. 

- **EmulatorJS** receives the ROM binary, optional BIOS files, and selected save states through props passed to the v1 Player component.
- **Ruffle** passes the SWF URL directly to `player.load()` along with UI configuration options like background color and fullscreen preferences.

This unified data flow ensures that emulator-specific implementations remain isolated while sharing a common interface for ROM retrieval and authentication.

## Fullscreen and Activity Tracking

RomM centralizes user preferences through the `useFullscreenPref` composable ([`frontend/src/v2/composables/useFullscreenPref.ts`](https://github.com/rommapp/romm/blob/main/frontend/src/v2/composables/useFullscreenPref.ts)). Both emulators respect the **fullscreen-on-play** setting, with EmulatorJS delegating to the emulator's internal fullscreen API and Ruffle calling `player.enterFullscreen()`.

Additionally, EmulatorJS integrates with the backend via Socket.IO to report active gameplay sessions. In [`frontend/src/v2/views/Player/EmulatorJS.vue`](https://github.com/rommapp/romm/blob/main/frontend/src/v2/views/Player/EmulatorJS.vue) (lines 30‑60), a heartbeat emits every 30 seconds:

```typescript
function emitActivityStart() { … }
function startActivityHeartbeat() { … }
watch(gameRunning, (running, prev) => {
  if (running && !prev) { emitActivityStart(); startActivityHeartbeat(); }
  if (prev && !running) { stopActivityHeartbeat(); emitActivityStop(); }
});

```

This allows the RomM dashboard to display "now playing" status indicators for active emulation sessions.

## Cleanup and Resource Management

When the user navigates away from the player view, `onBeforeUnmount` hooks in both shells perform thorough cleanup. The EmulatorJS shell stops the activity heartbeat, signals the emulator to exit, and removes injected scripts (lines 69‑80). The Ruffle shell (lines 74‑79) similarly destroys the player instance and clears the DOM container to prevent memory leaks in long-running single-page application sessions.

## Summary

- **Route-based architecture**: RomM uses dedicated routes (`/emulatorjs`, `/ruffle`) mapped to lazy-loaded Vue components to isolate emulator logic.
- **On-demand loading**: Heavy emulator scripts load only when users click **Play**, using `defineAsyncComponent` for the UI shell and dynamic `<script>` injection for the engine.
- **Automatic fallback**: Both emulators implement CDN fallback logic—EmulatorJS validates JavaScript content before execution, while Ruffle uses `onerror` handlers to switch to unpkg.com.
- **Unified data layer**: The `getDownloadPath` utility generates signed URLs for ROM access, while `usePlatformPlayable` determines the appropriate emulator based on metadata.
- **Lifecycle management**: Socket.IO heartbeats track active sessions, and `onBeforeUnmount` handlers ensure proper cleanup of scripts, event listeners, and DOM elements.

## Frequently Asked Questions

### How does RomM decide which emulator to use for a specific ROM?

The `usePlatformPlayable` composable ([`frontend/src/v2/composables/usePlatformPlayable/index.ts`](https://github.com/rommapp/romm/blob/main/frontend/src/v2/composables/usePlatformPlayable/index.ts)) inspects the ROM's platform metadata and file extension to return the appropriate platform string. The UI then generates navigation links to either the `/emulatorjs` or `/ruffle` route, ensuring that DOS and console games load in EmulatorJS while Flash content routes to RuffleRS.

### What happens if the local emulator script fails to load?

Both implementations include automatic CDN fallback mechanisms. For EmulatorJS, the `attemptLoad()` function validates the local [`loader.js`](https://github.com/rommapp/romm/blob/main/loader.js) via `isJsResource()`, and if validation fails, the system switches to `https://cdn.emulatorjs.org/`. RuffleRS uses an `onerror` handler on the script tag to immediately inject the nightly build from `unpkg.com/@ruffle-rs/ruffle` if the local [`/assets/ruffle/ruffle.js`](https://github.com/rommapp/romm/blob/main//assets/ruffle/ruffle.js) returns a 404.

### Does RomM include emulator code in the initial JavaScript bundle?

No. RomM uses `defineAsyncComponent()` to lazy-load the heavy Player components only when the user navigates to a game page. The emulator engine scripts (EmulatorJS and Ruffle) are not bundled with the application at all—they are injected as external `<script>` tags at runtime, keeping the initial bundle size small and improving Time-to-Interactive metrics.

### How does RomM handle save states in the browser emulator?

For EmulatorJS, the Vue 3 shell exposes UI controls for save and state selection that bind to reactive refs. These values pass as props to the v1 Player component, which interfaces with the emulator's native state management APIs. RuffleRS handles save data internally through its Flash compatibility layer, while RomM's UI provides the fullscreen and background color configuration options before the game initializes.