Guides

Driving the Game From Outside

Status
Working. Verified end to end on 2026-07-27 against

Status: Working. Verified end to end on 2026-07-27 against 2026.06.26.1097863.1-Rel-i343-Meteorite-2606-CU2 (Steam).

Every experiment in this repository used to end at the same place: build the thing, then sit in front of the game and type at it. Load a level, alt-tab, run a command, read a number off the screen, write it down. That is slow, it is easy to get wrong, and it means no experiment can be repeated cheaply enough to be worth repeating.

This is the fix. The game becomes something a tool can drive: launch it, start a mission, run console commands, evaluate Lua inside the process, send keys and mouse, and take screenshots.

The shape of it

Three transports, because no single one reaches everything.

ReachesWhere
Bridge modLua and console commands inside the processmods/MJOLNIRBridge
capture.ps1What is on screentools/mcp/game/capture.ps1
input.ps1Menus, movement, anything needing real inputtools/mcp/game/input.ps1

tools/mcp/game/game.mjs is the tools themselves. server.mjs serves them over MCP and cli.mjs runs them from a shell. Both are thin, so anything one can do the other can too — which matters when something breaks and you need to run a single step by hand.

Prefer the bridge over screenshots. A value read by reflection is exact. A value read off pixels is a guess about pixels, and the guess is usually right, which is what makes it dangerous.

Setup

node tools/mcp/game/cli.mjs status

If it says the bridge mod is not installed:

.\scripts\install-bridge.ps1

That copies the mod into the game's UE4SS Mods folder, enables it in mods.txt above the Keybinds entry that has to stay last, and creates the directory the bridge talks through. Nothing shipped is modified; deleting the mod folder and its mods.txt line undoes all of it. Re-run it after editing the Lua — UE4SS reads mods from disk at startup, so copy and restart is the whole edit loop.

.mcp.json registers the MCP server. Claude Code loads it at session start, so a session already running will not see it until it restarts.

The tools

ToolDoes
game_statusInstall, process, bridge heartbeat, current world and pawn
game_launchStart it and wait until the bridge answers
game_quitClose it
game_displayWindow mode, or restore the user's own settings
game_consoleA UE console command through the local PlayerController
game_luaEvaluate Lua on the game thread, return what it printed
game_travelopen a level — see the hazard below
game_screenshotPhotograph the window
game_inputKeyboard and mouse
game_logTail UE4SS.log

game_lua is the one that matters. Tag values, ammo, pawn state and loaded assets are all reachable by reflection but not through any fixed command, so the useful shape is an eval rather than a menu of verbs. Helpers are pre-bound: mj.pc(), mj.pawn(), mj.world(), mj.find(class), mj.props(obj), mj.name(obj), mj.console(cmd). UE4SS reflection — FindAllOf, StaticFindObject, FName — is there too. The sandbox persists between calls, so a helper defined once stays defined.

Long scripts come from stdin:

node tools/mcp/game/cli.mjs lua --timeout=60000 < scan.lua

The wire

UE4SS's Lua has no sockets, so the bridge uses files both sides can open, in <install>/Meteorite/Binaries/Win64/ue4ss/mjolnir-bridge/. Length-prefixed bodies, LF only, so a body can contain anything including the header delimiter:

mjolnir-bridge 1
id 42
op lua
bytes 17
--
print("hello")

request.txt in, response.txt out, and status.txt as a heartbeat written about once a second. The heartbeat is what makes failures legible: now is stamped by the poll thread and refreshed by the game thread, so the gap between them says whether the game is hung, loading, or fine. A caller that times out can tell the difference between "the mod is not loaded" and "the game thread is busy", which are very different problems.

What is verified

Run on 2026-07-27, in this order, all of it without touching the keyboard:

  1. game_launch → Steam → title screen, bridge answering in about 90 seconds.
  2. game_screenshot → the title screen, captured by PrintWindow without stealing focus.
  3. game_input Enter, Enter → through login and into mission A30 from the resume slot.
  4. game_lua → 37,409 loaded objects enumerated, pawn and world named.
  5. game_consolestat fps, dispatched via the Kismet fallback, visible in the next capture.
  6. game_input → fire and reload, and the HUD reflected it.

The capture path is worth calling out. PrintWindow with PW_RENDERFULLCONTENT returns real frames from this D3D12 title, so screenshots do not need the window focused or on top. The CopyFromScreen fallback exists for when that stops being true, and the blankness check decides between them on evidence rather than assumption.

Screenshots default to 800 px wide, from a game launched at 1280x720. Cost scales with area, so the difference between 800 and 1600 is four times the tokens for the same frame; 800 is still comfortably enough to read HUD counters. Raise max_width for genuinely small text.

Hazards

open from the frontend menu crashes the game — and solo-to-solo travel does too. Verified: EXCEPTION_ACCESS_VIOLATION reading 0x1c, a couple of minutes into the load. open skips the setup the campaign flow performs. game_travel refuses from the frontend unless forced, but the 2026-08-02 resize experiment hit the identical crash travelling open a30 from inside mission A15, so treat solo-to-solo travel as unsafe too. Start missions through the menus with game_input: the resume slot is two Enters from launch, and mission select is Campaign → New Game → difficulty → mission → two Enters through the skulls screen (Enter is "start" there — Space toggles the hovered skull, so keep the cursor off Bandana).

The multiplayer notes had already flagged mjolnir_travel as unverified and recommended reusing the official campaign flow. This is that prediction coming true.

Long work on the game thread freezes the game. Walking every property of all 37,000 loaded objects ran past three minutes and the game was unresponsive throughout. It recovers, and the heartbeat keeps ticking because the poll thread is separate, but scans should be narrowed to candidate classes first.

game_input steals focus, and needs an unlocked machine. The game reads RawInput, which only sees what the OS input queue actually delivered, so posted messages do not work and the window has to be in front. Windows also refuses SetForegroundWindow to a process that does not already own the foreground, so the tool taps Alt and attaches to the foreground window's input queue to get around it, retrying as the window settles.

None of that helps if the workstation is locked, on a screensaver, or a disconnected Remote Desktop session: the interactive desktop is switched away, GetForegroundWindow returns 0, and no synthetic input can reach the game. RDP is the one that bites in practice — the session locks on idle timeout, and since the automation is doing the work nobody is touching the machine to keep it alive. Expect to hit this on any run longer than the timeout. game_input reports this as a focus warning rather than pretending it worked. Screenshots keep working throughout, because PrintWindow does not need focus — so a locked machine looks like "I can see the game but cannot touch it".

There is no reflection-driven substitute yet. BPFL_CampaignMenuHelpers exposes lobby setters (SetClientLobbyMission, SetClientLobbyDifficulty, StartCountdown) but no plain "start the mission" entry point, so starting a mission still needs real input. Finding one would make the whole harness immune to this, and is the single highest-value thing left to add.

Ammo is not in UE reflection. Neither the pawn, the first-person weapon actor, nor any HUD object holds the round counts the HUD displays; a scan for the literal reserve value across every loaded object found nothing. It lives in the Blam simulation, consistent with tag payloads being opaque blobs parsed natively. Read it from a screenshot, or find the native path.

game_launch edits GameUserSettings.ini to force windowed mode, because that is the only lever that works whatever route the game starts by — Steam's rungameid URL is not a reliable way to pass -windowed. The original is copied to GameUserSettings.ini.mjolnir-backup on the first change; game_display with mode: restore puts it back.

The Steam app id is 2806050, per apps/launcher and Steam's own appmanifest_2806050.acf. The README carried 2993530 for a while, which is a different app and gets "game not found" on launch; it has been corrected.