Skip to main content
The GameWindowManager class creates and manages game windows in both native mode (single window with canvas rendering) and overlay mode (transparent overlay for external RetroArch process). It handles window animations, state persistence, and IPC communication.

Constructor

const gameWindowManager = new GameWindowManager(preloadPath);
preloadPath
string
required
Path to the preload script for window security context
Automatically sets up IPC handlers for window control (minimize, maximize, close, fullscreen, etc.).

Native mode windows

createNativeGameWindow()

Create a game window in native mode — the game renders inside the BrowserWindow via WebGL canvas. Single window, single title bar.
const gameWindow = gameWindowManager.createNativeGameWindow(
  game,
  workerClient,
  avInfo,
  shouldResume,
  cardScreenBounds
);
game
Game
required
Game object from LibraryService
workerClient
EmulationWorkerClient
required
Worker client managing the emulation utility process
avInfo
AVInfo
required
Audio/video info from the core (base resolution, aspect ratio, sample rate)
shouldResume
boolean
Whether to load autosave (slot 99) on boot (default: false)
cardScreenBounds
object
Screen-space bounds of the game card for hero transition animation
x
number
required
X position
y
number
required
Y position
width
number
required
Width
height
number
required
Height
Returns: BrowserWindow
If cardScreenBounds is provided, the window animates from the card position to its final size using a hero transition.

Window sizing and aspect ratio

Native windows automatically size themselves based on the core’s reported aspect ratio:
const baseWidth = avInfo.geometry.baseWidth || 256;
const baseHeight = avInfo.geometry.baseHeight || 240;
const aspectRatio = avInfo.geometry.aspectRatio || (baseWidth / baseHeight);

// Size to fill ~80% of display height, minimum 3x base height
const displayHeight = screen.getPrimaryDisplay().workAreaSize.height;
const defaultHeight = Math.max(baseHeight * 3, Math.round(displayHeight * 0.8));
const defaultWidth = Math.round(defaultHeight * aspectRatio);
The window is locked to this aspect ratio, so user resizes maintain correct proportions.

State persistence

Window position, size, and fullscreen state are saved per-system:
// State files are system-specific to handle different aspect ratios
const stateFile = `game-window-state-${game.systemId}.json`;
This prevents a wide GBA window size from being restored for a 4:3 NES game.

Zero-copy frame transfer

Native windows use SharedArrayBuffer for zero-copy video/audio data transfer:
const { port1, port2 } = new MessageChannelMain();
gameWindow.webContents.postMessage('game:shared-frame-port', null, [port2]);

port1.postMessage({
  type: 'sharedBuffers',
  control: sharedBuffers.control,
  video: sharedBuffers.video,
  audio: sharedBuffers.audio,
});
The renderer reads directly from shared memory without IPC overhead.

Shutdown animation

Native windows implement a graceful shutdown sequence:
  1. User clicks close → window intercepts close event
  2. Save window state immediately (before animation)
  3. Pause emulation and save SRAM + autosave (slot 99)
  4. Send game:prepare-close to renderer
  5. Renderer plays shutdown animation → sends game-window:ready-to-close
  6. Main process animates window close (fade + shrink)
  7. Window destroys after animation completes
A 2-second safety timeout force-closes the window if the renderer doesn’t respond.

Overlay mode windows (legacy)

createGameWindow()

Create an overlay game window for external RetroArch process. Transparent frameless window that tracks the RetroArch window position.
const overlayWindow = gameWindowManager.createGameWindow(game);
game
Game
required
Game object from LibraryService
Returns: BrowserWindow

startTrackingRetroArchWindow()

Start tracking an external RetroArch window to overlay controls.
gameWindowManager.startTrackingRetroArchWindow(game.id, retroArchPid);
gameId
string
required
Game ID
pid
number
required
RetroArch process ID
Returns: void Polls every 200ms to:
  • Query RetroArch window bounds via JXA (macOS only)
  • Match overlay window position and size
  • Detect fullscreen mode and adjust title bar offset
  • Show/hide controls based on cursor position
  • Close overlay if RetroArch exits (5 consecutive query failures)
This mode is legacy. New games use native mode (createNativeGameWindow) instead.

stopTracking()

Stop tracking a RetroArch window.
gameWindowManager.stopTracking(game.id);
gameId
string
required
Game ID
Returns: void

Window control

closeGameWindow()

Close a specific game window.
gameWindowManager.closeGameWindow(game.id);
gameId
string
required
Game ID
Returns: void

closeAllGameWindows()

Close all game windows (e.g., on app quit).
gameWindowManager.closeAllGameWindows();
Returns: void

getGameWindow()

Get the BrowserWindow instance for a game.
const window = gameWindowManager.getGameWindow(game.id);
if (window && !window.isDestroyed()) {
  window.focus();
}
gameId
string
required
Game ID
Returns: BrowserWindow | undefined

destroy()

Clean up all windows and IPC listeners. Called on app quit.
gameWindowManager.destroy();
Returns: void

IPC handlers

GameWindowManager automatically sets up these IPC handlers for renderer control:

game-window:minimize

Minimize the game window.
// Renderer
ipcRenderer.send('game-window:minimize');

game-window:maximize

Toggle maximize/unmaximize.
// Renderer
ipcRenderer.send('game-window:maximize');

game-window:close

Close the game window (triggers shutdown animation in native mode).
// Renderer
ipcRenderer.send('game-window:close');

game-window:toggle-fullscreen

Toggle fullscreen mode.
// Renderer
ipcRenderer.send('game-window:toggle-fullscreen');

game-window:set-click-through

Set whether the window should forward mouse events (for overlay mode).
// Renderer
ipcRenderer.send('game-window:set-click-through', true);
clickThrough
boolean
required
Whether to ignore mouse events

game-window:set-traffic-light-visible

Show/hide macOS traffic light buttons.
// Renderer
ipcRenderer.send('game-window:set-traffic-light-visible', false);
visible
boolean
required
Whether buttons should be visible

game-window:ready-to-close

Renderer signals that the shutdown animation has completed and the window is ready to destroy.
// Renderer (game window)
ipcRenderer.send('game-window:ready-to-close');

game:input

Forward input from renderer to emulation worker.
// Renderer
ipcRenderer.send('game:input', port, buttonId, pressed);
port
number
required
Controller port (0-3)
id
number
required
Button ID (from libretro button constants)
pressed
boolean
required
Whether button is pressed or released

Events sent to renderer

Game windows receive these events from the main process:

game:loaded

Sent when the window finishes loading with the game data.
// Renderer
ipcRenderer.on('game:loaded', (event, game: Game) => {
  console.log(`Loaded: ${game.title}`);
});

game:mode

Sent to indicate the window mode.
// Renderer
ipcRenderer.on('game:mode', (event, mode: 'native' | 'overlay') => {
  if (mode === 'native') {
    // Set up canvas renderer
  }
});

game:av-info

Sent with audio/video info from the core (native mode only).
// Renderer
ipcRenderer.on('game:av-info', (event, avInfo: AVInfo) => {
  console.log(`Resolution: ${avInfo.geometry.baseWidth}x${avInfo.geometry.baseHeight}`);
  console.log(`Aspect ratio: ${avInfo.geometry.aspectRatio}`);
});

game:ready-for-boot

Sent when the window is ready to start the boot animation (after hero transition completes).
// Renderer
ipcRenderer.on('game:ready-for-boot', () => {
  // Start CRT power-on animation
});

game:prepare-close

Sent when the user closes the window, before the shutdown animation.
// Renderer
ipcRenderer.on('game:prepare-close', () => {
  // Play shutdown animation, then send 'game-window:ready-to-close'
});

game:video-frame

Fallback video frame delivery when SharedArrayBuffer is not available.
// Renderer
ipcRenderer.on('game:video-frame', (event, frame: { data: Buffer; width: number; height: number }) => {
  // Draw frame to canvas
});

game:audio-samples

Audio samples from the worker.
// Renderer
ipcRenderer.on('game:audio-samples', (event, audio: { samples: Buffer; sampleRate: number }) => {
  // Queue audio samples
});

game:emulation-error

Fatal emulation error.
// Renderer
ipcRenderer.on('game:emulation-error', (event, error: { message: string }) => {
  console.error('Emulation error:', error.message);
});

overlay:show-controls

Show/hide overlay controls based on cursor position (overlay mode only).
// Renderer
ipcRenderer.on('overlay:show-controls', (event, visible: boolean) => {
  // Fade controls in/out
});

Usage example

import { GameWindowManager } from './GameWindowManager';
import { EmulationWorkerClient } from './emulator/EmulationWorkerClient';

const gameWindowManager = new GameWindowManager(preloadPath);

// Launch a game in native mode
const workerClient = new EmulationWorkerClient();
const avInfo = await workerClient.init({
  corePath: '/path/to/core.dylib',
  romPath: '/path/to/game.nes',
  systemDir: systemDir,
  saveDir: saveDir,
  sramDir: sramDir,
  saveStatesDir: saveStatesDir,
  addonPath: addonPath,
});

const gameWindow = gameWindowManager.createNativeGameWindow(
  game,
  workerClient,
  avInfo,
  false, // don't resume from autosave
  { x: 100, y: 100, width: 200, height: 300 } // card bounds for hero transition
);

// Window automatically handles:
// - Aspect ratio locking
// - State persistence
// - Frame/audio forwarding
// - Shutdown animation
// - Autosave on close

// Later: close the window
gameWindowManager.closeGameWindow(game.id);

// On app quit: clean up
gameWindowManager.destroy();

Performance considerations

See the Architecture guide for performance considerations and restrictions on game window rendering:
  • No backdrop-filter or backdrop-blur overlays
  • No expensive CSS effects during gameplay
  • Frame delivery synced to requestAnimationFrame
Violating these rules can cause frame drops and inconsistent frame pacing on high-refresh displays.

Source reference

  • Implementation: apps/desktop/src/main/GameWindowManager.ts:23
  • Usage example: apps/desktop/src/main/ipc/handlers.ts:24

Build docs developers (and LLMs) love