Automating games with the SendInput pipeline
by Fireal Software · ~7 min read
When I first tried to automate a Windows game with PyAutoGUI, none of the inputs registered. The script ran, mouse.click() returned, the log showed “clicked at (960, 540)” — and the game ignored it completely. Nothing happened on screen. It took me a day of reading about Win32 input APIs to figure out why, and the answer is the reason eyehands uses SendInput instead of the older calls that PyAutoGUI and most Python automation libraries use.
This post explains the difference, walks through which games eyehands actually works with, and ends with a working example of a simple single-player automation bot. It also includes the uncomfortable paragraph about anti-cheat and TOS that you should read before you write anything that touches a multiplayer game.
Why SendInput?
Windows has three generations of input APIs that accumulated over the years:
- **`SetCursorPos` + `mouse_event` / `keybd_event`** — the original Win32 calls from the '90s. Deprecated but still work. This is what PyAutoGUI, `pynput`, `pywinauto`, and most "click automation" libraries use under the hood. They inject events at the top of the input queue, which ordinary GUI applications (notepad, browser, Outlook) see just fine.
- **`SendInput`** — introduced in Windows 2000. Injects events into the same *Raw Input* queue that real hardware writes to. From the application's perspective, these events are indistinguishable from physical mouse and keyboard activity. This is the modern call.
- **`SendMessage` / `PostMessage`** — injects window messages directly, bypassing the input queue entirely. Works for some applications that listen to WM_KEYDOWN messages directly, but doesn't work for anything that reads from the device.
Most games use DirectInput or Raw Input to read mouse and keyboard, not the top-level message queue. They register as RawInput consumers so they get events before the OS wraps them in window messages. This is fast, gives them low latency, and supports features like pointer lock (where the OS hides the cursor and gives the game delta values). It also means that mouse_event-based injection doesn’t reach them — the events never make it into the RawInput queue.
SendInput does make it into the RawInput queue. That’s the whole reason eyehands uses it. The actual ctypes call is about 15 lines and looks like this:
import ctypes
from ctypes import wintypes
INPUT_MOUSE = 0
MOUSEEVENTF_MOVE = 0x0001
MOUSEEVENTF_ABSOLUTE = 0x8000
MOUSEEVENTF_VIRTUALDESK = 0x4000
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004
class MOUSEINPUT(ctypes.Structure):
_fields_ = [("dx", wintypes.LONG), ("dy", wintypes.LONG),
("mouseData", wintypes.DWORD), ("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD), ("dwExtraInfo", ctypes.c_void_p)]
class INPUT(ctypes.Structure):
_fields_ = [("type", wintypes.DWORD), ("mi", MOUSEINPUT)]
def send_mouse_move(dx, dy):
inp = INPUT(type=INPUT_MOUSE,
mi=MOUSEINPUT(dx=dx, dy=dy, mouseData=0,
dwFlags=MOUSEEVENTF_MOVE,
time=0, dwExtraInfo=None))
ctypes.windll.user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(inp))
eyehands wraps all this behind the /move, /click, /key, and /type_text endpoints, so you never touch ctypes. But knowing it exists is useful when you’re trying to figure out why a specific game works or doesn’t.
Which games actually work?
The short answer is: everything that reads input from the OS, at the layer the OS exposes it. The long answer is nuanced, because games have different reasons for rejecting synthetic input beyond “it didn’t reach them”:
| Game type | Works? | Notes |
|---|---|---|
| Single-player, no anti-cheat | Yes | Works reliably. This is the whole sweet spot. |
| Single-player, client-side anti-cheat (Denuvo, VMProtect) | Usually | These protect the binary, not the input. Input injection typically still works. |
| Online, server-side anti-cheat | No — and don't try | BattlEye, Easy Anti-Cheat, Vanguard. They explicitly flag synthetic input and you'll get banned. Also violates TOS. |
| Parsec remote sessions | Yes | Your local Parsec client sees the input and forwards it over the network to the host. The remote game receives real input events. |
| RDP / Remote Desktop sessions | Partial | RDP captures the top-level input queue but not necessarily the RawInput queue. Works for menus and productivity apps, flaky for games. |
| Steam games via Proton on Windows | Yes | Proton is Linux-only; on Windows Steam launches the native binary directly and eyehands sees it like any other app. |
| Browser games (Flash/HTML5) | Yes | Browser is a normal GUI app; input injection works like clicking any webpage. |
| VR headsets | No | VR input is a completely different pipeline (OpenXR, OpenVR). eyehands can't touch it. |
**The anti-cheat line.** If a game has BattlEye, Easy Anti-Cheat, Vanguard, FairFight, or any similar rootkit-level anti-cheat, *do not use eyehands on it*. Not because eyehands won't work — it might — but because you'll get permabanned the moment the anti-cheat signature matches. This includes PUBG, Fortnite, Valorant, CS2, Rainbow Six Siege, Apex Legends, and most other competitive online games. If you're not sure whether a game has anti-cheat, assume it does.
A simple click-farm bot for a single-player idle game
Idle games that need you to click a button repeatedly are a reasonable sandbox for learning the patterns. Pick any single-player idle/clicker game you own. This example finds a button labeled “Claim” via OCR and clicks it once a second with a bit of jitter, bailing out if anything unusual appears on screen:
import requests
import time
import random
import os
TOKEN = open(os.path.expanduser("~/AppData/Roaming/eyehands/.eyehands-token")).read().strip()
BASE = "http://127.0.0.1:7331"
AUTH = {"Authorization": f"Bearer {TOKEN}"}
def find(text):
r = requests.get(f"{BASE}/find", params={"text": text}, headers=AUTH)
return r.json()
def click_at(x, y):
r = requests.post(f"{BASE}/click_at",
json={"x": x, "y": y},
headers={**AUTH, "Content-Type": "application/json"})
r.raise_for_status()
def bail_if_error():
"""Abort if any error dialog shows up."""
for word in ("error", "disconnected", "update"):
result = find(word)
if result.get("found"):
print(f"Found {word!r} on screen — bailing.")
raise SystemExit(1)
print("Starting click loop. Ctrl+C to stop.")
loops = 0
while loops < 1000: # hard cap to prevent runaway
bail_if_error()
result = find("Claim")
if not result.get("found"):
print("No Claim button visible, waiting 2s...")
time.sleep(2)
continue
match = result["matches"][0]
click_at(match["x"], match["y"])
loops += 1
print(f"[{loops}] clicked at ({match['x']}, {match['y']})")
# Jitter between 400ms and 1200ms
time.sleep(0.4 + random.random() * 0.8)
Three things to notice:
- **Jittered sleep.** Games sometimes detect constant-interval clicking as bot behavior. Randomizing between 400-1200ms is enough to look organic in most single-player contexts.
- **The bail check.** Any loop that clicks forever needs an escape hatch. Scanning for "error", "disconnected", or "update" text catches most "something broke" states and stops the loop before it does damage.
- **The loop cap.** `while loops < 1000` is a safety net. If something goes wrong and the bail check doesn't trigger, the script stops on its own after 1000 iterations instead of running overnight.
For this to work, the game window must be the foreground window. eyehands can screenshot and read UIA on background windows, but mouse clicks go to wherever the OS thinks the cursor is pointing. If you alt-tab to Discord while the bot is running, the next click lands in Discord. There’s no way around this except either (a) dedicating the machine to the bot or (b) using Parsec to run the game on a second computer and automate the Parsec client.
Absolute vs relative coordinates
eyehands has two different ways to send a mouse event: /move_absolute (goes to a specific screen coordinate) and /move (moves the cursor by a delta). Which to use depends on whether the game has pointer lock:
- **No pointer lock** (menu-based games, clickers, idle games, most strategy games): use absolute. The cursor is visible and moves around the screen; absolute coordinates are easy to reason about.
- **Pointer lock** (first-person shooters, third-person action games): the OS hides the cursor and the game reads delta values from RawInput. Absolute moves don't do anything useful because the cursor "position" isn't what the game is reading. Use `/move` with dx/dy deltas.
eyehands’s /smooth_move endpoint (Pro) interpolates between two points with configurable steps and delay, which is useful for FPS-style aim adjustments where a hard jump would feel unnatural or be detected as synthetic by client-side heuristics.
Anti-cheat, TOS, and using your brain
Everything in this post is for single-player games you own, on your own machine. Automating a game is legally fine in that context — you’re not interacting with anyone else’s computer, not violating anyone else’s enjoyment, and not bypassing any active defense. It’s no different from writing a macro in your text editor.
Automating a multiplayer game is a completely different thing. It’s a TOS violation on every major platform. It ruins the game for other players. Anti-cheat systems will detect it and ban your account, and the detection works because every modern anti-cheat has a signature for SendInput-style injection from a foreign process. You’ll lose progress, you’ll lose access to the game, and you’ll have nothing to show for it.
If you want to automate anything in a game where other humans are playing, talk to the developers first. Some games have official API access for accessibility or mod tooling. Most don’t. If yours doesn’t, pick a different game.
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