Skip to content

Architecture

This document covers RetroSync's technical architecture, design decisions, and the reasoning behind them.

Overview

RetroSync is a desktop application for managing retro game ROM libraries. It integrates with IGDB for game metadata, supports extensible addon sources for discovering and importing ROMs, and organizes files by platform and device compatibility.

The app is built with Electron (main + renderer processes), React 19 for the UI, SQLite via Drizzle ORM for persistence, and a custom addon system that allows external plugins to provide game and BIOS sources.

Tech Stack

LayerTechnologyRationale
Desktop shellElectronCross-platform desktop app with native filesystem access
UI frameworkReact 19Component model, ecosystem, concurrent features
Build toolingVite + electron-viteFast HMR, native ESM, Electron-aware bundling
StylingTailwind CSS 4Utility-first, custom design tokens, no runtime cost
State managementZustandMinimal boilerplate, no providers, simple selectors
DatabaseSQLite (better-sqlite3)Embedded, zero-config, synchronous reads for Electron main process
ORMDrizzle ORMType-safe queries, lightweight, SQLite-native, migration tooling
Accessible UIHeadless UIUnstyled, WAI-ARIA compliant primitives (popovers, menus)
Iconslucide-reactTree-shakeable, consistent icon set
Fuzzy searchFuse.jsClient-side fuzzy matching for ROM name normalization
Loggingelectron-logScoped logging for main/renderer/addon processes
Packagingelectron-builderCross-platform installers (NSIS, DMG, AppImage, deb)

Process Architecture

Process Architecture

Main Process (src/main/)

The main process owns all I/O: database access, filesystem operations, network requests (e.g. IGDB API), and addon lifecycle management. It exposes functionality to the renderer exclusively through typed IPC handlers.

Key modules:

  • index.ts - App lifecycle, window creation, IPC handler registration
  • config.ts - JSON config file management with deep-merge updates
  • igdb.ts - IGDB OAuth2 authentication, game search, metadata fetching, image caching
  • platforms.ts - Device profiles, platform definitions, active platform ID resolution
  • db/ - SQLite setup (WAL mode), Drizzle schema, migrations
  • addons/ - Addon registry, loader, context, IPC, type contracts
  • imports/ - Import queue manager, transfer lifecycle, staging/library file placement
  • bios/ - BIOS source aggregation, local scanning, installation
  • library.ts - Library management (add/remove games, collection queries)
  • imageCache.ts - IGDB cover image caching and serving

Preload (src/preload/)

A thin bridge that exposes a typed window.api object to the renderer using Electron's contextBridge. Each namespace (api.igdb, api.config, api.addons, api.imports, etc.) maps 1:1 to IPC channels. The preload script never contains business logic.

Renderer (src/renderer/)

A React 19 SPA with Zustand for state management. The renderer never accesses the filesystem or database directly - all data flows through window.api.* IPC calls.

Key parts:

  • store/useAppStore.ts - Single Zustand store for all application state
  • pages/ - Top-level views (Dashboard, Library, Imports, Platform Setup, Addons, Settings, About)
  • components/ - Reusable UI (GameCard, GameDetailPanel, HeroBanner, Sidebar, etc.)
  • types/ - Shared renderer-side type definitions

Design Decisions

Why SQLite over Electron Store / JSON files?

The app needs to query indexed ROM metadata (tens of thousands of entries from addon indexes), track import status, and store library games with relational lookups. SQLite with WAL (Write-Ahead Logging) mode gives us:

  • Fast reads without blocking writes
  • Proper indexing for search queries
  • Shared access between main process and addons
  • Atomic transactions for batch inserts (addon indexing)

JSON-based stores would struggle with the index sizes and lack query capabilities.

Why a shared database for addons?

Addons get a Drizzle ORM instance through their context. This means addons create their own tables in the same database file rather than managing separate databases.

Trade-offs:

  • ✅ Single file to backup/migrate
  • ✅ Addons can use Drizzle's full query builder
  • ✅ No inter-process database coordination
  • ❌ Addons must namespace their tables to avoid collisions
  • ❌ A misbehaving addon could theoretically corrupt shared data

This was chosen over separate databases because addon queries often correlate with app data (e.g., checking import status for a source), and a single WAL-mode database handles concurrent access well.

Why Zustand over Redux / Context?

Zustand was chosen for minimal boilerplate and simplicity:

  • No providers or wrappers needed
  • Selectors are plain functions - fine-grained re-renders
  • Works naturally with React 19's concurrent features
  • Single store file keeps all state co-located and easy to trace

The store (useAppStore.ts) contains all application state: navigation, search, game data, IGDB integration, device profiles, and user preferences.

Why an addon system?

ROM sources vary widely - local folders, archive CDNs, community databases - and each has different discovery, indexing, and transfer mechanisms. Rather than hardcoding source types, RetroSync uses an addon architecture where each source is a self-contained plugin.

Design principles:

  • Capability-based: Addons declare what they can do (sources:games, sources:bios, metadata)
  • Context injection: Addons receive a controlled context (database, config, logging) rather than importing app internals
  • Transfer contract: All addons implement the same createTransfer() interface, so the import manager treats every source uniformly
  • Dynamic loading: External addons are loaded from {userData}/addons/ at startup, allowing user-installed plugins without app rebuilds

Why Tailwind CSS with custom design tokens?

The app uses a dark theme with retro gaming aesthetics. Tailwind's utility classes keep styling co-located with components. Custom CSS variables (--color-rs-accent, --color-rs-panel, etc.) define the design system:

css
--color-rs-bg: #0f0f0f;
--color-rs-panel: #1a1a1a;
--color-rs-panel-light: #242424;
--color-rs-accent: #6366f1; /* Indigo */
--color-rs-accent-hover: #818cf8;
--color-rs-text: #f3f4f6;
--color-rs-text-secondary: #9ca3af;
--color-rs-danger: #ef4444;
--color-rs-success: #22c55e;
--color-rs-warning: #f59e0b;
--color-rs-sidebar: #141414;
--color-rs-border: #2a2a2a;

This approach allows potential theming in the future by swapping CSS variable values.

Data Flow

Game Discovery & Import

Game Discovery & Import

Import Queue

The import manager maintains a concurrency-limited queue (default: 3 concurrent transfers). Imports go through these states:

Import Queue States

Key behaviors:

  • Paused imports persist across app restarts
  • Staging files are cleaned up on startup if their import records are stale
  • File placement handles cross-filesystem moves (copy + delete fallback)
  • Each addon controls whether pause/resume is supported via TransferHandle.supportsPause

Database Schema

Main Application

Database Schema

Addon Tables

Addons create their own tables via Drizzle SQL migrations run during init(). For example, a source addon might create:

  • An index table for discovered ROM entries (with platform, filename, size, region)
  • A status table tracking which collections have been indexed

Addons are responsible for running their own migrations during init().

Configuration

App configuration is stored at {userData}/retrosync-config.json as a flat JSON file with deep-merge semantics for partial updates.

typescript
interface AppConfig {
  igdb: { clientId: string; clientSecret: string }
  igdbSetupSkipped: boolean
  igdbExcludedGameTypes: number[]
  devices: string[] // Selected device profile IDs
  customDevices: CustomDevice[] // User-created device profiles
  addons: {
    enabled: string[] // Enabled addon IDs
    sourcesDisplayMode: 'compact' | 'expandable'
    config: Record<string, unknown> // Per-addon config keyed by addon ID
  }
  libraryPath: string // Final ROM storage location
  importPath: string // Staging directory for active imports
  maxConcurrentImports: number // Concurrent transfer limit
  importsBadgeStyle: 'count' | 'dot' | 'none'
}

Platform & Device System

RetroSync supports 32 retro platforms (NES, SNES, N64, PS1, PS2, Dreamcast, etc.) and ships with 11 pre-configured device profiles (Miyoo Mini Plus, Anbernic RG35XX, Steam Deck, etc.).

Each device profile maps to a set of IGDB platform IDs it can emulate, organized by performance tiers:

  • Tier 1 (lightweight): GB, GBC, NES, SMS, Game Gear, Lynx, Atari 2600/7800, NGP, NGPC, WonderSwan
  • Tier 2 (moderate): SNES, SFC, Mega Drive, GBA, 32X, Sega CD, PC Engine, Neo Geo AES/MVS/CD
  • Tier 3 (demanding): PS1, N64, Nintendo DS, PSP
  • Tier 4 (heavy): Dreamcast, Saturn, GameCube, PS2, Wii, 3DS, Jaguar

The union of all selected device platform IDs determines which games and sources are relevant to the user. This is exposed to addons via context.getActivePlatformIds().

Security Considerations

  • IGDB API credentials are stored in the local config file (not in the repository)
  • Addons run in the same Node.js process as the main app (no sandboxing)
  • Addon installation requires explicit user action
  • The preload script exposes only whitelisted IPC channels via contextBridge
  • No remote code execution - addons are loaded from local disk only

Released under the MIT License.