How RomM Achieves In-Browser Emulator Integration with EmulatorJS and RuffleRS
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 (lines 35‑42), RomM defines three distinct emulator paths:
{
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) 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 (lines 55‑60), the heavy v1 Player component loads only when needed:
// 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 (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 (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():
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 (lines 76‑84), the component inserts a script tag pointing to the bundled version first:
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:
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) 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). 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 (lines 30‑60), a heartbeat emits every 30 seconds:
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
defineAsyncComponentfor 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
onerrorhandlers to switch to unpkg.com. - Unified data layer: The
getDownloadPathutility generates signed URLs for ROM access, whileusePlatformPlayabledetermines the appropriate emulator based on metadata. - Lifecycle management: Socket.IO heartbeats track active sessions, and
onBeforeUnmounthandlers 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) 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 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 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.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →