SendInput vs keybd_event: why your Python automation breaks in games
by Fireal Software · ~9 min read
If you’ve ever tried to automate a game, a Parsec remote desktop session, a full-screen browser, or pretty much any “pointer-lock” application with PyAutoGUI, pynput, or keyboard — you’ve probably hit the same wall I did. Your script sends clicks and keystrokes. The game ignores them. Not an error — it just… doesn’t see them.
The fix is a one-line change in how you call Win32. This post is about why that matters.
Two input pipelines
Windows has two input delivery pipelines, and most Python automation libraries use the wrong one.
The legacy pipeline: keybd_event and mouse_event
These are Win32 API functions from the Windows 3.0 era. They inject synthetic input into the window message queue of the foreground window. If the foreground window is a normal WinForms/WPF/Win32 app that reads input via WM_KEYDOWN / WM_LBUTTONDOWN, this works. PyAutoGUI, most versions of pynput, and the Python keyboard library all go through this path by default.
The critical thing: these APIs are deprecated as of Windows Vista. Microsoft’s documentation says:
This function has been superseded. Use
SendInputinstead.
But the deprecation was never enforced — old code still compiles, still runs, and still mostly works. That “mostly” is where the trouble lives.
The modern pipeline: SendInput
SendInput was introduced in Windows 2000 and has been the recommended API ever since. It injects input into the Raw Input pipeline — the same pipeline that real hardware uses. Every RawInput-consuming application sees the event, whether it’s the foreground window or not.
The critical difference: pointer-lock applications register as RawInput consumers. Games using DirectInput, XInput, or raw Win32 RawInput. Parsec remote desktop. Full-screen browsers capturing the cursor. Anything that calls RegisterRawInputDevices.
These apps explicitly do not read from the window message queue — they read from Raw Input because they want mouse deltas (for FPS aim) rather than absolute cursor positions. So when PyAutoGUI fires a mouse_event, the game genuinely doesn’t see it. There’s no bug — the event went into a queue the game isn’t reading from.
The SendInput fix
Here’s the minimal ctypes call that makes pointer-lock apps start working:
import ctypes
from ctypes import wintypes
# ---- Win32 constants ----
INPUT_MOUSE = 0
INPUT_KEYBOARD = 1
MOUSEEVENTF_MOVE = 0x0001
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004
KEYEVENTF_UNICODE = 0x0004
KEYEVENTF_KEYUP = 0x0002
# ---- ctypes structs ----
class MOUSEINPUT(ctypes.Structure):
_fields_ = [
("dx", wintypes.LONG),
("dy", wintypes.LONG),
("mouseData", wintypes.DWORD),
("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ctypes.POINTER(wintypes.ULONG)),
]
class KEYBDINPUT(ctypes.Structure):
_fields_ = [
("wVk", wintypes.WORD),
("wScan", wintypes.WORD),
("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ctypes.POINTER(wintypes.ULONG)),
]
class _INPUT_UNION(ctypes.Union):
_fields_ = [("mi", MOUSEINPUT), ("ki", KEYBDINPUT)]
class INPUT(ctypes.Structure):
_fields_ = [("type", wintypes.DWORD), ("union", _INPUT_UNION)]
SendInput = ctypes.windll.user32.SendInput
SendInput.argtypes = [wintypes.UINT, ctypes.POINTER(INPUT), ctypes.c_int]
SendInput.restype = wintypes.UINT
# ---- The actual move ----
def move_relative(dx, dy):
u = _INPUT_UNION()
u.mi = MOUSEINPUT(dx=dx, dy=dy, mouseData=0,
dwFlags=MOUSEEVENTF_MOVE, time=0, dwExtraInfo=None)
inp = INPUT(type=INPUT_MOUSE, union=u)
SendInput(1, ctypes.byref(inp), ctypes.sizeof(inp))
That MOUSEEVENTF_MOVE flag with dwFlags set — and running through SendInput rather than mouse_event — is what puts the event on the Raw Input pipeline where games can see it.
Why this isn’t just “use SendInput everywhere”
There are nuances:
Modifier key atomicity
If you press Ctrl+C by sending Ctrl down, then C down, then C up, then Ctrl up as four separate SendInput calls, the OS can interleave real keyboard events between them. A real Ctrl key press from the user might land between your synthetic Ctrl down and C down, and you’ll get weird behavior.
The fix is to send all four key events in a single SendInput call — as an array. SendInput guarantees atomicity: either all events land in order, or none do. This is what eyehands’ keypress() function does:
def keypress(vk, modifiers=[]):
events = []
# Press modifiers
for m in modifiers:
events.append(KEYBDINPUT(wVk=m, ...))
# Press target key
events.append(KEYBDINPUT(wVk=vk, ...))
# Release target
events.append(KEYBDINPUT(wVk=vk, dwFlags=KEYEVENTF_KEYUP, ...))
# Release modifiers (reverse order)
for m in reversed(modifiers):
events.append(KEYBDINPUT(wVk=m, dwFlags=KEYEVENTF_KEYUP, ...))
arr = (INPUT * len(events))(*[INPUT(type=INPUT_KEYBOARD, union=_INPUT_UNION(ki=e)) for e in events])
SendInput(len(events), ctypes.byref(arr), ctypes.sizeof(INPUT))
Unicode text input
KEYEVENTF_UNICODE lets you send arbitrary unicode characters through SendInput without them being interpreted as virtual keys. This is how you type “日本語” or emoji into a text field.
def type_text(text):
events = []
for char in text:
events.append(KEYBDINPUT(wVk=0, wScan=ord(char),
dwFlags=KEYEVENTF_UNICODE, ...))
events.append(KEYBDINPUT(wVk=0, wScan=ord(char),
dwFlags=KEYEVENTF_UNICODE | KEYEVENTF_KEYUP, ...))
arr = (INPUT * len(events))(...)
SendInput(len(events), ctypes.byref(arr), ctypes.sizeof(INPUT))
Multi-monitor absolute moves
SendInput with MOUSEEVENTF_ABSOLUTE takes coordinates in a 0-65535 range. Most samples normalize to the primary monitor’s bounds, which is wrong — you want MOUSEEVENTF_VIRTUALDESK, which normalizes to the union of all monitors. Otherwise, absolute moves to points on a second monitor land on the primary monitor instead.
What eyehands does
eyehands uses SendInput via ctypes for everything — mouse moves, clicks, scrolling, keyboard, text typing. All modifier+key combinations are atomic single SendInput calls. Absolute moves use MOUSEEVENTF_VIRTUALDESK with cached virtual-desktop metrics computed under Per-Monitor DPI v2 awareness.
The end result: if you’ve been trying to automate a game or a Parsec session with PyAutoGUI and wondering why nothing works, swap in eyehands and it’ll just work. Not because the code is more clever, but because it’s going through the input pipeline that pointer-lock applications actually subscribe to.
When PyAutoGUI is still fine
If you’re automating a normal WinForms/WPF/Electron app that runs in windowed mode and doesn’t use RawInput, PyAutoGUI and friends work fine. The problem only surfaces when the target uses Raw Input for mouse or keyboard — games, Parsec, full-screen captures, some remote desktop clients.
But given SendInput is a strict superset of mouse_event / keybd_event — every case where the old APIs work, SendInput also works — there’s no reason to keep using the legacy path. eyehands is SendInput-everywhere by design.
Install
pip install eyehands
eyehands --install-skill
eyehands
Links
- eyehands repo: https://github.com/shameindemgg/eyehands
- MSDN:
SendInputfunction: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendinput - MSDN:
mouse_eventdeprecation: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-mouse_event
*This post exists because I spent three hours debugging why PyAutoGUI wasn't clicking buttons in a Parsec session before realizing it was a RawInput issue. eyehands is, in large part, me writing down the fix so no one else has to spend those three hours.*
Give Claude eyes and hands on Windows
eyehands is a local HTTP server for screen capture, mouse control, and keyboard input. Open source with a Pro tier.
Try eyehands