mmo-borderless-windowed-multi-monitor-pywin32
Why borderless windowed matters for MMO rigs
Last year I helped a friend rebuild her three-monitor MMO rig. She'd just dropped real money on an ultrawide for the center seat, plus two 1440p panels on the wings, and she expected her favorite MMO to "just work" across the layout. It didn't. The launcher gave her exclusive fullscreen (which locked alt-tab to a five-second mode-switch every time) or windowed mode (which left a title bar mocking her from the top of the screen). Borderless windowed — the mode she actually wanted — was nowhere in the menu. We fixed it in about forty lines of Python. This is the version of that lesson I wish I'd had when she first asked.
Why does the most-wanted windowing mode on a multi-monitor MMO rig keep showing up missing from the launcher menu? Most MMOs ship with three windowing modes baked into the launcher: exclusive fullscreen, windowed, and "borderless windowed" (sometimes called "windowed fullscreen"). The first locks the GPU into a presentation mode that owns the display surface. The second hands you a draggable frame with a title bar and resizing borders. The third is the sweet spot for multi-monitor players — the game thinks it's fullscreen, the OS treats it as a normal top-level window, and alt-tabbing happens at desktop-compositor speed instead of triggering a mode switch.
The catch: not every MMO exposes that mode. Plenty of older titles still ship with only the binary fullscreen/windowed toggle, and a few newer ones expose "borderless" but resize to your primary monitor only — useless when your second display is the one you actually want the game on. Even modern MMOs sometimes forget that a 32:9 ultrawide is one physical screen, not two, and clamp the borderless mode to half of it.
This is where Win32 manipulation comes in. The Windows window manager exposes every long-style flag and geometry primitive needed to convert a regular windowed game into a borderless one that snaps to any monitor of your choice. The Python binding for those calls is pywin32. With about forty lines of glue, you can script the post-launch transformation: launch the MMO normally, wait for its main window to appear, strip the decorations, place it at the right monitor's origin, and resize to that monitor's exact pixel dimensions.
The Win32 styles that gate the behavior
A few weeks after we fixed my friend's rig, I tried explaining the trick to another player over voice chat and realized the whole transformation hinges on flipping exactly four bits in a single integer — once you know which four, the rest is mechanical. The window's appearance is encoded in two integer bitmasks: GWL_STYLE for the standard window style, and GWL_EXSTYLE for the extended style. A normal MMO window in windowed mode carries WS_OVERLAPPEDWINDOW, which is itself a composition of WS_CAPTION, WS_THICKFRAME, WS_SYSMENU, WS_MINIMIZEBOX, and WS_MAXIMIZEBOX — the title bar, the resize border, the system menu, and the minimize/maximize buttons.
To get a true borderless look, the script needs to clear all of those bits and replace them with WS_POPUP and WS_VISIBLE. WS_POPUP says "I have no frame at all"; WS_VISIBLE says "stay on the screen." The official documentation on these constants lives in the Window Styles reference on Microsoft Learn, and it's worth bookmarking because every borderless trick eventually traces back to a single bit on that page.
The extended style usually doesn't need editing for borderless mode, but two flags are worth knowing: WS_EX_TOPMOST forces the window above all non-topmost siblings (useful for keeping a tiny chat window pinned over a fullscreened raid), and WS_EX_TOOLWINDOW removes the entry from the taskbar (handy when you're running ten clients and don't want ten taskbar buttons).
The geometry primitives
Why does the window sometimes look correctly borderless but stay stuck at the old size and origin until you drag it? That's the puzzle this section unwinds — and the answer is one specific flag. After the style is stripped, the window is shaped correctly but sitting in the wrong place: it's still at the old client area's origin and still measures whatever size the game launcher picked. The fix is a single call to SetWindowPos with the right combination of flags. SetWindowPos accepts a window handle, an "insertAfter" handle (the famous HWND_TOPMOST / HWND_NOTOPMOST / HWND_TOP), an X/Y, a width/height, and a uFlags bitmask. The SetWindowPos reference on Microsoft Learn lists every uFlag in plain English; the four worth memorizing are SWP_FRAMECHANGED, SWP_NOZORDER, SWP_NOACTIVATE, and SWP_SHOWWINDOW.
SWP_FRAMECHANGED is the crucial one. Without it, the window manager caches the old frame geometry, and the new WS_POPUP style doesn't take visible effect until the user drags or minimizes the window. Setting SWP_FRAMECHANGED forces a non-client area recalculation immediately, which is what makes the borderless transformation pop on the next paint.
Enumerating monitors
To target a specific display, the script needs to walk the monitor list and pick the right one. The EnumDisplayMonitors callback takes a function that the OS invokes once per monitor, passing a handle, a clipping rectangle, and a device-context handle. For most use cases, only the clipping rectangle matters — that's the monitor's bounding box in virtual-screen coordinates, where (0, 0) is the top-left corner of the primary monitor and other monitors live at positive or negative offsets depending on the Display Settings layout.
The pywin32 wrapper for EnumDisplayMonitors is in win32api. It returns a list of tuples rather than firing callbacks, which is much friendlier than the raw C call. Each tuple is (monitor_handle, dc_handle, rect_tuple), and the rect_tuple is (left, top, right, bottom). For a 2560x1440 monitor sitting to the right of a 1920x1080 primary, the rect will be (1920, 0, 4480, 1440) — note that the height ends at 1440 even though the primary is shorter, because monitors live in virtual coordinates, not stacked rows.
Getting set up
The dependency story is mercifully simple. pywin32 is the only thing you need beyond a standard CPython install. The package lives on PyPI as pywin32, and the canonical install is a single pip command. The upstream project, maintained by Mark Hammond, lives on the pywin32 GitHub repository and tracks every Python and Windows version pair worth supporting. After install, a post-install script registers a handful of COM helpers — usually unnecessary for window manipulation, but harmless to leave in place.
A typical environment for a borderless-MMO helper looks like this:
python -m pip install --upgrade pywin32
python -m pywin32_postinstall -install
The postinstall is optional. If it errors out because you're not in an elevated shell, ignore it — the window APIs we need ship in win32gui, win32con, and win32api, all of which are pure user-mode and need no admin rights.
Finding the MMO process window
The first concrete step in the script is finding the game's main window handle (HWND). Two strategies work well: walking the top-level window list and matching by window title, or matching by the process executable name. The first is faster to write; the second is more robust against title-bar mutations (some MMOs append the realm or character name to the title after login).
For title matching, win32gui.EnumWindows iterates every top-level window and accepts a callback. For executable matching, win32process.GetWindowThreadProcessId returns the PID associated with a window, which you can then resolve to an exe name through psutil.Process(pid).name(). Most MMO helper scripts pick title matching for v1 and add an exe-name fallback once they hit the title-mutation edge case.
The borderless transformation in code
Here's a minimal end-to-end script. It looks for a window whose title contains a substring you choose, strips the decorations, and snaps it to the second monitor in your virtual-screen layout. The script doesn't relaunch the game — it transforms an already-running window — so the user pattern is "launch the MMO from its normal launcher, alt-tab out, run the script, alt-tab back."
import win32api
import win32con
import win32gui
TARGET_TITLE_FRAGMENT = "World of" # tweak per MMO
TARGET_MONITOR_INDEX = 1 # 0 = primary, 1 = second
def find_window(fragment: str) -> int:
found: list[int] = []
def cb(hwnd: int, _: object) -> bool:
if win32gui.IsWindowVisible(hwnd):
title = win32gui.GetWindowText(hwnd)
if fragment in title:
found.append(hwnd)
return True
win32gui.EnumWindows(cb, None)
if not found:
raise RuntimeError(f"no window matched: {fragment}")
return found[0]
def monitor_rect(index: int) -> tuple[int, int, int, int]:
monitors = win32api.EnumDisplayMonitors(None, None)
if index >= len(monitors):
raise RuntimeError(f"only {len(monitors)} monitor(s) available")
return monitors[index][2]
def go_borderless(hwnd: int, rect: tuple[int, int, int, int]) -> None:
left, top, right, bottom = rect
width, height = right - left, bottom - top
style = win32gui.GetWindowLong(hwnd, win32con.GWL_STYLE)
style &= ~win32con.WS_OVERLAPPEDWINDOW
style |= win32con.WS_POPUP | win32con.WS_VISIBLE
win32gui.SetWindowLong(hwnd, win32con.GWL_STYLE, style)
win32gui.SetWindowPos(
hwnd,
win32con.HWND_TOP,
left, top, width, height,
win32con.SWP_FRAMECHANGED | win32con.SWP_NOZORDER | win32con.SWP_SHOWWINDOW,
)
if __name__ == "__main__":
hwnd = find_window(TARGET_TITLE_FRAGMENT)
rect = monitor_rect(TARGET_MONITOR_INDEX)
go_borderless(hwnd, rect)
print(f"transformed hwnd={hwnd} to rect={rect}")
Forty lines, no external dependencies beyond pywin32, no admin rights needed. Run it, the window snaps into place, and the game now thinks it's in a borderless windowed mode at exactly the monitor's native resolution.
Multiboxing across monitors
The same primitives generalize neatly to running several MMO clients side by side on different monitors. The standard pattern is to launch N clients (most MMOs allow multiple processes; a few require copies of the install directory), then run the script once per client with a different target monitor index and either a title disambiguator (log in as a different character to vary the title bar) or a PID list captured at launch.
Two practical issues come up. First, when two clients land on the same monitor and overlap, you usually want to set WS_EX_TOPMOST on the foreground one so input goes where you expect. Second, the input layer is its own can of worms — keystroke broadcasting across all client windows usually wants a separate input multiplexer (AutoHotkey, Inputmapper, or a custom raw-input forwarder), because pywin32 alone doesn't solve "type once, all clients receive it." The borderless geometry script is just the windowing half of the equation.
Pitfalls and caveats
Three classes of bugs will eat hours of debugging time if you don't know about them in advance.
First, DPI awareness. If your Python process isn't declared DPI-aware, Windows will lie to it about monitor resolutions on high-DPI displays — your 3840x2160 monitor will report 1920x1080, and the borderless snap will fill only a quarter of the screen. The fix is calling ctypes.windll.shcore.SetProcessDpiAwareness(2) near the top of the script, which opts into per-monitor DPI awareness.
Second, the order of style change and geometry change. Setting the new style without a follow-up SetWindowPos with SWP_FRAMECHANGED leaves the window in a half-transformed state: the new style is recorded internally but the non-client area isn't repainted until the next major event. Always set the style and call SetWindowPos in the same script run.
Third, anti-cheat. Most MMO anti-cheat systems (BattlEye, EasyAntiCheat, custom in-house) don't flag pywin32 window manipulation because it operates entirely outside the game process — no memory reads, no injection. But a small number of titles will refuse to run if any process is enumerating their windows or process info via Win32 calls, and a smaller number will refuse to run if any non-system process has WS_EX_TOPMOST set near the game window. Read the EULA of your target MMO before automating; window manipulation is the kind of grey-area tooling that's rarely banned in practice but is also rarely explicitly blessed.
Where to go from here
Once the basic borderless transformation works, three natural extensions present themselves. A monitor-picker GUI built with tkinter that lists displays by their adapter name, so you don't have to remember which index is which. A profile system that remembers per-MMO window titles and target monitor preferences in a small YAML file. And a hot-key launcher (using keyboard or pynput) that re-applies the borderless transformation on demand, because some MMOs reset their window state when the user toggles a graphics setting in-game.
The pywin32 docs are sparse, but the constants and function signatures map one-to-one onto the underlying Win32 API, so the official Microsoft documentation is the canonical reference for any flag or call signature. Bookmark the Window Styles and SetWindowPos pages, and you've got enough surface to script almost any windowing transformation an MMO multi-monitor rig requires.
Borderless windowed mode on the monitor you actually want is one of those small quality-of-life upgrades that, once it's installed, becomes invisible — you launch the game, alt-tab, run the script, and never think about windowing again. Forty lines of Python is a small price for that.