How RomM Integrates EmulatorJS for In-Browser Playback: A Technical Deep Dive

RomM embeds EmulatorJS by setting global window.EJS_* properties that the emulator reads on startup, then orchestrates the runtime through a Vue component that handles save states, custom UI controls, and iOS compatibility shims.

RomM is an open-source ROM management platform that enables browser-based emulation through deep integration with EmulatorJS. This article examines the rommapp/romm codebase to reveal how the frontend Vue application bootstraps the emulator, manages virtual file systems, and synchronizes save data between the client and server.

Global Configuration via Window Globals

Before the EmulatorJS script executes, RomM configures the environment by declaring a series of global properties on the window object. The Player.vue component defines these values during the onMounted lifecycle hook, populating the emulator’s core settings, ROM URLs, BIOS paths, and UI preferences.

In frontend/src/views/Player/EmulatorJS/Player.vue (lines 28-89), the component assigns:

  • window.EJS_core – The selected emulation core (e.g., "snes9x", "mgba")
  • window.EJS_gameUrl – The API endpoint serving the ROM content
  • window.EJS_biosUrl – The firmware/BIOS file URL for consoles requiring it
  • window.EJS_threads – Boolean flag indicating threading support
  • window.EJS_language – UI localization string (e.g., "en-US")

These globals act as the contract between RomM and EmulatorJS, allowing the emulator to locate resources without direct API coupling.

Vue Component Runtime Orchestration

The Player.vue component serves as the runtime orchestrator, managing the emulator lifecycle from bootstrap to teardown. It handles script injection, event listener registration, and callback wiring for save states and net-play features.

Bootstrap and Lifecycle Hooks

When the component mounts, it stores user preferences (BIOS and core selections) in localStorage and registers event listeners for save management. The critical integration point is the window.EJS_onGameStart callback (lines 79-96), which fires when the emulator finishes initializing.

Inside this callback, RomM executes an asynchronous bootstrap sequence:

  1. Waits for the gameManager object to become available via waitForGameManager
  2. Injects any server-provided save files or save states using loadSave and loadState
  3. Initializes the custom UI button bar

Save State Synchronization

RomM overrides the default EmulatorJS save behavior by implementing window.EJS_onSaveSave and window.EJS_onLoadSave. These callbacks intercept the emulator’s binary save data and forward it to the backend API.

The flow works as follows:

  • Saving: When the user triggers a save, EJS_onSaveSave receives the binary data and calls RomM’s saveSave utility, which POSTs the data to the server
  • Loading: When selecting a save slot, EJS_onLoadSave retrieves the data from the server and injects it into the emulator’s virtual file system

Custom UI Controls

RomM injects custom buttons into the EmulatorJS menu bar to provide platform-specific features:

  • createQuickLoadButton – Adds a quick-load button that bypasses the standard menu
  • createExitEmulationButton – Overrides the default exit behavior to properly clean up resources
  • createSaveQuitButton – Combines save-and-quit functionality for streamlined session management

These controls are defined in utils.ts and appended to the emulator’s UI during the onGameStart phase.

Utility Helpers for File System and Compatibility

All low-level interactions with the EmulatorJS virtual file system reside in frontend/src/views/Player/EmulatorJS/utils.ts. This module abstracts the complexity of IndexedDB cache management, file system writes, and mobile browser compatibility.

Virtual File System Operations

The loadEmulatorJSSave function (lines 38-51) handles injecting external save files into the running emulator:

import { loadEmulatorJSSave } from '@/views/Player/EmulatorJS/utils';

// Fetch save data from RomM backend
const saveData = await fetch(saveUrl).then(r => r.arrayBuffer());

// Write into EJS virtual FS and trigger load
loadEmulatorJSSave(new Uint8Array(saveData));

This utility writes the data to the emulator’s virtual path using FS.writeFile, then invokes gameManager.loadSaveFiles() to make the emulator aware of the new state.

Similarly, loadEmulatorJSState manages save states (snapshots of runtime memory) using gameManager.loadState().

