Skip to main content
Everything interesting an overlay does is a reaction to gameplay: a kill feed updates when someone dies, a scoreboard tracks the match, a HUD swaps layouts when the round phase changes. This guide walks through subscribing to game events, folding them into an in-memory game state, and getting that state to your windows.
This guide covers the event-consumption workflow. For the subscription API reference, see Game Events; for the per-game event lists, see Supported Games - e.g. Rainbow Six Siege and Universal.

How Game Events Work

Four things to know before writing any code:
  1. Events arrive per game module. You subscribe to a game’s events by passing its module in your overlayed() config, and listen on the matching property of the overlay object - overlay.siege for Siege.module(). The universal module is always available as overlay.universal, whether or not you pass it.
  2. Nothing is emitted until you call readyForGameEvents(). Each module queues incoming events (up to 1,000) from startup. The first call to that module’s readyForGameEvents() replays the queue to your listeners - in arrival order, synchronously - and switches the module to live delivery for the rest of the app’s run.
  3. Every event is validated and fully typed. Payloads are validated with Arktype before your listeners see them. The object your callback receives is an envelope - { game, type, creation_time, content } - and the per-game reference tables document the shape of content.
  4. Listeners run in your main process. Game events never arrive in a renderer directly. Keep your canonical game state in the main process and forward snapshots to windows over IPC.

From Events to Game State

The examples use Rainbow Six Siege, but the pattern is identical for any module: reset on match_started, build a roster from player_joined/player_left, and patch it from the *_changed events.
1

Register the game module

Pass the module for each game whose events you want. This also subscribes you to that game’s lifecycle events - see Game Launch & Close Events for how gameLaunch, gameReady, and gameClose fit in.
overlayed.ts
import { overlayed } from "@overlayed/app";
import * as Siege from "@overlayed/app/siege";
import electron from "electron";

export const overlay = overlayed({
	electron,
	applicationId: "YOUR_APPLICATION_ID",
	modules: [Siege.module()],
});
2

Build state from events

Keep one plain state object in the main process and treat each listener as a reducer. The event types are exported from the module package, so you can derive your state types from them instead of hand-writing shapes:
gameState.ts
import { overlay } from "./overlayed.ts";
import type { PlayerJoinedEvent, MatchStartedEvent, SiegeGameModePhase } from "@overlayed/app/siege";

type SiegePlayer = PlayerJoinedEvent["content"];
type SiegePlayerId = SiegePlayer["player_id"];

export const state = {
	match: null as MatchStartedEvent["content"] | null,
	phase: null as SiegeGameModePhase | null,
	players: new Map<SiegePlayerId, SiegePlayer>(),
};

overlay.siege.on("match_started", (event) => {
	state.match = event.content;
	state.players.clear();
});

overlay.siege.on("player_joined", (event) => {
	state.players.set(event.content.player_id, event.content);
});

overlay.siege.on("player_left", (event) => {
	state.players.delete(event.content.player_id);
});

overlay.siege.on("player_kills_changed", (event) => {
	const player = state.players.get(event.content.player_id);
	if (player) {
		player.match_stats.kills = event.content.kills;
	}
});

overlay.siege.on("phase_changed", (event) => {
	state.phase = event.content.phase;
});
Note that the game data is always under event.content - the reference tables on the per-game pages document that inner shape. The envelope also carries event.game and event.type, which matter once one listener handles several games or event types.
3

Flush the queue with readyForGameEvents()

Once all listeners are attached, tell the module you’re ready:
gameState.ts
overlay.siege.readyForGameEvents();
Any events that arrived while your app was starting up are replayed to your listeners immediately, then new events flow as they happen.
Call readyForGameEvents() after registering your listeners. The queue is replayed synchronously during the call, and queued events with no matching listener are discarded - flushing first means the events your app started up with are gone by the time your listeners exist.
4

Reset state when the session ends

A match ending and the game closing are different resets. match_ended clears per-match state while the player sits in the menus; gameClose clears everything for the session:
gameState.ts
overlay.siege.on("match_ended", () => {
	state.match = null;
	state.phase = null;
	state.players.clear();
});

overlay.on("gameClose", ({ game }) => {
	if (game !== "siege") {
		return;
	}

	state.match = null;
	state.phase = null;
	state.players.clear();
});

Specific Events vs. Listening to Everything

Every module exposes both on for a single event type and onAny for all of the module’s events. For the same event, on listeners are invoked before onAny listeners.
Use on when each event has its own handling. The callback is typed to that event, so event.content is fully typed with no narrowing needed:
overlay.siege.on("player_died", (event) => {
	killFeed.push({
		victim: event.content.player_id,
		killer: event.content.instigator_player_id,
		headshot: event.content.headshot,
	});
});
Both have matching removers - overlay.siege.off(eventType, callback) and overlay.siege.offAny(callback) - which take the same callback reference you registered. You can attach multiple listeners to the same event.

Getting State to Your Windows

Since events land in the main process, your windows never see them directly. Forward a snapshot over IPC whenever state changes - both createWindow and createInGameWindow return (a wrapper around) an Electron BrowserWindow, so the standard webContents.send flow applies:
main.ts
function broadcastState() {
	hudWindow.webContents.send("game-state", {
		match: state.match,
		phase: state.phase,
		players: Array.from(state.players.values()),
	});
}

overlay.siege.onAny(() => broadcastState());
overlay.siege.readyForGameEvents();
Send the whole snapshot rather than re-emitting individual game events to the renderer. A window that opens mid-match then renders correctly from the first message, instead of waiting to accumulate deltas - and your renderer stays a pure function of one state object.

Event Queueing in Depth

The queue exists so your app’s startup never races the game: events that arrive while your windows and listeners are still being set up are held per module until you flush them.
  • The queue is per module. Each module you want events from needs its own readyForGameEvents() call - flushing overlay.siege does not flush overlay.universal.
  • It holds up to 1,000 events per module. Once full, additional events are dropped until the queue is flushed, and a warning is raised - observable via overlay.on("warning", ...) (see Issue Handling).
  • Flushing is one-way and once per app run. After the first call, the module stays in live delivery for the lifetime of the process - subsequent calls are no-ops, and the queue does not re-arm when the game closes and relaunches. There’s no harm in calling it again, but you can’t “pause” events with it either; to stop reacting, remove your listeners with off/offAny.

Universal Events

Alongside per-game events, Overlayed emits a small set of universal events - such as logged_in - for subscribed games. They arrive on overlay.universal, which exists even if you never passed the universal module, and they need their own readyForGameEvents():
main.ts
overlay.universal.on("logged_in", (event) => {
	// event.game tells you which game this came from
	linkAccount(event.game, event.content.account_id);
});

overlay.universal.readyForGameEvents();
Setting universal: true in your config and the universal events module are related but different things. The option subscribes you to every supported game’s lifecycle (gameLaunch/gameReady/gameClose) and universal events - but a game’s own events (kills, rounds, phases) still only flow for games whose module you passed. Events for a game without a registered module are dropped.

FAQ

Check these in order:
  1. Module registered: the game’s module must be in your overlayed() modules array - universal: true alone does not deliver game-specific events.
  2. readyForGameEvents(): it must be called on that specific module. Until then, everything sits in the queue.
  3. Rejected launch: calling reject() in a gameLaunch listener blocks that game’s events for the session - see Overlay Events.
  4. Game support: some games support rendering and hotkeys but have no events - check Supported Games.
  5. Logs: every incoming event is logged internally, including events that were dropped for having no listener. Run with debug: true and see Logging.
No. It’s once per module per app run. Queueing only covers your app’s startup window - after the first flush, events are delivered live for the rest of the process, across game sessions. Handle relaunches by resetting your state (on gameClose and match_started), not by re-flushing.
Yes - and you should. Listener registration is independent of the game’s lifecycle, and combined with queueing it means the usual setup (register listeners, call readyForGameEvents(), both at app startup) never misses an event regardless of when the game starts.
Each module queues at most 1,000 events. Once the buffer is full, further events are dropped until you flush, and a warning is emitted that you can observe with overlay.on("warning", ...). If you’re hitting the cap, call readyForGameEvents() earlier in your startup.
No. Every event is validated against its Arktype schema before your listeners run; events that fail validation are dropped with a warning rather than delivered partially. One deliberate exception to the noise: events whose type your installed package version doesn’t know about (e.g. a newer game module adds an event) are silently ignored, so shipping apps don’t spam warnings when new events roll out.
Yes. When gameReady reports inGameRenderingSupported: false, only rendering inside the game is affected - events still flow. See Handling inGameRenderingSupported.
Siege emits phase_changed with a phase string ("intro", "planning", "prep", "action", "results", …) as the round moves along, and round_ended with a full summary - final round state per team, every damage event, and each player’s final health and operator. The package also exports helpers like isSiegeRoundStateVictory and isSiegeRoundStateCompleted plus constants such as SIEGE_ROUND_STATE_LABELS for interpreting the numeric team states. See the Siege events reference.
Two options that work well together: attach a onAny listener per module that logs event.type and event.content, and enable debug: true in your config so Overlayed’s internal logs - which include every incoming event, validation failures, and “no handlers” drops - appear. See Debug Mode.

Next Steps

Game Events Reference

The subscription API: on, onAny, queueing, types, and constants.

Supported Games

Every supported game and its full event reference tables.

Game Launch & Close Events

The session lifecycle that brackets these events.

Issue Handling

Observe warnings, errors, and fatals from the overlay.