Docs · Overlays

Build a Nerva overlay.

Nerva overlays are plain HTML / CSS / JS. They render in-game on the desktop and as an OBS browser source on stream. The runtime injects live BLE state and vitals on every BLE transition and every HR packet, your overlay decides how to draw them.

Overlay structure

A Nerva overlay is a folder containing:

my-overlay/
├── manifest.json       # Required, overlay metadata
├── overlay.html        # Required, the rendered overlay
├── style.css           # Optional, external stylesheet
└── assets/             # Optional, images, fonts, SVGs
    ├── heart.svg
    └── custom-font.woff2

manifest.json

{
  "name": "my-overlay",
  "author": "Your Name",
  "version": "1.0",
  "description": "A short description of your overlay"
}
  • name must be unique and folder-safe (no spaces, lowercase recommended).
  • name is used as the folder name when installed.

Data, JavaScript API

Nerva updates a global object on every BLE state transition and on every HR packet. The shape is the canonical MonitorSnapshot: a ble block and a vitals block. vitals is null whenever the device is not live (idle, scanning, connecting, connection lost), there is no synthetic “empty” packet, your overlay is expected to render its own disconnected state when vitals is missing.

window.__NERVA__ = {
  ble: {
    status: "connected",     // "idle" | "scanning" | "connecting" | "connected" | "reconnecting" | "connectionLost"
    deviceName: "Polar H10", // null when not connected
    address: "AA:BB:...",    // null when not connected
    reconnectingSecs: null   // seconds since reconnection started (only during "reconnecting")
  },
  vitals: {                  // null when ble.status is not "connected" or "reconnecting"
    bpm: 72,                 // u16, always present in a vitals object
    rawRr: [820, 815],       // ms intervals from this packet, or null
    sensorContact: true,     // BLE-reported contact flag
    energyExpended: null,    // kJ since session start, or null
    stress: 43,              // 0-100, or null while the engine is calibrating (~30s for BPM-only)
    stressBand: "moderate",  // "low" | "moderate" | "highFocus" | "peak", or null when stress is null
    rmssd: 42.3,             // ms, or null when no RR-intervals
    sdnn: 51.2,              // ms, or null when no RR-intervals
    pnn50: 18.4              // %, or null when no RR-intervals
  },
  dragMode: false            // true while the user is repositioning the desktop overlay window
};

The desktop overlay window also dispatches a nerva-dragmode custom event with detail.active as a boolean when entering or exiting drag mode. Use it to render a visual affordance (a border, a grab cursor) so users know the overlay is movable. It only fires inside the desktop window, OBS browser sources never see it.

Deriving common predicates

The wire format avoids redundant booleans, derive them client-side:

const nerva = window.__NERVA__;
const isLive         = nerva.vitals != null;                       // device is streaming
const isConnected    = nerva.ble.status === "connected";
const isReconnecting = nerva.ble.status === "reconnecting";
const hasRR          = nerva.vitals?.rmssd != null;                // device sends RR-intervals
const isCalibrated   = nerva.vitals?.stress != null;               // stress engine has enough data

Event listener

Nerva dispatches a custom event on each update:

window.addEventListener('nerva-update', (e) => {
  const m = e.detail;
  if (m.vitals) {
    document.getElementById('bpm').textContent = m.vitals.bpm;
    document.getElementById('stress').textContent = m.vitals.stress ?? '--';
  } else {
    document.getElementById('bpm').textContent = '--';
  }
});

Data, CSS Custom Properties

Nerva injects these on :root, updated in real-time:

:root {
  --nerva-bpm: 72;            /* 0 when no vitals */
  --nerva-stress: 43;         /* 0 when null */
  --nerva-stress-pct: 43%;    /* For gauge fills */
  --nerva-bpm-zone: 'rest';   /* rest | moderate | high | extreme */
  --nerva-ble-status: 'connected'; /* idle | scanning | connecting | connected | reconnecting | connectionLost */
}

The default overlay also toggles a status-<bleStatus> class on the root content element so you can style each BLE state independently:

.content.status-idle,
.content.status-scanning,
.content.status-connecting,
.content.status-connectionLost { opacity: 0.6; }
.content.status-reconnecting { opacity: 0.85; }
.content.status-connected { opacity: 1; }

Examples

Heart pulse speed tied to BPM:

.heart {
  animation: pulse calc(60s / var(--nerva-bpm)) ease-in-out infinite;
}

Stress bar fill:

.stress-fill {
  width: var(--nerva-stress-pct);
}

Data, WebSocket

Overlays connect to the local WebSocket for live data:

const ws = new WebSocket('ws://' + location.host + '/ws');
ws.onmessage = (e) => {
  if (e.data === 'reload') { location.reload(); return; }
  const m = JSON.parse(e.data);
  // m.ble.status, m.vitals?.bpm, m.vitals?.stress, …
};
ws.onclose = () => setTimeout(connect, 1000); // auto-reconnect

The server pushes a fresh MonitorSnapshot on every real change (BLE transition or new HR packet) and a literal "reload" text message whenever you change the active overlay in the app, all connected clients (OBS, desktop) reload automatically.

Guidelines

Transparent background required

Overlays are rendered on top of games and streams. Your <html> and <body> must have background: transparent:

html, body {
  background: transparent;
  overflow: hidden;
  margin: 0;
}

Performance tips

  • Prefer CSS animations over JavaScript animations.
  • Avoid heavy DOM manipulation on every update.
  • Use transform and opacity for animations (GPU-accelerated).
  • Keep total JS payload small (< 50 KB).
  • Avoid setInterval, use the nerva-update event instead.

Recommended dimensions

  • Default overlay window size: 150 x 150 px (square).
  • Users can resize freely, design fluidly, don't pin to a single size.
  • Use relative units (%, vh, vw) where possible.

Testing locally

  1. Open your overlay.html directly in a browser.
  2. Paste this mock script to simulate data:
<script>
// Mock Nerva data for testing, mirrors the canonical MonitorSnapshot.
window.__NERVA__ = {
  ble: { status: 'idle', deviceName: null, address: null, reconnectingSecs: null },
  vitals: null,
  dragMode: false
};
function bandFor(s) {
  if (s < 25) return 'low';
  if (s < 50) return 'moderate';
  if (s < 75) return 'highFocus';
  return 'peak';
}
let bpm = 60;
setInterval(() => {
  bpm = 60 + Math.round(Math.random() * 40);
  const stress = Math.round(Math.random() * 100);
  window.__NERVA__.ble = { status: 'connected', deviceName: 'Mock H10', address: '00:00', reconnectingSecs: null };
  window.__NERVA__.vitals = {
    bpm, stress, stressBand: bandFor(stress),
    rmssd: 42, sdnn: 51, pnn50: 18,
    rawRr: null, sensorContact: true, energyExpended: null
  };
  document.documentElement.style.setProperty('--nerva-bpm', bpm);
  document.documentElement.style.setProperty('--nerva-stress', stress);
  document.documentElement.style.setProperty('--nerva-stress-pct', stress + '%');
  window.dispatchEvent(new CustomEvent('nerva-update', { detail: window.__NERVA__ }));
}, 500);
</script>

Or run Nerva and open http://localhost:9876/widget?overlay=your-overlay-name.

Packaging

As a folder

Copy your overlay folder to:

  • Linux: ~/.config/nerva/overlays/my-overlay/
  • Windows: %APPDATA%/nerva/overlays/my-overlay/

As a .nerva-overlay file

ZIP your overlay folder:

cd my-overlay && zip -r ../my-overlay.nerva-overlay . && cd ..

Import via the Nerva app: Style > Import skin, then pick the .nerva-overlay (or .zip) file from the system picker.

Reference overlay

The bundled default overlay serves as the reference implementation. Browse the live previews and the .nerva-overlay bundles on the overlays page, or look at overlays/default/ in the Nerva repo for a working example of every feature on this page.