RPGDev's desktop window started life as a macOS-only Swift + WKWebView host. As of v0.6, the same overlay also runs on Windows (native) and inside WSL2 (the window appears on the Windows host). Everything except the window — the server, the hook CLI, the reducer, BGM/SFX generation, the web front-end — is cross-platform; only the desktop window needs a per-platform host, and on Windows/WSL2 the single shared hub runs on the Windows host (see below).
Status / honesty note. The single-hub WSL2 path is verified end-to-end on a real WSL2 box (Ubuntu 26.04): a Windows-host server (local files,
0.0.0.0) + a localhost-connected window + WSL2 hooks → the window correctly renders Explore (field), a battle (encounter→defeat), and Clear (town + resting hero). The macOS path is unchanged and remains the reference. Everything surfaces a clear error on failure (no silent fallback) — please file anything that breaks.
If you run RPGDev across both a native-Windows project and a WSL2 project (or two of either), they do not each spin up their own server and window. That used to cause three problems: both sides fought over port 37373, two windows appeared, and the WSL2 window could connect to the wrong server. Instead:
- One hub. Exactly one
nodeserver runs, on the Windows host, started from Windows-local files and bound to0.0.0.0. Binding all interfaces lets both the host window (localhost) and WSL2 (the host's WSL-adapter IP) reach the same server. The physical NIC stays closed by Windows Defender's default inbound block, so only loopback and the WSLvEthernetadapter (one inbound allow rule, below) actually reach it — no token, no real LAN exposure. - One window. The C# host's single-instance
Mutexuses a fixed key (rpgdev-hub) in theGlobal\namespace with an Everyone-allow ACL, so the dedup crosses Windows sessions: a Windows-native launch and a WSL2-interop launch can land in different Terminal-Services sessions, and a session-scopedLocal\mutex would let each think it is first and open its own window (two windows).Global\+ACL makes whoever launches second just bring the existing window forward (it falls back toLocal\only ifGlobal\can't be created). The window always connects vialocalhost:37373(it is always on the same host as the hub). - One adventure / one state. The hub's state lives in a single global directory
(
%LOCALAPPDATA%\rpgdev\hub), so all tool use — Windows or WSL2 — drives the same monsters, quests, and stages. Ownership of the quest list is decided by the reducer's existing multi-session arbitration (ownerSession). - Order-independent. Set up Windows first or WSL2 first — both converge on the same
reachable hub. Hook configs never bake in an address; each side resolves it at runtime
via
scripts/hub-net.mjs.
scripts/hub-net.mjs resolves three purpose-specific addresses (mixing them is what
broke earlier attempts):
| helper | meaning | win32 | wsl | darwin/linux |
|---|---|---|---|---|
hubBindHost |
address the server listens on (RPGDEV_HOST env) |
0.0.0.0 |
0.0.0.0 |
127.0.0.1 |
hubReachHost |
where this process reaches the hub (hook POST / health) | 127.0.0.1 |
gateway (host's WSL-adapter IP) | 127.0.0.1 |
HUB_WINDOW_HOST |
where the window connects | 127.0.0.1 |
127.0.0.1 |
127.0.0.1 |
So: the server listens on everything, the window always talks to localhost, and only
WSL2 hooks use the gateway IP. The port-collision / two-windows / wrong-server class of
bugs is designed out, not patched.
Run the server from local files, never off the
\\wsl.localhostshare. When the WSL2 path starts the hub, it copiesserver/andpublic/into%LOCALAPPDATA%\rpgdev\huband runs that copy with the hostnode.exe. Running the server script directly from the\\wsl.localhost\<distro>\…share makes the host WebView2 unable to receive the SSE stream (/events) — the window loads static assets but never gets live updates (background stuck, no BGM). Local-file execution (the same shape as a native-Windows server) fixes it. This is verified.
The Windows-native and WSL2 globals are separate installs of rpgdev. If only one
side gets updated, whichever side launches first starts its version of the hub, and the
other side's hooks/window then talk to a hub from a different release — symptoms look like
"my fix isn't live" or mismatched assets. Recovery, in order:
- Stop the other Claude/Codex sessions that are feeding the hub.
- Shut the hub down.
- Update both globals:
npm i -g rpgdev@latestin WSL, and from a Windows working directory (powershell.exe -Command 'Set-Location $env:USERPROFILE; npm i -g rpgdev@latest'— see releasing.md for why the Windows cwd matters, plus the EBUSY trap when the hub is still running). - Relaunch; both sides now converge on the same hub version.
| Platform | Window host | How it is built | Notes |
|---|---|---|---|
| macOS | Swift + WKWebView | swiftc on demand → .rpgdev/RPGDev.app |
unchanged reference path |
| Windows (native) | C# WinForms + WebView2 | csc.exe on demand → %LOCALAPPDATA%\rpgdev\hub\RPGDevWin\RPGDev.exe |
hub binds 0.0.0.0; window connects localhost |
| WSL2 | C# WinForms + WebView2 on the Windows host | csc.exe via interop → %LOCALAPPDATA%\rpgdev\hub\RPGDev.exe |
hub server (local-copied) + window both on the host; WSL2 hooks reach it via the gateway IP |
| bare Linux | — | — | desktop window not supported; use npm run web (browser view) |
The platform is chosen by scripts/desktop-platform.mjs (detectPlatform()):
darwin / win32 / wsl (linux + /proc/version contains microsoft, or
WSL_DISTRO_NAME / WSL_INTEROP is set) / linux. scripts/desktop.mjs
dispatches on it.
- WebView2 Evergreen Runtime. Preinstalled on Windows 11 and most Windows 10 machines. If missing, install the bootstrapper from Microsoft.
- .NET Framework 4.x C# compiler (
csc.exe). Ships with Windows at%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe. - Node.js 20+ on the Windows host. Required for native Windows and for WSL2: in
the single-hub model the server runs on the host, so the host needs
node.exe(the WSL2 path finds it viawhere node, elseC:\Program Files\nodejs\node.exe). Node inside WSL2 still runs the hook CLI.
The WebView2 SDK DLLs are bundled in desktop/webview2/ (no manual download).
The C# host creates the WebView2 controller directly on the window handle, so it needs exactly two files, both shipped with RPGDev:
Microsoft.Web.WebView2.Core.dll— managed Core wrapper (AnyCPU, .NET Framework 4.6.2).WebView2Loader.dll— native loader, x64.
Microsoft's distribution docs require shipping these two with the app, so they are committed to the repo (and the npm package). If removed, the build stops with a clear error.
arm64 Windows: swap in the
win-arm64WebView2Loader.dlland change/platform:x64to/platform:arm64inscripts/desktop.mjs(deferred / untested).
npm start (or the UserPromptSubmit hook → node scripts/desktop.mjs):
detectPlatform()→win32.ensureServer()starts the hub locally withRPGDEV_HOST=0.0.0.0andRPGDEV_PROJECT_DIR=%LOCALAPPDATA%\rpgdev\hub. If a hub already serves, it is a no-op.buildWinWindowIfNeeded()compilesdesktop/RPGDevWindow.csto%LOCALAPPDATA%\rpgdev\hub\RPGDevWin\RPGDev.exe(mtime-gated).- Launch
RPGDev.exe http://127.0.0.1:37373/overlay.html <webview2-data> <state.json> rpgdev-hub. - The fixed
rpgdev-hubMutex(in theGlobal\namespace, Everyone-allow ACL) enforces a single window across sessions (so a WSL2-interop launch and a Windows-native launch share one window); a second launch foregrounds the existing one.npm run build:desktopjust compiles and exits.
Hub runtime state lives in %LOCALAPPDATA%\rpgdev\hub — RPGDevWin/RPGDev.exe,
webview2-data/, desktop-window-win.json, and the server's state.json /
events.ndjson / playback.ndjson. Only per-launch error logs stay in the project's
own .rpgdev/.
- Always-on-top, no taskbar button, 4:3 kept on resize, min client 512×384, position restored across restarts.
- Resize quality. Flicker — Window-to-Visual hosting
(
COREWEBVIEW2_FORCED_HOSTING_MODE=…WINDOW_TO_VISUAL). Pixel-art sharpness — ZoomFactor re-rasterization (BoundsMode=UseRawPixels,RasterizationScale=1, integerZoomFactor) +image-rendering: pixelated+ integer-multiple scaling + letterbox. - Audio. No native bridge on Windows. BGM and SFX both play through the overlay's
<audio>elements (the unified<audio>path as of v0.6.5; macOS usesAVAudioPlayer). Autoplay via--autoplay-policy=no-user-gesture-required; if blocked, click♪once. The hub serves/audio/*.wavwith HTTP Range support (serveStatic→Content-Length+Accept-Ranges+206; range parsing inserver/http-range.mjs). This is required, not optional: WebView2/Chromium<audio>loads media via range requests, and serving the files chunked without a length made the 7 preloaded BGM elements hold connections so the later-ordered ones (dungeon-*/castle-*) never loaded and played silent (v0.7.6 fix). - Transparency. v1 keeps a normal titled window; letterbox bars are black. A frameless per-pixel-transparent overlay is deferred.
Alongside the window, scripts/desktop.mjs also builds and launches a small task-tray
resident (desktop/RPGDevTray.cs → RPGDevTray.exe, C# WinForms NotifyIcon, no
WebView2). Its job is to make hub liveness visible: it polls GET /health every 3s and
removes itself after 3 consecutive failures, so tray icon present = hub running,
gone = hub stopped. The icon is the water-spirit Aqua face, cropped at runtime from
public/assets/sprites/ally-water-facing-slit.png via System.Drawing (no external image
tool; --make-ico also writes a PNG-in-ICO). Right-click menu: Open window / Return to
town (POST /control/return-town) / Quit which stops the hub (POST /control/shutdown).
Single instance is a file lock rpgdev-hub.tray.lock in the hub dir (separate from the
window's Global\ mutex). Note: Windows hides newly-added tray icons in the overflow (^)
by default — drag it out to keep it visible.
Same on every platform: ask your AI agent to set it up (it runs rpgdev setup and merges
the result), or run rpgdev setup yourself. See install-hooks.md. The
target is <project>\.claude\settings.local.json or %USERPROFILE%\.claude\settings.json
(Claude Code user-global), or your Codex hooks file.
Why rpgdev setup matters on Windows: the Claude config is exec form
("command": "<node.exe>", "args": ["<…>\\rpg-hook.mjs", …]). Claude Code spawns exec-form
hooks without a shell, so a bare "command": "rpgdev-hook" does not resolve the
rpgdev-hook.cmd PATH shim — the hook silently never fires. The absolute-node form
sidesteps that. Run rpgdev setup in the environment the agent runs in.
The static
examples\*.jsonremain for manual copying but assume a global install.
rpgdev setup-shortcut adds a Start Menu entry so the hub can be launched without a
terminal. It writes %APPDATA%\Microsoft\Windows\Start Menu\Programs\RPGDev.lnk with the
Aqua-face rpgdev.ico (generated under %LOCALAPPDATA%\rpgdev\hub by the tray exe's
--make-ico mode); the shortcut target launches rpgdev. No admin needed, and it works
from WSL2 via interop. On macOS / bare Linux it is a no-op (skipped).
When Claude Code / Codex run inside WSL2, the hook runs in Linux, but both the hub
server and the window run on the Windows host — WSL2 does not run its own server. The
WSL2 side resolves the host's WSL-adapter IP (its default gateway) for hooks, and the
window connects via localhost. Automatically:
detectPlatform()→wsl.ensureWindowsHubFromWsl()makes sure the hub is up on the host: it checkshttp://<gateway>:37373/health, and if nothing answers it copiesserver/andpublic/into%LOCALAPPDATA%\rpgdev\hub(viawslpath-translated paths) and starts that copy with the hostnode.exe(where node, elseC:\Program Files\nodejs). Env crosses the WSL→Windows boundary viaWSLENV:RPGDEV_HOST=0.0.0.0,RPGDEV_PORT,RPGDEV_PROJECT_DIR=%LOCALAPPDATA%\rpgdev\hub. It waits for/health(on the gateway IP) before continuing. (The hook CLI delegates this tonode scripts/desktop.mjs --ensure-hub.)- The window is built on the host via interop (
csc.exe), into%LOCALAPPDATA%\rpgdev\hub. - It launches
RPGDev.exepointed athttp://127.0.0.1:37373/overlay.html— the window is on the host, so it talks to the hub overlocalhost(the same0.0.0.0hub that WSL2 hooks reach via the gateway IP).
WSLENV is required. Only variables listed in
WSLENVcross into the interop-spawned Windows process. Without it the hostnode.exefalls back to its defaults and the shared hub never comes up correctly.Why copy instead of running off the share. Running
server/rpgdev-server.mjsstraight from\\wsl.localhost\…makes the host WebView2 unable to receive/events(SSE) — the window shows static art but never updates. Copying to local files fixes it (verified).server/is small;public/is a few MB and is copied mtime-gated.
- A Windows-host firewall rule allowing WSL→host inbound on the hub port (see
"Firewall" below) — the one external dependency. Needed even with
0.0.0.0, because Defender's default inbound block otherwise drops the WSLvEthernettraffic. - Node.js on the Windows host (the hub runs there), plus the WebView2 runtime,
csc.exe, and the bundleddesktop/webview2/DLLs (copied across by the build). - WSL interop enabled (default) so
cmd.exe/csc.exe/ the hostnode.exe/ the built.exerun from Linux. localhostForwardingis not required. The window connects to the hub overlocalhoston the same host, and WSL2 reaches it over the gateway IP — nothing relies on the Windows→WSLlocalhostForwardingmechanism. Keeping the default is harmless.
Mirrored networking mode. This is for the default NAT mode. Under
networkingMode=mirroredhost and WSL2 sharelocalhostwith no separate gateway — setRPGDEV_HOST=127.0.0.1so both sides resolve the same loopback hub.
The single hub lives on the Windows host, so the host firewall must allow WSL2→host inbound
on the hub port. The easy, correct way is rpgdev setup-firewall — run it on the
Windows host (it asks for admin once). It applies a reboot-stable allow rule at both
firewall layers:
- Standard Windows Defender Firewall. WSL2→host arrives on the host's
vEthernet (WSL …)adapter as inbound, which Defender blocks by default. The rule is scoped by the WSL NAT source range-RemoteAddress 172.16.0.0/12, not by-InterfaceAlias: scoping by interface bakes the WSL adapter's GUID into the saved rule, and that GUID changes on every reboot, so an interface-scoped rule silently stops matching after a restart (hit on a real box, 2026-06-18). The NAT range is stable and still host-only — LAN/VPN are outside it. - Hyper-V firewall. On builds that expose
New-NetFirewallHyperVRule, the WSL vmCreator's default inbound is Block, so a matching allow rule is added there too. If either layer blocks, the hub is unreachable — that is why both are needed.
From WSL2 you can't raise the UAC prompt the change needs, so rpgdev setup-firewall run
inside WSL2 just tells you to run it on the Windows side (and exits non-zero so an agent can
branch). Verify from inside WSL2:
curl http://$(ip route show default | grep -oE '([0-9]+\.){3}[0-9]+' | head -1):37373/health
should return {"ok":true,...}. If it still fails, a VPN kill-switch (e.g. NordVPN) may be
blocking it — allow LAN/local in the VPN, or run wsl --shutdown from Windows to refresh
networking.
No new config and no baked-in address: the hook resolves the hub IP at runtime
(hubReachHost()), so the existing Linux examples — or rpgdev setup — are used
unchanged at ~/.claude/settings.local.json / your Codex hooks path. The same configs
work whether you set up Windows first or WSL2 first.
The hub binds 0.0.0.0 so the host window (localhost) and WSL2 (the gateway IP) can
both reach it. That is not a LAN exposure in practice: Windows Defender's default
inbound block keeps the physical NIC closed, and the only inbound allow rule is scoped to
the WSL NAT source range (-RemoteAddress 172.16.0.0/12). So the unauthenticated /hook and
the /control/* endpoints (e.g. /control/reset, and the tray's /control/return-town /
/control/shutdown) are reachable only from the host itself and from WSL2 — which is why
no token is needed. The window never uses a non-loopback address, so there is
no WebView2-to-private-IP fragility.
Verified on a real WSL2 box (the first three); the rest still wants eyes on a real machine:
- WSL2 → host reachability:
curl http://<gateway>:37373/healthreturns{"ok":true}(needs the firewall rule above). -
node scripts/desktop.mjs --ensure-hubfrom WSL2 reuses a running hub (no-op) and, from cold, copiesserver/+public/locally and starts a0.0.0.0hub. - WSL2 tool use drives the window: Explore (field bg), encounter→defeat, and Clear (town bg + resting hero) render with correct backgrounds/sprites and BGM/SFX.
-
npm run build:desktopcompiles the exe (the WebView2 DLLs are bundled). - Window chrome: always-on-top, no taskbar button, 4:3 on resize, min size, position restored, reset on display change.
- Resize: no flicker; pixel-art edges stay crisp (confirm on your WebView2 version).
- Single instance: a Windows launch and a WSL2 launch → one window (
rpgdev-hub). - Order independence: Windows-first then WSL2, and WSL2-first then Windows.
- Hooks: Claude/Codex resolve
rpgdev-hook, POST succeeds, window opens onUserPromptSubmit.