Cache Management and iOS Shims

RomM implements two critical compatibility layers:

IndexedDB Cache Invalidation: The invalidateEmulatorJSRomCacheIfRenamed function (lines 118-169) clears the browser’s IndexedDB cache whenever a ROM’s filename changes, preventing the emulator from loading stale data.

iOS Fullscreen Support: The installIOSFullscreenShim function (lines 171-238) patches the browser’s fullscreen API on iOS devices, which lack native fullscreen support. This shim returns a cleanup function that restores original behavior when the player exits:

import { installIOSFullscreenShim } from '@/views/Player/EmulatorJS/utils';

const removeShim = installIOSFullscreenShim();
// ... emulation runs ...
removeShim(); // Restore original fullscreen behavior on exit

Step-by-Step Integration Flow

The complete RomM EmulatorJS integration follows this execution path:

  1. Router Resolution – frontend/src/v2/router/routes.ts maps /play/:romId to the EmulatorJS.vue wrapper component
  2. Global Setup – Player.vue sets window.EJS_* globals with core selection and download URLs
  3. Script Injection – The browser loads EmulatorJS.js (referenced in index.html), which reads the globals
  4. Game Boot – Emulator downloads the ROM and fires EJS_onGameStart
  5. State Injection – RomM waits for gameManager availability, then injects any existing saves via loadEmulatorJSSave
  6. User Interaction – Custom buttons handle quick-load, exit, and save-and-quit operations
  7. Data Persistence – Save events trigger saveState and saveSave to sync with the RomM backend
  8. Cache Maintenance – Stale IndexedDB entries are cleared on ROM rename; iOS shims are removed on component unmount

Summary

  • RomM configures EmulatorJS through global window.EJS_* properties set in Player.vue before the emulator script loads
  • The Vue component orchestrates the runtime via lifecycle hooks and callbacks like EJS_onGameStart and EJS_onSaveSave
  • Utility functions in utils.ts handle virtual file system operations, save state injection, and IndexedDB cache management
  • iOS compatibility is achieved through a fullscreen shim that patches browser APIs during the emulation session
  • Save data flows bidirectionally between the emulator’s virtual file system and RomM’s backend API through typedArray buffers

Frequently Asked Questions

How does RomM handle save states with EmulatorJS?

RomM intercepts EmulatorJS save events through the window.EJS_onSaveSave and window.EJS_onSaveState callbacks. When triggered, these functions receive binary data from the emulator and forward it to the backend via saveSave and saveState utilities. For loading, loadEmulatorJSSave writes the server-provided data into the emulator’s virtual file system using FS.writeFile, then invokes gameManager.loadSaveFiles() to activate the save.

What is the purpose of the iOS fullscreen shim in RomM?

The installIOSFullscreenShim function in utils.ts patches the browser’s fullscreen API on iOS devices, which do not support the standard Fullscreen API used by EmulatorJS. This shim enables true fullscreen gameplay on iPhones and iPads by overriding native methods. The function returns a cleanup handler that restores original behavior when the user exits the emulation view, preventing side effects on other parts of the application.

How does RomM select the appropriate emulator core for a ROM?

Core selection logic resides in frontend/src/utils/index.ts, which exports getSupportedEJSCores and areThreadsRequiredForEJSCore. These utilities map the ROM’s platform (e.g., SNES, GBA) to compatible EmulatorJS cores and determine whether threading should be enabled. The selected core string (such as "snes9x" or "mgba") is assigned to window.EJS_core before the emulator initializes.

Why does RomM clear the IndexedDB cache when ROMs are renamed?

RomM implements invalidateEmulatorJSRomCacheIfRenamed to prevent the emulator from loading stale ROM data. EmulatorJS caches ROM files in the browser’s IndexedDB for offline capability. If a user renames a ROM file in RomM, the cached version under the old filename would persist and cause mismatches. This utility detects filename changes and clears the specific cache entry, ensuring the emulator always loads the current file version.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →