Multi-Monitor and Multi-Window Guide
Thunder.MonoGame.Avalonia supports three patterns for rendering a game across multiple
displays or windows. Each pattern targets a different use case. Understand the constraints
before choosing.
Why multi-monitor is a first-class requirement
Most game platforms treat multi-monitor as an afterthought: stretch the backbuffer to fill all screens, or spawn a separate process per window. Neither approach lets the player decide at runtime which parts of the game appear on which display.
The Avalonia integration makes a different thing possible: sub-viewports are Avalonia controls. They participate in normal layout, they are draggable, they can snap to monitor edges, and they can be shown or hidden via data binding — all while rendering through the same MonoGame GL context at full frame rate. Players can arrange their game workspace the way they arrange browser tabs or IDE panels.
This is not incidental. It is one of the primary reasons to use this integration over a standalone MonoGame DesktopGL window.
Background — the VSync constraint
On X11, Avalonia creates a single Compositor instance for all windows in the process.
Its render loop iterates every active window target and calls glXSwapBuffers (the VSync
barrier) once per target, sequentially. With two OS windows open in the same process, the
compositor performs two sequential VSync waits per render tick, giving approximately 30 FPS
per window instead of 60.
This is inside Avalonia internals and cannot be changed from application code. It is the reason Patterns A and C below exist, and why Pattern B (separate processes) is the only route to two independent 60 FPS OS windows.
Pattern A — Sub-viewports inside a single window
Use when: You want multiple distinct game views (map, inspector, secondary camera, tool panel) within one application window. All views share the same game tick and GL context.
Performance: Full 60 FPS — one OS window, one VSync budget.
How it works
SubViewGameControl is a plain Avalonia Control (not OpenGlControlBase). It has no GL
context of its own. It acts as a transparent, hit-testable layout placeholder that communicates
its bounds to the game via a Draw delegate. Actual rendering happens inside
MonoGameControl.OnOpenGlRender, which iterates all registered sub-views after game.Tick()
and invokes each delegate with a SubViewportDrawContext containing the sub-view's current
viewport rectangle.
The game sets GraphicsDevice.Viewport (and optionally ScissorRectangle) to the provided
rectangle before drawing into each sub-view region. All drawing uses the same GPU resources —
textures, shaders, vertex buffers — because there is only one GL context.
<Grid>
<!-- Main game — backbuffer sized to this control's layout bounds -->
<mg:MonoGameControl x:Name="Host" Game="{Binding Game}" />
<!-- Floating map panel — can be dragged anywhere in the window -->
<mg:SubViewGameControl HostControl="{x:Reference Host}"
Draw="{Binding DrawMinimapDelegate}"
Width="320" Height="240"
Canvas.Left="20" Canvas.Top="20" />
</Grid>
// In the game or ViewModel:
DrawMinimapDelegate = ctx =>
{
GraphicsDevice.Viewport = new Viewport(
ctx.Viewport.X, ctx.Viewport.Y,
ctx.Viewport.Width, ctx.Viewport.Height);
// draw minimap content...
};
Focus and input routing
Each SubViewGameControl is Focusable = true and forwards keyboard events to
AvaloniaInputState. When a sub-view gains focus it sets FocusedViewportTag
to its Tag value. The game queries this tag in Update() to route context-sensitive input —
for example, pressing Ctrl+W closes the focused sub-window, or arrow keys scroll the focused
viewport rather than moving the player character.
Pattern B — Separate processes (60 FPS per independent window)
Use when: You need two completely independent game instances, each at full frame rate, with no shared GL context or game state. For example: two players on the same machine, each with their own game session displayed on their own monitor.
Performance: 60 FPS per window — each process has its own Avalonia compositor and its own VSync budget.
How it works
Run two independent dotnet processes. Each process has its own App, MainWindow,
MonoGameControl, and Game. There is no shared GL context and no shared AvaloniaInputState.
For shared state (e.g. a shared game world), use IPC: named pipes, Unix domain sockets, memory-mapped files, or a lightweight local gRPC/HTTP server.
# Terminal 1 — player 1
dotnet run --project src/MyGame -- --player 1 --ipc-port 5001
# Terminal 2 — player 2
dotnet run --project src/MyGame -- --player 2 --ipc-port 5001
This pattern requires no framework changes — it is an application architecture choice.
Pattern C — Single borderless window spanning multiple displays
Use when: You want a seamless multi-monitor game surface where the player can arrange sub-viewport panels freely across physical displays, with snap-to-screen-edge behaviour.
Performance: Full 60 FPS — one OS window regardless of how many displays it covers.
Why it works at full frame rate
A window spanning two physical displays is still a single TopLevel — one
ServerCompositionTarget, one glXSwapBuffers. The number of physical monitors the window
covers is irrelevant to the VSync budget.
Window setup — single screen
For a single-monitor fullscreen that covers the taskbar, use WindowState.FullScreen.
Set the position first so the WM fullscreens the correct display:
window.SystemDecorations = SystemDecorations.None;
window.Topmost = true;
window.Position = new PixelPoint(screen.Bounds.X, screen.Bounds.Y);
window.WindowState = WindowState.FullScreen;
WindowState.FullScreen maps to _NET_WM_STATE_FULLSCREEN on X11, which bypasses taskbar
strut reservations. Topmost alone (_NET_WM_STATE_ABOVE) does not.
Window setup — multiple screens on X11
WindowState.FullScreen alone covers only one monitor. To span fullscreen across two or more
displays and cover all their taskbars, combine it with _NET_WM_FULLSCREEN_MONITORS:
// Step 1 — set position and enter FullScreen (covers primary monitor's struts).
window.SystemDecorations = SystemDecorations.None;
window.Topmost = true;
window.WindowState = WindowState.Normal;
window.Position = new PixelPoint(left, top);
window.WindowState = WindowState.FullScreen;
// Step 2 — tell the WM which Xinerama screens to span (deferred: WM must process
// _NET_WM_STATE_FULLSCREEN before the monitor-range message is sent).
Dispatcher.UIThread.Post(
() => X11MultiScreenFullscreen.TryApply(window, targetScreens),
DispatcherPriority.Render);
TryApply sends _NET_WM_FULLSCREEN_MONITORS to the X11 root window.
It maps Avalonia Screen objects to Xinerama monitor indices by matching physical pixel origin
(X, Y). The helper is safe to call on any platform — it returns false on non-X11 systems.
See Platform/Avalonia/X11MultiScreenFullscreen.cs for the implementation.
Window setup — multiple screens on non-X11
Wayland, Windows, and macOS require platform-specific APIs for taskbar coverage across multiple monitors. The cross-platform fallback is borderless + manual size:
var screens = targetScreens;
var left = screens.Min(s => s.Bounds.X);
var top = screens.Min(s => s.Bounds.Y);
var right = screens.Max(s => s.Bounds.Right);
var bot = screens.Max(s => s.Bounds.Bottom);
window.SystemDecorations = SystemDecorations.None;
window.Position = new PixelPoint(left, top);
window.Width = (right - left) / window.RenderScaling;
window.Height = (bot - top) / window.RenderScaling;
This covers the desktop area but may not override taskbar struts on all WMs.
Taskbar coverage — platform summary
| Platform | Single screen | Multi-screen |
|---|---|---|
| X11 (EWMH WM, e.g. GNOME, KDE) | WindowState.FullScreen |
FullScreen + _NET_WM_FULLSCREEN_MONITORS |
| Wayland | WindowState.FullScreen (compositor-controlled) |
Platform-specific (no standard protocol) |
| Windows | WindowState.FullScreen or SetWindowPos |
SetWindowPos across monitors |
| macOS | WindowState.FullScreen |
NSScreen API required |
Keeping the main game at native resolution (no stretching)
By default, MonoGameControl sizes the game's backbuffer to its own layout bounds. If the control fills a 3840×1080 spanning window, the game renders at 3840×1080.
To keep the main game at native resolution and use secondary displays for sub-viewports, constrain MonoGameControl to one screen via Avalonia layout and place SubViewGameControl instances in the remaining space:
<!-- 3840×1080 borderless window; left screen = primary, right screen = map -->
<Grid>
<!-- Main game: locked to left 1920px — backbuffer = 1920×1080 -->
<mg:MonoGameControl x:Name="Host" Game="{Binding Game}"
Width="1920" HorizontalAlignment="Left" />
<!-- Map viewport: right 1920px -->
<mg:SubViewGameControl HostControl="{x:Reference Host}"
Draw="{Binding DrawMapDelegate}"
Width="1920" HorizontalAlignment="Right" />
</Grid>
The game renders at exactly 1920×1080. The right display gets a sub-viewport pass through the same GL context with zero extra VSync cost.
Snap-to-screen-edge using the Screens API
Avalonia exposes every connected display's bounds via window.Screens.All. Use these to
compute monitor boundary positions relative to the window origin, then snap dragged panels
when they approach a boundary:
const Int32 SnapThreshold = 16; // pixels
void OnPanelDragged(SubViewGameControl panel, Point newPosition)
{
var windowOrigin = window.Position;
foreach (var screen in window.Screens.All)
{
// Convert screen boundary to window-local X coordinate
var boundaryX = screen.Bounds.X - windowOrigin.X;
if (Math.Abs(newPosition.X - boundaryX) < SnapThreshold)
{
newPosition = newPosition.WithX(boundaryX);
break;
}
}
Canvas.SetLeft(panel, newPosition.X);
Canvas.SetTop(panel, newPosition.Y);
}
Screen.Scaling provides the DPI factor per display — use it when converting between device
pixels (screen coordinates) and logical pixels (Avalonia layout coordinates).
Choosing a pattern
| Scenario | Pattern | Notes |
|---|---|---|
| Map/inspector panel in same window | A | Simplest; zero extra cost |
| Player-draggable sub-windows, one monitor | A | Same window, drag within bounds |
| Player-draggable sub-windows, multi-monitor | C | Borderless spanning + snap API |
| Two independent games, one machine | B | Separate processes; IPC if shared state needed |
| RCT-style floating tool windows | A or C | C if player drags across displays |
| Full 60 FPS per completely separate window | B | Only option within Avalonia's compositor model |
What is NOT supported
- Two separate OS windows in one process at 60 FPS each — the Avalonia single-compositor VSync constraint caps each at approximately 30 FPS on X11. Use Pattern B (separate processes) instead.
- Cross-context GPU resource sharing — each
OpenGlControlBasecreates an independent GL context. GPU resources (textures, buffers) created in one context are not accessible from another. This is not a limitation for Patterns A and C, where all rendering goes through a single MonoGameControl.