JX Automation Input Throttle: Safe Loop Patterns for MMO Bots
Build a publisher-safe automation loop for JX MMO with jittered input pacing, ban-signal detection, and retreat triggers. Lua + Python patterns included.
JX Automation Input Throttle: Safe Loop Patterns
A bot that hammers SendInput 50 times a second is a bot that gets flagged in a week. The fix is not "send fewer inputs" — it is to design the entire loop around the assumption that the server, the client, and possibly a behavioral model are all watching the cadence. Throttle is the load-bearing primitive. Everything else (OCR, pathing, target selection) sits on top of it.
This piece walks through three patterns that belong in any JX automation loop running within publisher-permitted scope: jittered pacing, ban-signal detection, and retreat triggers. The examples are deliberately framing-agnostic — the same shape works whether the loop drives a fishing macro, a buff rotation, or a gather-and-return script.
Why throttle is not just a sleep call
A naive loop looks like this:
while running:
send_key("F1")
time.sleep(1.0)
Two problems. First, time.sleep(1.0) is exact — every keypress lands on a 1000ms boundary. Real human input has a distribution closer to a log-normal with a long tail: most keypresses cluster around a median, but there are occasional 2-3 second gaps when the player checks chat or sips coffee. Second, the loop has no concept of "something went wrong" — it sends F1 whether the character is alive, dead, in a cutscene, or staring at a GM warning dialog.
A safer loop separates three concerns: pacing (when to act), observation (what is on screen), and gating (should I act at all). The throttle becomes the schedule, not the entire loop.
Pattern 1: Jittered pacing with adaptive median
Replace fixed sleeps with a draw from a distribution. The simplest workable shape is a clipped log-normal centered on a "human-feeling" median for the action class.
import random
import time
def jittered_sleep(median_ms: int, sigma: float = 0.35,
min_ms: int = 80, max_ms: int = 4000) -> None:
# log-normal: mean shifted so median lands at median_ms
raw = random.lognormvariate(0, sigma)
delay_ms = int(median_ms * raw)
delay_ms = max(min_ms, min(max_ms, delay_ms))
time.sleep(delay_ms / 1000.0)
The sigma=0.35 produces a distribution where ~95% of samples fall within roughly 0.5× to 2× the median, with rare outliers reaching 3-4×. That outlier tail matters. A loop where every action is exactly within ±10% of a median is statistically obvious to anyone running a Kolmogorov-Smirnov test on action timestamps.
Tune the median per action class:
| Action class | Suggested median | Why | |---|---|---| | Spell-cast follow-up | 1200ms | GCD-bound, predictable in legit play | | Loot pickup | 600ms | Fast hand motion, low cognitive load | | Vendor click sequence | 2200ms | Reading prices, decision-making | | Map travel waypoint | 4500ms | Path-checking, terrain awareness |
Pick a median that matches what a tired-but-attentive player would actually do. A 200ms vendor-purchase loop is a flag even with perfect jitter.
Pattern 2: Ban-signal detection via OCR sampling
The loop must be able to detect that something has changed about the game state — specifically, signals that suggest a GM, a kick, or an anti-cheat flag. The cheapest reliable channel is OCR sampling of fixed screen regions.
A small Lua snippet using a publisher-permitted screen-capture binding plus an external OCR service:
local capture = require("screen_capture")
local http = require("http_client")
local DANGER_PATTERNS = {
"kicked", "disconnected", "report", "gm:",
"warning", "violation", "banned", "suspended"
}
function check_chat_region()
local img = capture.region(20, 880, 600, 200) -- chat box bounds
local text = http.post_ocr(img):lower()
for _, pattern in ipairs(DANGER_PATTERNS) do
if text:find(pattern) then
return pattern
end
end
return nil
end
Three points worth noting. The OCR sample interval should be slower than the action loop (every 5-10 seconds is enough — GM dialogs do not disappear in milliseconds). Run OCR on a separate coroutine or thread so it never blocks the input loop. And keep the danger pattern list short and high-precision; a noisy detector that triggers on any chat message containing "report" will cause the loop to retreat constantly during normal play.
A 1-second OCR cadence over a 600×200 region is roughly 80ms of CPU per check on a modest local OCR engine like Tesseract — fast enough that the loop's pacing is unaffected. Compare this to keystroke-injection-only bots that have no idea the GM is yelling at them: the OCR layer is a 3× safety improvement for ~3% extra CPU.
Pattern 3: Retreat triggers as a state machine
When a danger signal fires, the loop should not just stop — it should retreat. "Retreat" means a deterministic sequence: stop sending inputs, optionally execute a logout-or-portal-out keybind, log the event with a timestamp, and refuse to resume for a cooldown window.
Model this as a small state machine:
from enum import Enum
import time
class LoopState(Enum):
RUNNING = "running"
RETREATING = "retreating"
COOLDOWN = "cooldown"
HALTED = "halted"
class SafeLoop:
def __init__(self):
self.state = LoopState.RUNNING
self.cooldown_until = 0.0
self.retreat_count = 0
def on_danger(self, signal: str) -> None:
self.state = LoopState.RETREATING
self.retreat_count += 1
# publisher-permitted exit: portal stone, /logout
execute_safe_exit_sequence()
self.cooldown_until = time.time() + 1800 # 30 min
self.state = LoopState.COOLDOWN
if self.retreat_count >= 3:
self.state = LoopState.HALTED # operator must reset
def can_act(self) -> bool:
if self.state == LoopState.RUNNING:
return True
if self.state == LoopState.COOLDOWN and time.time() >= self.cooldown_until:
self.state = LoopState.RUNNING
return True
return False
The escalation matters. One retreat is normal — maybe a stranger said "report this guy" in chat and the OCR caught it. Two retreats in a session suggest the loop's behavior is being noticed. Three retreats means the operator should look at the logs before resuming. A loop that auto-resumes infinitely is a loop that walks itself into a permanent ban.
Putting it together
The full loop is shorter than you might expect:
loop = SafeLoop()
ocr_thread = start_ocr_watcher(loop.on_danger, interval_s=8)
while loop.state != LoopState.HALTED:
if not loop.can_act():
jittered_sleep(median_ms=2000)
continue
perform_next_action()
jittered_sleep(median_ms=1200)
Three layers, each independent: the OCR watcher runs on its own clock, the state machine answers "should I act," and the action loop only handles pacing. Each layer is testable in isolation — you can fake the OCR signal and verify the state transitions without touching the game.
When this approach falls short
Behavioral detection over input distribution is the obvious next escalation tier. A model that looks at click coordinates, mouse paths, and reaction-to-event latency will eventually flag any automation no matter how well-jittered the keystrokes are. If you need to defend against that, the OCR layer must extend to detect "you are being challenged" prompts (CAPTCHA-like overlays, GM whisper dialogs) and retreat before the challenge completes. There is no perfect input throttle; there is only a throttle that buys enough time to detect the next escalation.
Stay within what the publisher's TOS permits. The patterns above describe loop design; they do not authorize anything the publisher disallows.
References:
- https://www.python.org/dev/peps/pep-3148/
- https://github.com/tesseract-ocr/tesseract
- https://docs.python.org/3/library/random.html#random.lognormvariate
- https://github.com/love2d/love