Plugin developer guide
About 53 minutes to read
Moonlight plugins are small Lua scripts that run inside Moonlight's engine. A plugin can:
- replace or reshape any stage of the aim: targets, aim point, prediction, movement, recoil and mouse output;
- read the game screen (text, icons, colors) and react to what's on the HUD;
- press keys and mouse buttons through your mouse device;
- draw its own overlay on the preview;
- show live values and charts;
- work together with other plugins.
You don't need to rebuild the app: save the file, and Moonlight reloads it.
Note
Plugins are available in the AI edition. They are turned off in the Color edition.
#Contents
- Overview
- What you can build
- What plugins can't do
- How to read this guide
- Getting started
- Your first plugin
- The Plugins tab
- Core concepts
- Anatomy of a plugin
- How plugins run
- Coordinates, units and time
- API reference
- Hooks
- Context: t, aim and targets
- Settings
- Screen reading
- Input automation
- Aim control
- Overlays, monitors and charts
- State, time and math
- Guides
- Aim cookbook
- HUD and automation cookbook
- Case study: Killfeed Pro
- Designer
- Testing and debugging
- Performance and limits
- Sharing and the Marketplace
- Reference
- Known limitations
- Troubleshooting
#Overview
#What you can build
Aim and recoil
| You want to… | Use |
|---|---|
| Replace the aim movement with your own algorithm | override_aim_provider — see Hooks |
| Ignore some detections, or choose which target to lock | override_filter_targets, override_select_target |
| Move the aim point, or add your own lead | override_aim_point, override_prediction |
| Shape recoil compensation | override_rcs |
| Shape every mouse movement Moonlight sends | override_humanization, override_output |
| Build an aim provider without writing code | The Designer |
The screen and the HUD
| You want to… | Use |
|---|---|
| Read text: a killfeed, an ammo counter, a timer, a banner | ocr — see Screen reading |
| Spot an icon, a crosshair or a scope overlay | templateMatch |
| Measure a health bar, detect a colored banner | colorMatch |
| Count markers or pips of a color | colorContour |
| Hold Moonlight's aim off while something shows | pauseAim, disableAim, enableAim — see Aim control |
Automation
| You want to… | Use |
|---|---|
| Tap or hold a key when something happens | keyPress, setKey — see Input automation |
| Click or hold a mouse button, or move the mouse | click, setMouseButton, moveMouse |
| Run a timed sequence of presses | A small state machine on nowMs() — see Run a timed key sequence |
Overlays, diagnostics and teamwork
| You want to… | Use |
|---|---|
| Draw boxes, lines and labels on the preview | drawRectangle, drawText, drawScreenRectangle… — see Overlays |
| Show live values and charts in the Library | monitor, setChartData |
| Pass a value from one plugin to another | shared — see Keeping state |
| Behave differently per weapon class | Settings and enabled plugins are saved per class — see Weapon classes |
#What plugins can't do
Plugins run in a sandbox. They have no file, network or process access and can't load other code. They don't get the raw capture image or the detector's raw output. They get:
- the targets, settings and inputs listed in Context;
- answers about screen regions from the Screen reading functions.
They can send input only through the user's mouse device, not to controllers. Known limitations lists everything that isn't connected yet.
#How to read this guide
- New to Moonlight plugins: start with Your first plugin, then How plugins run.
- Writing an aim algorithm: read Coordinates, units and time before you write any math. Most broken aim plugins mix up pixels, mouse counts and clocks.
- Watching the HUD or automating keys: read Aim hooks and frame plugins, then Screen reading and Input automation.
- Looking something up: the API reference lists every hook, table field and function.
- Want working code: the Aim cookbook and the HUD and automation cookbook have complete plugins you can copy, and the Killfeed Pro case study walks through a shipped one.
Warning
Test one plugin at a time. Start with monitors only, then add movement, and verify live behavior on your own device before you share a plugin.
This guide matches the plugin runtime as of September 2026 (Lua 5.4).
#Getting started
#Your first plugin
This walkthrough builds a plugin that only shows information, so it can't move your mouse. Once it works, you'll know the whole loop: write, save, enable, read results.
#1. Create the file
Open Moonlight, go to Plugins → Scripter, pick a template and click New. Or create a file in Moonlight's plugins folder with any text editor; Folder in the Scripter (or Open Folder in the Library) opens that folder for you.
Name it my_monitor.lua and paste:
plugin_name = "My Monitor"
plugin_description = "Shows the aim key state and the context rate"
plugin_author = "Your name"
plugin_version = "1.0"
function on_frame(t_ms)
monitor("Aim key held", aim.input.aim_key)
monitor("Context FPS", aim.frame.fps)
end
What this does:
- The four
plugin_*lines are the plugin's name card in the Library. on_frameis a hook: Moonlight calls it up to 30 times a second.monitor(label, value)shows a live value next to the plugin in the Library.aimis the global context table: the latest aim state Moonlight published.
#2. Save and enable it
- Click Save in the Scripter (it validates and reloads the plugin), or click Reload All in the Library after saving from another editor.
- In Plugins → Library, tick the plugin to enable it.
- Select it in the list. Its monitors appear in the details panel. Hold your aim key and watch Aim key held change.
Note
Enabled plugins and their settings belong to the active weapon class. If you switch class and the plugin is off, enable it for that class too. See How plugins run.
#3. Add a setting
Settings are declared in a config table and appear as controls in the Library:
plugin_name = "My Monitor"
plugin_description = "Shows the aim key state and the context rate"
plugin_author = "Your name"
plugin_version = "1.1"
config = {
show_fps = { type="bool", default=true, label="Show context FPS", order=10 }
}
function on_frame(t_ms)
monitor("Aim key held", aim.input.aim_key)
if config.show_fps.value then
monitor("Context FPS", aim.frame.fps)
end
end
Read a setting with config.<key>.value inside a hook, never at file scope. See Settings.
#4. Next steps
- Move the mouse: write an aim provider.
- React to the HUD: pause aim while a banner shows, or read a number from the HUD.
- Understand when each hook runs: How plugins run.
- Something not working: Testing and debugging.
Warning
print() and log() show nothing. Use monitor() for all debug output.
#The Plugins tab
Everything plugin-related lives on the Plugins page, which has three sub-tabs.
#Library tab
Manage installed plugins.
- Enable or disable a plugin with its checkbox. This applies to the active weapon class.
- Select a plugin to see its settings, its monitors and charts, and any enable or runtime error.
- Reload All re-reads every file in the
pluginsfolder after you edit files outside Moonlight. - Open Folder opens the
pluginsfolder.
Enabling a plugin makes its hooks eligible to run. An aim provider also has to be selected: on the Aim page, set the aim engine to Script and choose your plugin as the provider. Enabling a provider plugin alone doesn't make Moonlight use it.
#Scripter tab
A code editor for .lua files.
| Button | What it does |
|---|---|
| New | Starts a new plugin from the template picked next to it |
| Save (Ctrl+S) | Validates, saves and hot-reloads the plugin into the Library |
| Save As | Saves under a new file name |
| Validate | Compiles the script in the sandbox and reports errors |
| Reload | Discards unsaved edits and reloads the file from disk |
| Delete | Deletes the plugin file (a backup is kept) |
| Folder | Opens the plugins folder |
Note
Validate checks that the script compiles and that Moonlight can read its settings. It doesn't prove that a hook gets called, or that your mouse device supports an output command. Test live.
#Designer tab
A node editor that generates an aim provider for you. Connect blocks, wire the result to Output, and click Generate & Save. The generated file is a normal plugin: enable it and select it as the Script provider like any other. See Designer.
#The plugins folder
Plugins are plain .lua files in Moonlight's plugins folder. Use Open Folder (Library) or Folder (Scripter) to find it; don't assume a path. Moonlight ships example plugins there (PID, One Euro filter, velocity lead, Bézier, curve, FOV deadzone and more). Open them in the Scripter to see complete, working plugins.
Keep a plugin's file name and its setting keys stable. Saved settings are matched by them. Renaming a setting key without a migration loses the user's saved value; see renamed_from.
#Core concepts
#Anatomy of a plugin
A plugin is one .lua file with four kinds of content: a name card, settings, private state, and hooks.
-- 1. Name card: plain top-level assignments, read by the Library without running the file.
plugin_name = "Anatomy Example"
plugin_description = "Shows every part of a plugin"
plugin_author = "Your name"
plugin_version = "1.0"
aim_use_humanization = true -- optional; only matters for aim providers
-- 2. Settings: a literal table. Each entry becomes a control in the Library.
config = {
gain = { type="number", default=0.5, min=0.0, max=2.0, label="Gain", order=10 },
}
-- 3. Private state: file-scope locals live as long as the plugin stays loaded.
local calls = 0
-- 4. Hooks: functions with reserved names that Moonlight calls.
function override_output(dx, dy)
calls = calls + 1
monitor("Output calls", calls)
local g = config.gain.value
return dx * g, dy * g
end
#The name card
| Declaration | Meaning |
|---|---|
plugin_name | Display name. Recommended; without it the file name is used. |
plugin_description | One-line explanation shown in the Library. |
plugin_author | Your name or handle. |
plugin_version | A version string, e.g. "1.2". |
aim_use_humanization | For aim providers: the default of Script Aim's Apply our humanization option (true if absent). The user can change it. |
capabilities | The permissions the plugin needs when distributed through the Marketplace, e.g. {"vision.color", "input.write"}. See Sharing and the Marketplace. |
Write these as plain literal assignments at the top level. Moonlight reads them, and the config table, from the file's text without running your code. Values computed at run time (plugin_name = "My " .. suffix) aren't picked up.
#Declaring settings
The config table declares your settings. Moonlight turns it into controls, saves the user's values per weapon class, and exposes each value as config.<key>.value. The full list of types and fields is in Settings.
#Private state
Anything you keep between calls goes in file-scope local variables, or in the pre-made state table. It lasts until the plugin is disabled or reloaded. To share values with other plugins use shared. See State, time and math.
#Defining hooks
Hooks are global functions with reserved names. Define only the ones you need. Moonlight calls each at a specific point in its pipeline; which hook runs when, and how often, is explained in How plugins run. The full list is in Hooks.
Warning
Hook names are case-sensitive, and there's no on_init, on_shutdown or on_target_lost. Code at file scope runs once when the plugin loads; that's your initialization.
#Load order inside a file
When a plugin loads, Moonlight:
- Creates a fresh, sandboxed Lua 5.4 state for it.
- Runs the file from top to bottom. This defines your hooks and settings table.
- Fills each
config.<key>.valuefromdefault, then applies the user's saved values. - Starts calling your hooks.
So config.<key>.value doesn't exist yet while the file body runs. Read settings inside hooks.
#How plugins run
Plugins run inside Moonlight's native aim engine, not in the user interface. Each plugin gets its own isolated Lua 5.4 state. The engine calls your hooks at fixed points in its pipeline, on the thread that owns that point.
#The pipeline
For every new detector frame, the engine goes through these stages. Hooks let you change each one:
Detections → Filter targets → Select target → Aim point → Prediction
→ Aim movement → Recoil → Humanization → Output → Mouse device
| Stage | Hook |
|---|---|
| Filter targets | override_filter_targets |
| Select target | override_select_target |
| Aim point | override_aim_point |
| Prediction | override_prediction |
| Aim movement | override_aim_provider (only when your plugin is the selected Script provider) |
| Recoil | override_rcs |
| Humanization | override_humanization |
| Output | override_output |
on_frame runs beside the pipeline, not in it. It's where plugins that watch the HUD, press keys or draw overlays do their work; see Aim hooks and frame plugins.
#When each hook runs
| Hook | Thread | Called |
|---|---|---|
override_filter_targets, override_select_target | Engine loop | Every detector frame that has candidates |
override_aim_point | Engine loop | Every fresh frame with a target |
override_prediction | Engine loop | Every fresh frame with a target, even with built-in prediction turned off |
override_aim_provider | Engine loop | Every fresh frame while Script Aim is on, your plugin is the selected provider, the aim key is held and a target is in the FOV |
override_rcs | Output thread (mouse); engine loop (controller) | When queued movement is sent while recoil control runs, plus one final call with active = false when it stops |
override_humanization, override_output | Output thread | When queued mouse movement is sent. Not called while aim is paused. |
on_frame | Its own thread | Up to 30 times per second while Moonlight is running, with or without a target and whether or not the preview is open. Missed ticks are merged, not queued. |
Rates depend on the user's capture and detector speed, so they aren't fixed. Build everything on elapsed time (see Coordinates, units and time), never on a call count.
#Aim hooks and frame plugins
Plugins come in two shapes, and one plugin can be both.
| Aim-path plugins | Frame plugins | |
|---|---|---|
| Hooks | override_* | on_frame |
| Runs | At a pipeline stage, when that stage runs: with a target, while output is sent, and so on | Up to 30 times a second while Moonlight is running, target or not |
| Typical work | Change targets, aim points, movement, recoil, output | Read the screen, press keys, draw overlays, publish values |
| Returns | New values for its stage | Nothing |
| Used with | The hook's arguments and aim | Screen reading, Input automation, Aim control, Overlays, shared |
A frame plugin doesn't move the aim itself. To make the HUD change how the aim behaves, publish what you see in shared and read it from an aim-path hook, in the same plugin or another one (example). Or hold Moonlight's aim with pauseAim.
on_frame stops when Moonlight stops. It doesn't run while Moonlight is only open and not started.
#One plugin call at a time
All plugin calls, from every plugin, go through a single lock. While one hook runs, the aim loop, the mouse output and on_frame wait for it. A slow hook doesn't just slow your plugin; it adds latency to all aim and output.
- Keep each call short: no big loops, no large tables built per call, no string building per frame.
- Each call has an instruction budget (1,000,000 Lua VM instructions). A call that exceeds it is stopped with an error.
- After three consecutive errors, Moonlight disables that one hook. Your other hooks keep running. Reload the plugin after fixing it.
More in Performance and limits.
#Several plugins at once
| Stage | With several enabled plugins |
|---|---|
| Aim point, prediction, recoil, humanization, output | Chained: each plugin receives the previous plugin's result, in enabled order. |
| Filter targets, select target | Only the first enabled plugin that defines the hook runs it, not all of them. |
| Aim movement | Exactly one provider: the one selected on the Aim page. |
Don't rely on the order of rows in the Library to control chaining.
#Lifecycle
- Load: runs when a plugin is enabled, saved from the Scripter, or reloaded. Your file body runs once, then settings are applied.
- Settings changes apply live: the next call sees the new
config.<key>.value. - Disable or reload throws away the plugin's Lua state (your locals and
state). Moonlight releases any mouse buttons or keys your plugin still holds. - Errors in the file body make enabling fail; the Library shows the error.
#Weapon classes
A plugin's enabled state and its settings belong to the active weapon class, and so does the Script Aim provider choice. When the class changes (by hand, by a hotkey or by weapon detection), Moonlight applies that class's plugin set and settings. That means:
- A plugin can be on for Rifles and off for Snipers, with different settings in each.
- Your plugin can be switched off, and its state reset, in the middle of a fight. Don't assume continuity across class switches.
aim.weapon.classtells you the active class.
#What the engine does for you
With a Script aim provider, Moonlight still detects, tracks and selects the target, applies the bone and offset choice, and gates output (engagement, FOV, safety limits, optional humanization). Your provider only decides how to move toward the point it's given.
#Coordinates, units and time
Most broken aim plugins come from mixing up three things: where a point is, what a movement means, and which clock a time comes from. Read this page before you write any aim math.
#Coordinates
| Value | Space |
|---|---|
t.target.x/y, t.crosshair.x/y, candidate x/y | Aim space: pixels of the image the detector sees (the capture crop). Not desktop pixels. |
t.target.width/height, distance | Pixels in the same aim space. |
Vision regions x1, y1, x2, y2 | Capture-frame pixels: the full, uncropped capture image, 0, 0 at its top-left. Corners, not x/y/width/height. |
drawCircle, drawRectangle, drawLine, drawText | "crop" (default, aim space) or "screen" (capture-frame pixels) via the last argument. |
drawScreenRectangle, drawScreenText | Capture-frame pixels, the same as vision regions. |
Aim space and capture-frame pixels are different spaces. The aim crop is usually a part of the capture frame, so an aim-space point isn't a capture-frame point.
Target error is simply target − crosshair in pixels:
local ex = t.target.x - t.crosshair.x -- positive: target is to the right
local ey = t.target.y - t.crosshair.y -- positive: target is below
t.target.x/y is Moonlight's resolved aim point: the target with the user's bone and offset choice already applied, before built-in prediction lead. t.target.center_x/center_y is the box center, which is a different point.
#Units
| Quantity | Unit |
|---|---|
| Positions, sizes, errors | Pixels (aim space) |
Target velocity (velocity_x/y) | Pixels per second |
Movement you return from override_aim_provider, override_humanization, override_output, override_rcs | Relative mouse counts |
moveMouse(dx, dy) | Relative mouse counts (rounded to integers) |
t.frame.delta_ms | Milliseconds |
| Confidence | 0 to 1 |
Pixels become mouse counts through the user's sensitivity. t.settings.sens is the active sensitivity scale. Apply it once, where you convert pixel error into movement:
local dx = ex * fraction * t.settings.sens
Don't apply it again in a later stage, and don't hide a units mistake behind a huge or tiny gain.
Note
Returning a movement is a request, not a promise of one mouse report. Moonlight still applies engagement, output gates and optional humanization, and a device can split or merge reports.
#Time and clocks
Moonlight hooks use two different clocks. Only compare times that come from the same clock.
| Value | Clock |
|---|---|
nowMs() | The system's steady clock, in milliseconds. |
aim.frame.time | The same clock as nowMs(). |
on_frame(t_ms) | Milliseconds since the engine started. |
override_rcs(…, now_ms) | Milliseconds since the engine started. |
t.frame.delta_ms, aim.frame.delta_ms | Milliseconds between aim context updates (a duration, not a time). |
So inside on_frame, measure elapsed time either with t_ms values only, or with nowMs() values only. Never subtract one from the other.
local last_press = nil -- a nowMs() value
function on_frame(t_ms)
local now = nowMs() -- use nowMs() for everything below
if last_press == nil or now - last_press > 1000 then
-- at most once per second
last_press = now
end
end
#Build on elapsed time, not call counts
Hook rates change with the user's capture rate and detector speed. Anything that must feel the same on every setup, such as smoothing, ramps, cooldowns or filters, has to use elapsed time:
-- Frame-rate independent exponential approach
local dt = math.max(0, t.frame.delta_ms) / 1000 -- seconds
local fraction = 1 - math.exp(-config.response.value * dt)
A fixed "move 20% per call" feels completely different at 60 and at 240 calls per second.
Never busy-wait or try to sleep inside a hook. You'd hold the engine lock (see How plugins run).
#API reference
#Hooks
Hooks are global functions with reserved names. Define the ones you need; Moonlight calls them at the points described in How plugins run.
#Return rules
Hooks that return a point or a movement must return exactly two finite numbers:
return dx, dy
An array return {dx, dy} also works. A table with named fields ({x=dx, y=dy}) does not.
| You return | Result |
|---|---|
| Two finite numbers | Used as the stage's new values. |
nil (or nothing) from a modifier hook | The stage keeps its incoming values, as if your hook weren't there. |
nil from the active aim provider | No provider movement for this call. Moonlight does not fall back to its built-in aim. |
0, 0 | A real zero: no movement (or the point 0,0 from a point hook). |
| NaN, infinity, or a non-number | Rejected; the incoming values are kept. |
Important
Never return a third value. The reader takes the last two numbers returned, so return dx, dy, something is read as dy, something.
Hooks receive copies. Changing a table you were given, or the global aim table, doesn't change Moonlight's state or settings.
#override_aim_provider(t)
Your own aim movement. Returns relative mouse movement dx, dy.
- Called only while Script Aim is on, this plugin is the selected provider, the aim key is held and a target is in the FOV.
tis the provider context: the target point, crosshair, frame timing, settings and inputs.- Moonlight has already selected the target and applied the bone/offset choice. You only decide the movement.
function override_aim_provider(t)
local dt = math.max(0, t.frame.delta_ms) / 1000
local fraction = 1 - math.exp(-6 * dt)
return (t.target.x - t.crosshair.x) * fraction * t.settings.sens,
(t.target.y - t.crosshair.y) * fraction * t.settings.sens
end
Only one provider is active at a time; providers don't chain.
#override_filter_targets(targets)
Decide which detections count. Return an array of the candidate tables you keep.
targetsis a normal 1-based array of candidate tables.- Return the original tables (don't build new ones), and keep their
_idxfield untouched. - Filtering chooses survivors only. It doesn't change box geometry, and the order of your returned array doesn't pick the target.
- Only the first enabled plugin that defines this hook runs it.
function override_filter_targets(targets)
local kept = {}
for _, c in ipairs(targets) do
if c.confidence >= 0.5 then kept[#kept + 1] = c end
end
return kept
end
#override_select_target(targets, current_lock)
Choose the target. Return one candidate table from targets, or nil to let Moonlight choose.
current_lockis the currently locked candidate table, ornil. It's a table, not an ID.- Without a selection, Moonlight keeps a surviving lock or picks the nearest survivor.
- Only the first enabled plugin that defines this hook runs it.
Warning
A selection beats a filter. If your filter returns an empty list, a selector can still be handed the original candidates. Don't use "filter everything out" as a safety switch when a selector is also enabled.
#override_aim_point(target)
Move the point Moonlight aims at. Receives a target table; returns aim-space x, y.
Later processing, such as bone and offset handling, can still affect the final destination.
#override_prediction(target, aim_x, aim_y)
Replace or adjust prediction. Receives the target and the current aim point; returns the predicted x, y in aim space. Called even when Moonlight's built-in prediction is off, so you can supply your own lead.
#override_rcs(rcs_x, rcs_y, active, now_ms)
Shape recoil compensation before it's merged with other movement. Returns replacement recoil x, y in mouse counts.
- Called while recoil compensation is running, plus one final call with
active = falsewhen it stops. Reset any recoil state on that call. now_mscounts milliseconds since the engine started, notnowMs()time. See Time.
#override_humanization(dx, dy)
Modify outgoing mouse movement. Returns the modified dx, dy. Runs on eligible mouse output, not controller sticks.
#override_output(dx, dy)
The last plugin stage before the mouse device, after override_humanization. Returns the modified dx, dy. Moonlight's own safety limits still apply after it.
#on_frame(t_ms)
Work beside the pipeline: reading the screen, pressing keys, drawing overlays, updating monitors, publishing values in shared, timed actions. No return value.
- Called up to 30 times a second on its own thread, while Moonlight is running. It runs with or without a target, and whether or not the preview is open.
- Missed ticks are merged, so don't count calls. Measure time instead.
t_msis milliseconds since the engine started. It's a time, not a delta, and it's a different clock fromnowMs().- Shapes you draw here stay on screen until the next
on_frame.
function on_frame(t_ms)
local r = colorMatch("banner", 800, 40, 1120, 90, "#ff3b3b", 30)
shared["acme_banner"] = r ~= nil and r.percent > 2
end
#Not available
| Name | Status |
|---|---|
override_smoothing(cur_x, cur_y, tgt_x, tgt_y, delta_ms) | Recognized, but the current aim pipeline never calls it. Use a provider or override_output. |
on_init, on_shutdown, on_target_lost | Don't exist. Use file-scope code for setup, and detect changes (for example a new t.target.id) yourself. |
#Context: t, aim and targets
Hooks see Moonlight's state through three kinds of tables: the provider context t, the global aim table, and candidate tables. Coordinates and units are explained in Coordinates, units and time.
#Provider context t
Passed to override_aim_provider(t).
| Field | Meaning |
|---|---|
t.target.x, t.target.y | The resolved aim point: target with the bone/offset choice applied, before built-in prediction lead. |
t.target.center_x, t.target.center_y | Center of the target's box. |
t.target.width, t.target.height | Box size in pixels. |
t.target.distance | Distance from the crosshair to the aim point, in pixels. |
t.target.velocity_x, t.target.velocity_y | Moonlight's motion estimate, in pixels per second. |
t.target.confidence | Detection score, 0–1. |
t.target.class_id | Detector class ID. |
t.target.id | Tracked target ID. Changes when the lock moves to another target. |
t.crosshair.x, t.crosshair.y | Crosshair position. |
t.frame.delta_ms | Time since the previous context update, in milliseconds. |
t.frame.fps | 1000 / delta_ms. Not the mouse report rate. |
t.settings.sens | Active sensitivity scale. Apply once when converting pixels to counts. |
t.settings.smooth_x, t.settings.smooth_y | The class's smoothing values. |
t.settings.fov | Active FOV radius. |
t.input.lc | Left button (fire) state as a number. Test t.input.lc ~= 0. |
t.input.aim_key, t.input.trigger_key, t.input.flick_key | Activation key states (booleans). |
Warning
t.input.lc is a number, and in Lua 0 is true. Write if t.input.lc ~= 0 then, never if t.input.lc then.
t.frame.time, t.frame.number and t.weapon aren't filled in t. Use the global aim.frame and aim.weapon instead. t.input.rc, mc, bb and fb exist but are always 0.
#Global aim
The latest context Moonlight published. Readable from any hook.
| Table | Fields |
|---|---|
aim.target | x, y, center_x, center_y, width, height, distance, velocity_x, velocity_y, confidence, class_id, id |
aim.crosshair | x, y |
aim.frame | delta_ms, fps, time (milliseconds on the nowMs() clock), number (update counter) |
aim.settings | sens, smooth_x, smooth_y, fov |
aim.input | lc (number), aim_key, trigger_key, flick_key; rc, mc, bb, fb are always 0 |
aim.weapon | name, class: both hold the active weapon-class name |
aim.mouse | x, y: always 0, not mouse telemetry |
Note
aim can be stale. When no new context has been published, it still holds the last one, and there's no aim.has_target flag. A non-zero aim.target.id from earlier doesn't mean a target is visible now. Inside a hook, prefer the hook's own arguments.
#Target table
Passed to override_aim_point(target) and override_prediction(target, aim_x, aim_y). Same fields as t.target.
#Candidates
Each entry in override_filter_targets(targets), override_select_target(targets, current_lock), and current_lock itself:
| Field | Meaning |
|---|---|
x, y | Candidate center in aim space. |
width, height | Box size in pixels. |
distance | Distance from the crosshair, in pixels. |
confidence | Detection score, 0–1. |
class_id | Detector class ID. |
id | Tracked target ID. |
_idx | Moonlight's reference for this call. Leave it untouched and never keep it across calls. |
velocity_x, velocity_y | Always 0 in candidate lists. Don't filter on them. |
Return the tables you were given; Moonlight matches them by _idx.
#Settings
Declare settings in a top-level config table. Moonlight shows each entry as a control in the Library, saves the user's value per weapon class, and gives it to your code as config.<key>.value.
#All setting types
config = {
heading = { type="section", label="Response", order=10 },
note = { type="label", label="Settings apply to the active class", order=20 },
speed = { type="number", default=0.25, min=0.0, max=1.0,
label="Speed", tooltip="How quickly the aim closes the gap", order=30 },
limit = { type="int", default=10, min=1, max=100, label="Limit", order=40 },
enabled = { type="bool", default=true, label="Enabled", order=50 },
mode = { type="enum", default="soft", options={"soft", "firm"}, label="Mode", order=60 },
caption = { type="text", default="Demo", label="Caption", order=70 },
tint = { type="color", default="#a78bfa", label="Color", order=80 },
error_chart = { type="chart", label="Target error", order=90 },
}
| Type | Control | .value |
|---|---|---|
number | Slider in steps of 0.01 | number |
int | Slider in steps of 1 | integer |
bool | Checkbox | true or false |
enum | Dropdown of options | string |
text | Text box | string |
color | Color picker | hex string, e.g. "#a78bfa" |
section | Heading | none |
label | Explanatory text | none |
chart | Live chart fed by setChartData | none |
| Field | Used by | Meaning |
|---|---|---|
type | all | One of the types above. |
label | all | Text shown next to the control. |
default | value types | The starting value. |
min, max | number, int | Slider range. |
options | enum | List of strings. |
tooltip | all | Hover help. |
order | all | Sort position. Use unique, increasing numbers. |
renamed_from | value types | Earlier key name(s) to migrate a saved value from. |
Other fields (step, precision, conditional visibility) aren't supported.
Warning
Write config as a literal table with plain numbers, strings, booleans and string lists. Moonlight reads it from the file's text. Computed values (max = 2 * base) aren't understood.
#Reading values
Read .value inside a hook, every time:
function override_output(dx, dy)
local g = config.speed.value -- always the current value
return dx * g, dy * g
end
- Values don't exist while the file body runs; they're filled in after it. Don't read
.valueat file scope. - Don't copy a value into a file-scope local if you want live edits to take effect.
- If you build an object from settings (a filter, a PID controller), rebuild it when the settings change. The PID recipe shows how.
#Booleans
false is a real value. config.enabled.value or true turns a user's false into true. If you need a fallback, test for nil:
local enabled = config.enabled.value
if enabled == nil then enabled = config.enabled.default end
#Renaming a setting
Saved values are matched by key. To rename a key without losing users' saved values, list the old name(s) in renamed_from:
config = {
response = { type="number", default=4.0, min=0.1, max=12.0, label="Response",
renamed_from={"speed", "legacy_speed"}, order=10 },
}
#Chart settings
A chart setting is a live graph. Feed it with setChartData(<key>, number) from any hook. Up to 120 recent points are kept.
config = { error_chart = { type="chart", label="Horizontal error (px)", order=10 } }
function override_aim_provider(t)
setChartData("error_chart", t.target.x - t.crosshair.x)
return nil
end
#Screen reading
Plugins can look at the game screen through four functions:
| Function | Answers |
|---|---|
ocr | What text is in this region? |
templateMatch | Is this picture (an icon, a crosshair, a scope overlay) in this region, and where? |
colorMatch | How much of this region is this color? |
colorContour | How many separate blobs of this color are in this region? |
They're how a plugin reacts to the HUD: a killfeed line, an ammo counter, a scope overlay, an ability icon, a health bar. They work independently of aim, so they don't need a target on screen. Most screen-reading plugins are frame plugins that call these functions from on_frame.
#What a plugin can see
- Vision functions read the full capture frame: the whole image from your capture source (for example 1920×1080 from a capture card), not the smaller crop the detector and aim work in. A region reads the same whatever your FOV or crop settings are.
- A region is two corners in capture-frame pixels:
x1, y1(top-left) andx2, y2(bottom-right), with0, 0at the top-left of the frame. Regions are clipped to the frame. - A plugin never gets the image itself, only the answer to the question it asks.
- There's no function that reports the frame size. Give users settings for the region (every recipe below does), and draw it so they can place it. See Finding the right region.
#Slots: how a vision call works
Every vision function takes a slot name first, then the region, then its own arguments:
local r = colorMatch("banner", x1, y1, x2, y2, "#ff3b3b", 30)
- The first call with a slot name registers a request with that region and those arguments. It returns
nil. - Moonlight's vision worker processes every registered slot in the background, on the latest capture frame, again and again.
- Each later call updates the slot's region and arguments and returns the latest finished result as a table, or
nilif none has finished yet. - The call never waits. A result can be from an earlier frame, and the same result can come back from several calls in a row. Treat it as "the latest reading", not as a new observation made for this call.
- After you change a slot's region or arguments, the next call can still return a result computed with the old ones.
- A slot keeps being processed until your plugin is disabled or reloaded, even if you stop calling it. There's no way to remove a single slot. Use a small, fixed set of slot names, and don't register slots you only need now and then.
- Slot names are private to your plugin. Two plugins can both use
"hud"without clashing.
How often a result refreshes depends on the function, the size of the region and how many slots are active. Color functions are fast, template matching is slower, and OCR is the slowest. None of them refresh faster than capture. Don't depend on a fixed rate: time things with nowMs().
Vision only runs while Moonlight is running and capture delivers frames. When it isn't, calls keep returning the last result, or nil.
#ocr(slot, x1, y1, x2, y2, min_confidence)
Reads the text in the region with Windows' built-in OCR, using its English recognizer.
| Result field | Meaning |
|---|---|
text | Everything recognized in the region, as one string. Separate lines are joined with spaces. Empty when nothing was read. |
confidence | 1 when any text was read, 0 when none. Windows OCR doesn't give a probability per read, so this is a "found text" flag, not a quality score. |
wordCount | The number of words in text. |
min_confidence(default 0.5) is accepted but doesn't filter anything. Checkr.textyourself.- OCR works best on large, high-contrast text. Keep the region tight around the text: less background means better and faster reads.
- OCR output is noisy. Letters get confused (
0/O,1/l/I,5/S), spaces appear or disappear, punctuation changes. Normalize before you compare: uppercase, keep only letters and digits, and allow some difference. React to text on the HUD shows how. - To read a number, pull the digits out of
text(text:match("%d+")) after fixing common look-alikes. See Read a number from the HUD. - If OCR never returns any text at all, check that an English language is installed in Windows' language settings.
#templateMatch(slot, x1, y1, x2, y2, min_score, path)
Looks for a small picture, the template, inside the region.
| Result field | Meaning |
|---|---|
matched | true when the best spot's score is at least min_score. |
score | How closely the best spot matches: 1.0 is identical, unrelated content scores near 0, and it can go below 0. |
x, y | Top-left corner of the match, in capture-frame pixels. Filled only when matched. |
w, h | The template's size in pixels. Filled only when matched. |
min_scoredefaults to 0.7. Crisp HUD icons usually match at 0.85 or more; monitorscorewith the icon shown and hidden, and pick a value between.pathis an image file; PNG is recommended. A relative path is looked up in thepluginsfolder, so"icons/scope.png"meansplugins/icons/scope.png.- The image is loaded once and cached. If you replace the file, reload the plugin.
- Matching compares pixels one to one. There's no scaling and no rotation. The template must come from a capture at the same resolution and HUD scale you match against, and the region must be at least as large as the template. A new game resolution or HUD scale needs a new template.
- Colors count. An icon that changes tint (a cooldown, a team color) may need one template per state.
Making a template. Take a full-resolution screenshot at the same resolution your capture delivers (if the capture is 1920×1080, the screenshot must be 1920×1080 too). Crop a small, distinctive part of the icon with an image editor, keeping as little background as possible, and save it as a PNG in the plugins folder.
#colorMatch(slot, x1, y1, x2, y2, color, tolerance)
Measures how much of the region is one color.
| Result field | Meaning |
|---|---|
percent | The share of the region's pixels that match, from 0 to 100. |
total | The number of pixels in the region. |
matched | true when percent is above 0.5. |
coloris a hex color,"#rrggbb"(default"#ffffff").tolerance(0–255, default 30) is how far each of the red, green and blue values may be fromcolor. A pixel matches only when all three are within it.- Set your own threshold on
percent; don't rely onmatched. Monitorpercentwith the HUD element visible and hidden, and pick a value between the two. - Captured colors aren't exactly what the game draws: capture color range, HDR and compression shift them. Pick the color from a capture.
#colorContour(slot, x1, y1, x2, y2, color, tolerance, min_area)
Counts separate blobs of a color: markers, pips, dots.
| Result field | Meaning |
|---|---|
count | The number of separate areas of matching pixels that are at least min_area pixels large. |
percent | The share of the region's pixels that match, from 0 to 100, as in colorMatch. |
matched | true when count is at least 1. |
colorandtolerancework as incolorMatch.min_areadefaults to 50 pixels; raise it to ignore specks and noise.- Blobs that touch count as one.
- It tells you how many, not where: there are no positions.
#The result table
Every result table has the same fields: text, confidence, wordCount, matched, score, x, y, w, h, count, percent and total. Only the ones listed for the function you called mean anything; the others are empty or 0.
A call returns nil until its first result is ready, so always check:
local r = templateMatch("scope", 900, 480, 1020, 600, 0.85, "icons/scope.png")
if r == nil then return end -- no reading yet
#Finding the right region
Put the region in settings, draw it, and adjust it while you watch the debug view:
config = {
x1 = { type="int", default=1500, min=0, max=3840, label="Region left", order=10 },
y1 = { type="int", default=950, min=0, max=2160, label="Region top", order=20 },
x2 = { type="int", default=1900, min=0, max=3840, label="Region right", order=30 },
y2 = { type="int", default=1070, min=0, max=2160, label="Region bottom", order=40 },
show = { type="bool", default=true, label="Show region", order=50 },
}
function on_frame(t_ms)
local x1, y1, x2, y2 = config.x1.value, config.y1.value, config.x2.value, config.y2.value
local r = colorMatch("hud", x1, y1, x2, y2, "#ffffff", 30)
if config.show.value then
drawScreenRectangle(x1, y1, x2, y2, "#a78bfa", 2) -- same coordinates as vision
if r ~= nil then
drawScreenText(string.format("%.1f%%", r.percent), x1, y1 - 6, "#a78bfa", 14)
end
end
end
drawScreenRectangle and drawScreenText use capture-frame pixels, the same as vision, so the box shows exactly what's being read. Drawing from on_frame keeps it on screen between ticks. See Drawing.
#Turning readings into events
Readings flicker: a frame is missed, a color blends into the background, OCR drops a letter. And a repeated result can be the same old reading. Don't act on a single result. Combine these patterns:
| Pattern | Use it to | How |
|---|---|---|
| Edge | Act once when something appears | Remember the last state; act when it goes from not seen to seen. |
| Hold time | Ignore flickers | Act only once the state has been true for a while (nowMs() - since >= 150). |
| Hysteresis | Stop a value near its threshold from flapping | mlib.new_hysteresis(on, off): turns on above on, off only below off. |
| Re-arm | Act once per appearance | After acting, wait until the thing has been gone for a while before acting again. |
| Cooldown | Limit how often you act | Keep the time of the last action; skip until enough time has passed. |
-- Edge + hold time + cooldown in a few lines.
local seen_since, fired, last_fire = nil, false, nil
local function update(visible, now)
if not visible then
seen_since, fired = nil, false -- gone: re-arm
return false
end
seen_since = seen_since or now
if fired or now - seen_since < 150 then return false end -- flicker guard
if last_fire ~= nil and now - last_fire < 1000 then return false end -- cooldown
fired, last_fire = true, now
return true -- act now
end
#What it costs
Each registered slot adds work to the vision worker on every pass, for as long as your plugin is enabled. A few color slots cost little. Template matching costs more with bigger regions and bigger templates. OCR is the most expensive, so keep OCR regions small and use one OCR slot where you can.
Vision runs on its own worker, not inside your hook, so calling a vision function is cheap for your hook. The cost shows up as CPU load, not as aim latency.
#Vision permissions
Local plugins can use every vision function. For Marketplace plugins each function needs its capability: vision.ocr (or screen.read) for ocr, vision.template for templateMatch, vision.color for colorMatch and colorContour. Without it the call returns nil. See Capabilities.
#Input automation
Plugins can press mouse buttons and keys and move the mouse through the user's mouse device. It's meant for actions tied to what's on screen: tap a key when an icon appears, hold a key while a condition lasts, run a short timed sequence.
#Functions
| Function | What it does |
|---|---|
keyPress(key) | Taps a key: press and release. Same as setKey(key, 2). |
setKey(key, mode) | mode 1 holds the key down, 0 releases it, 2 taps it. Use numbers, not booleans. |
click(button, hold_ms, dx, dy) | Presses a mouse button now and releases it hold_ms later. Defaults: "left", 40 ms, no movement. hold_ms is limited to 0–60000. The optional movement goes with the press. |
setMouseButton(button, down, dx, dy) | Presses (down = true or 1) or releases (false or 0) a mouse button. Optional dx, dy (default 0) are sent with it. |
moveMouse(dx, dy) | Moves the mouse by dx, dy counts, rounded to whole numbers. |
Mouse buttons: "left" (also "m1", "1"), "right" ("m2", "2"), "middle" ("m3", "3"), "side1" ("mouse4", "4"), "side2" ("mouse5", "5").
Movement sent with a button must be a finite number from -32768 to 32767 on each axis, or the call is ignored. Where the device supports it, a movement and a button go out together, but one call isn't guaranteed to be one report on every device.
#What each device supports
| Output device | Mouse: moveMouse, buttons, click | Keys: setKey, keyPress |
|---|---|---|
| Serial devices (Ferrum, MAKCU, KMBox B+, Medius) | Yes | Yes, when the device's firmware supports keyboard commands |
| Moonlink | Yes | Letters, digits, space, tab, enter, esc, shift, ctrl, alt (left and right) and the arrow keys |
| Controller output (Titan Two, Makcu Controller) | No | No |
With a controller output device, input functions do nothing. A key name the device doesn't know is ignored.
#Key names
Case doesn't matter. Names follow key positions on a US keyboard.
| Keys | Names |
|---|---|
| Letters and digits | a–z, 0–9 |
| Punctuation | - = [ ] \ ; ' ` , . / (or minus, equals, lbracket, rbracket, backslash, semicolon, quote, grave, comma, period, slash) |
| Editing | enter (return), esc (escape), backspace, tab, space, capslock |
| Function keys | f1–f12 |
| Navigation | up, down, left, right, insert, home, end, pageup (pgup), pagedown (pgdn), delete (del) |
| System | printscreen, scrolllock, pause, numlock |
| Modifiers | ctrl (lctrl), rctrl, shift (lshift), rshift, alt (lalt), ralt, win (lwin), rwin |
On serial devices you can also pass a USB HID key code as text, for example "44" for the space bar.
#Holding and releasing
setKey(key, 1)andsetMouseButton(button, true)hold until you release them. Always pair a hold with a release.- Holds are counted per plugin. If two plugins hold the same key or button, it stays down until both release it.
- When your plugin is disabled, reloaded or turned off for a weapon class, Moonlight releases everything it still holds. That's a safety net, not your plugin's logic.
keyPresstaps immediately; the device decides how long the tap lasts.clickis timed by the engine: the release goes outhold_msafter the press, even though your hook has long returned.
#Timing
Input calls queue a command and return at once. The engine sends queued commands on its output step, normally a few milliseconds later. Up to 256 commands can wait; more are dropped.
There's no sleep, and busy-waiting would block the engine. To do something later, remember when you started and check the time on later calls. Run a timed key sequence shows the pattern. on_frame runs up to 30 times a second, so a step scheduled from it lands on the next tick after its time: about 33 ms resolution. When you need an exact press duration, use click(button, hold_ms).
#Rules for safe automation
Warning
Hooks run many times a second. An input call in every call presses every time.
- Act on an edge (something changed), behind a cooldown, and never on a single reading. See Turning readings into events.
- Pair every press with a release, and release when your condition ends.
- Keep sequences short, and give users a setting to switch automation off.
- Test with the game's own input display or a key tester before you use it in a match.
#Input permissions
Local plugins can use every input function. Marketplace plugins need the input.write capability, which the buyer must approve. See Capabilities.
#Aim control
A plugin can hold back Moonlight's own aim output, for example while a HUD banner shows or after a kill.
| Function | Effect |
|---|---|
pauseAim(ms) | Suppresses Moonlight's automatic output until ms milliseconds from now (default 200; a negative value counts as 0). A new call replaces the deadline. |
disableAim() | Suppresses output for a long time (up to one hour), until enableAim() is called. |
enableAim() | Clears any plugin suppression now. |
- The pause is shared by all plugins: any plugin can extend or clear it.
- It gates output. It doesn't change the user's Aim checkbox. It can also hold back other automated movement that goes through the same output path, such as recoil compensation.
- While aim is paused,
override_humanizationandoverride_outputaren't called. - It doesn't affect your plugin's own input automation.
-- Hold aim off while a HUD banner is visible.
function on_frame(t_ms)
local r = colorMatch("banner", 800, 40, 1120, 90, "#ff3b3b", 30)
if r ~= nil and r.percent > 2 then
pauseAim(250) -- refreshed every call while visible; ends 250 ms after it disappears
end
end
Marketplace plugins should declare aim.control so buyers are told. See Capabilities.
#Overlays, monitors and charts
#Drawing on the preview
Drawing puts shapes on Moonlight's debug view and preview, not into the game. Use it to show where a plugin is looking and what it decided.
| Function | Arguments |
|---|---|
drawRectangle(x1, y1, x2, y2, color, thickness, space) | Outline rectangle from two corners. Defaults: red, thickness 1, "crop". |
drawCircle(x, y, radius, color, thickness, space) | Outline circle. Same defaults. |
drawLine(x1, y1, x2, y2, color, thickness, space) | Line. Same defaults. |
drawText(text, x, y, color, size, space) | Text. Defaults: white, size 12, "crop". |
drawScreenRectangle(x1, y1, x2, y2, color, thickness) | Rectangle in "screen" space. |
drawScreenText(text, x, y, color, size) | Text in "screen" space. |
Spaces. "crop" is aim space, the same coordinates as t.target.x/y and the crosshair, for marking targets and aim points. "screen" is capture-frame pixels, the same coordinates as vision regions, for marking HUD regions.
How long a shape stays.
- Shapes drawn in
on_framestay on screen until your nexton_frame, so HUD overlays don't flicker between ticks. - Shapes drawn in any other hook show once. Draw them again on every call for anything that should stay visible.
Colors. Use hex colors, "#rrggbb": they work in every view. Named colors (red, green, blue, white, black, yellow, cyan, magenta, orange) work in the standard debug view but not everywhere.
Limits. Up to 256 shapes can be queued; extra ones are dropped. Keep overlays small.
function override_aim_provider(t)
drawCircle(t.target.x, t.target.y, 4, "#50fa7b", 2) -- aim point
drawLine(t.crosshair.x, t.crosshair.y, t.target.x, t.target.y, "#a78bfa", 1) -- error
return nil
end
Local plugins can draw. For Marketplace plugins drawing needs the draw capability, which they get by default.
#Monitors
monitor(label, value) shows the latest value under your plugin in the Library. It's the way to debug, and a simple status display for users. Numbers, booleans and short strings work best.
monitor("Error X (px)", t.target.x - t.crosshair.x)
monitor("Status", armed and "Armed" or "Waiting")
Warning
print() and log() exist but show nothing. Use monitor().
#Feeding a chart
setChartData(key, value) appends a number to the chart declared as config.<key> with type="chart". The last 120 points are kept. See Chart settings.
#State, time and math
#Keeping state
| Where | Lifetime and use |
|---|---|
File-scope local variables | Private to your plugin. Kept until the plugin is disabled or reloaded. |
state | A ready-made private table: state.count = (state.count or 0) + 1. Same lifetime as locals. |
shared | A store shared by all plugins. Values must be numbers, booleans or strings; assign nil to delete. Nested tables aren't supported. |
Use a name prefix for shared keys so plugins don't collide:
shared["acme_recoil_phase"] = 2 -- in one plugin
local phase = shared["acme_recoil_phase"] -- in another
shared values are runtime data. They aren't saved, and they don't survive an engine restart.
#The nowMs clock
| Function | Meaning |
|---|---|
nowMs() | Steady-clock milliseconds. Use it for deadlines and elapsed time. Not a date or time of day. |
Remember that on_frame(t_ms) and override_rcs(…, now_ms) use a different clock (since engine start). See Time.
#Global helpers
| Function | Result |
|---|---|
clamp(v, lo, hi) | v limited to lo..hi. |
lerp(a, b, t) | a + (b - a) * t. t isn't clamped. |
distance(x1, y1, x2, y2) | Straight-line distance. |
normalize(x, y) | Unit vector; 0, 0 for a zero vector. |
isset(v) | v ~= nil. 0 and false count as set. |
#mlib: curves and shaping
Stateless functions for shaping values:
| Function | Result |
|---|---|
mlib.multiplier(x, k) | x * k |
mlib.gamma(x, g) | Signed power curve: sign(x) * abs(x)^g |
mlib.saturate(x) | Clamped to 0–1 |
mlib.sign(x) | -1, 0 or 1 |
mlib.remap(x, a, b, c, d) | Maps range a..b onto c..d (not clamped) |
mlib.deadzone(x, dz) | 0 inside ±dz, otherwise shrunk toward 0 by dz |
mlib.anti_deadzone(x, amount) | Adds amount to the magnitude of non-zero values |
mlib.s_curve(x, k) | Normalized tanh S-curve (default k = 2) |
mlib.tanh_sat(x, k) | tanh(k·x) / k (default k = 1) |
mlib.softsign(x, k) | x / (1 + abs(k·x)) (default k = 1) |
mlib.clamp_mag(dx, dy, max) | Caps the vector's length at max, keeping direction. Returns two numbers. |
mlib.lead(x, vx, seconds) | x + vx * seconds |
mlib.angle_gate(dx, dy, max_deg) exists for compatibility but returns its input unchanged. Don't use it as an angle limit.
#mlib: filters and controllers
Create once (at file scope, or when settings change), then call methods with :. Use one instance per axis.
| Create | Update | Reset |
|---|---|---|
mlib.new_ema(alpha) | f:update(x) | f:reset() |
mlib.new_simple_smooth(t) | f:update(current, target) | Create a new one |
mlib.new_slew(rate) | f:update(x, dt): x changes by at most rate · dt | Create a new one |
mlib.new_pid(kp, ki, kd, integral_limit) | pid:update(error, dt) | pid:reset() |
mlib.new_hysteresis(on, off) | h:update(x) returns true above on, false again below off | Create a new one |
mlib.new_one_euro(mincutoff, beta, dcutoff) | f:update(x, dt) | Create a new one |
Pass dt in seconds everywhere.
Note
new_ema and new_simple_smooth move a fixed fraction per update, so they behave differently at different call rates. For rate-independent smoothing use new_one_euro, new_slew, or an exponential based on dt (see Coordinates, units and time).
#Standard Lua
Lua 5.4's base functions and the math, string and table libraries are available. There's no LuaJIT, FFI, os, io, require or debug. See Performance and limits.
#Guides
#Aim cookbook
Complete aim, recoil and output plugins you can copy into the plugins folder. For plugins that watch the screen or press keys, see the HUD and automation cookbook. Each one compiles on Lua 5.4 and was run against Moonlight's plugin API. Start with conservative settings and tune on your own setup.
#Smooth exponential aim
An aim provider that closes a fixed share of the gap per second, whatever the frame rate.
plugin_name = "Exponential Aim"
plugin_description = "Frame-rate independent smooth aim toward Moonlight's aim point"
plugin_author = "Your name"
plugin_version = "1.0"
aim_use_humanization = true
config = {
response = { type="number", default=6.0, min=0.5, max=20.0, label="Response (per second)",
tooltip="Higher closes the gap faster", order=10 },
max_move = { type="number", default=12.0, min=1.0, max=60.0, label="Maximum move per update", order=20 },
}
function override_aim_provider(t)
local dt = math.max(0, t.frame.delta_ms) / 1000
local fraction = 1 - math.exp(-config.response.value * dt)
local dx = (t.target.x - t.crosshair.x) * fraction * t.settings.sens
local dy = (t.target.y - t.crosshair.y) * fraction * t.settings.sens
return mlib.clamp_mag(dx, dy, config.max_move.value)
end
Enable it, then on the Aim page choose Script and select Exponential Aim as the provider.
#PID aim
A PID controller per axis. It rebuilds the controllers when the gains change, and resets them when the lock moves to a new target.
plugin_name = "PID Aim"
plugin_description = "PID controller per axis toward Moonlight's aim point"
plugin_author = "Your name"
plugin_version = "1.0"
config = {
kp = { type="number", default=6.0, min=0.0, max=30.0, label="Proportional (Kp)", order=10 },
ki = { type="number", default=0.0, min=0.0, max=10.0, label="Integral (Ki)", order=20 },
kd = { type="number", default=0.05, min=0.0, max=2.0, label="Derivative (Kd)", order=30 },
max_move = { type="number", default=12.0, min=1.0, max=60.0, label="Maximum move per update", order=40 },
}
local pid_x, pid_y, built_with, last_id = nil, nil, nil, nil
local function gains_changed()
return built_with == nil or built_with[1] ~= config.kp.value
or built_with[2] ~= config.ki.value or built_with[3] ~= config.kd.value
end
function override_aim_provider(t)
if gains_changed() then
local kp, ki, kd = config.kp.value, config.ki.value, config.kd.value
pid_x = mlib.new_pid(kp, ki, kd, 50)
pid_y = mlib.new_pid(kp, ki, kd, 50)
built_with = { kp, ki, kd }
end
if t.target.id ~= last_id then -- new target: forget the old error history
pid_x:reset(); pid_y:reset()
last_id = t.target.id
end
local dt = math.max(t.frame.delta_ms, 1) / 1000
local ex = t.target.x - t.crosshair.x
local ey = t.target.y - t.crosshair.y
local dx = pid_x:update(ex, dt) * dt * t.settings.sens -- controller output is a rate: scale by dt
local dy = pid_y:update(ey, dt) * dt * t.settings.sens
return mlib.clamp_mag(dx, dy, config.max_move.value)
end
The controller output is treated as a speed (pixels per second) and multiplied by dt, so the gains mean the same thing at any frame rate.
#Your own lead
Adds lead in the direction the target is moving. Works with Moonlight's built-in prediction on or off.
plugin_name = "Simple Lead"
plugin_description = "Leads the aim point by target velocity"
plugin_author = "Your name"
plugin_version = "1.0"
config = {
lead_ms = { type="number", default=40.0, min=0.0, max=200.0, label="Lead (ms)", order=10 },
max_lead_px = { type="number", default=40.0, min=0.0, max=200.0, label="Maximum lead (px)", order=20 },
}
function override_prediction(target, aim_x, aim_y)
local s = config.lead_ms.value / 1000
local lx, ly = mlib.clamp_mag(target.velocity_x * s, target.velocity_y * s, config.max_lead_px.value)
return aim_x + lx, aim_y + ly
end
#Filter and sticky target selection
Ignores weak or tiny detections, and only switches away from the current target when another one is clearly closer.
plugin_name = "Sticky Targets"
plugin_description = "Confidence/size filter with switch hysteresis"
plugin_author = "Your name"
plugin_version = "1.0"
config = {
min_conf = { type="number", default=0.45, min=0.0, max=1.0, label="Minimum confidence", order=10 },
min_height = { type="int", default=12, min=0, max=300, label="Minimum box height (px)", order=20 },
switch_margin = { type="int", default=25, min=0, max=300,
label="Switch only if closer by (px)", order=30 },
}
function override_filter_targets(targets)
local kept = {}
for _, c in ipairs(targets) do
if c.confidence >= config.min_conf.value and c.height >= config.min_height.value then
kept[#kept + 1] = c -- keep the original table
end
end
return kept
end
function override_select_target(targets, current_lock)
local nearest = nil
for _, c in ipairs(targets) do
if nearest == nil or c.distance < nearest.distance then nearest = c end
end
if current_lock ~= nil and nearest ~= nil then
for _, c in ipairs(targets) do
if c.id == current_lock.id
and c.distance - nearest.distance < config.switch_margin.value then
return c -- stay on the current target
end
end
end
return nearest -- nil lets Moonlight decide
end
#Recoil strength with a ramp
Scales recoil compensation, starting softer and reaching full strength over a set time.
plugin_name = "Recoil Ramp"
plugin_description = "Scales recoil compensation with a ramp-in"
plugin_author = "Your name"
plugin_version = "1.0"
config = {
strength = { type="number", default=1.0, min=0.0, max=2.0, label="Strength", order=10 },
start_scale = { type="number", default=0.6, min=0.0, max=1.0, label="Start at (x strength)", order=20 },
ramp_ms = { type="int", default=250, min=1, max=2000, label="Ramp time (ms)", order=30 },
}
local started_ms = nil -- an override_rcs now_ms value (engine clock)
function override_rcs(rcs_x, rcs_y, active, now_ms)
if not active then
started_ms = nil -- recoil stopped: reset for the next spray
return nil
end
if started_ms == nil then started_ms = now_ms end
local ramp = clamp((now_ms - started_ms) / config.ramp_ms.value, 0, 1)
local k = config.strength.value * lerp(config.start_scale.value, 1, ramp)
return rcs_x * k, rcs_y * k
end
#Output cap and micro-jitter
Limits every outgoing movement and adds a little random noise. Returns nil for zero movement so idle output stays untouched.
plugin_name = "Output Shaper"
plugin_description = "Caps mouse output per update and adds small jitter"
plugin_author = "Your name"
plugin_version = "1.0"
config = {
max_step = { type="number", default=25.0, min=1.0, max=200.0, label="Maximum counts per update", order=10 },
jitter = { type="number", default=0.3, min=0.0, max=3.0, label="Jitter (counts)", order=20 },
}
function override_output(dx, dy)
if dx == 0 and dy == 0 then return nil end
local j = config.jitter.value
dx = dx + (math.random() * 2 - 1) * j
dy = dy + (math.random() * 2 - 1) * j
return mlib.clamp_mag(dx, dy, config.max_step.value)
end
#Watch your aim while tuning
Charts the error and shows timing, without moving anything. Handy next to a provider you're tuning.
plugin_name = "Aim Scope"
plugin_description = "Charts target error and shows timing"
plugin_author = "Your name"
plugin_version = "1.0"
config = {
err_x = { type="chart", label="Error X (px)", order=10 },
err_y = { type="chart", label="Error Y (px)", order=20 },
}
function override_aim_point(target)
setChartData("err_x", target.x - aim.crosshair.x)
setChartData("err_y", target.y - aim.crosshair.y)
monitor("Context FPS", aim.frame.fps)
monitor("Target id", target.id)
return nil -- leave the aim point unchanged
end
#HUD and automation cookbook
Complete plugins that watch the screen, press keys and draw overlays. They run from on_frame, so they work whether or not a target is on screen. Every region is a setting; use Finding the right region to place it.
#Pause aim while a HUD banner shows
Watches a region for a color (for example a red "eliminated" banner) and holds Moonlight's aim while it's visible.
plugin_name = "Banner Pause"
plugin_description = "Pauses aim while a colored HUD banner is visible"
plugin_author = "Your name"
plugin_version = "1.0"
capabilities = {"vision.color"}
config = {
x1 = { type="int", default=800, min=0, max=3840, label="Region left", order=10 },
y1 = { type="int", default=40, min=0, max=2160, label="Region top", order=20 },
x2 = { type="int", default=1120, min=0, max=3840, label="Region right", order=30 },
y2 = { type="int", default=90, min=0, max=2160, label="Region bottom", order=40 },
color = { type="color", default="#ff3b3b", label="Banner color", order=50 },
tolerance = { type="int", default=30, min=0, max=255, label="Color tolerance", order=60 },
min_percent = { type="number", default=2.0, min=0.1, max=100.0, label="Coverage to trigger (%)", order=70 },
hold_ms = { type="int", default=300, min=50, max=5000, label="Keep paused for (ms)", order=80 },
}
function on_frame(t_ms)
local r = colorMatch("banner", config.x1.value, config.y1.value, config.x2.value, config.y2.value,
config.color.value, config.tolerance.value)
if r == nil then return end -- no result yet
monitor("Banner coverage %", r.percent)
if r.percent >= config.min_percent.value then
pauseAim(config.hold_ms.value) -- refreshed while visible
end
end
#Press a key once when an icon appears
Acts on the edge (icon newly visible) with a cooldown, instead of pressing on every call.
plugin_name = "Icon Key"
plugin_description = "Taps a key once when a HUD icon appears"
plugin_author = "Your name"
plugin_version = "1.0"
capabilities = {"vision.template", "input.write"}
config = {
template = { type="text", default="icons/low_ammo.png", label="Template (in plugins folder)", order=10 },
key = { type="text", default="r", label="Key to tap", order=20 },
cooldown_ms = { type="int", default=1500, min=100, max=10000, label="Cooldown (ms)", order=30 },
}
local was_visible = false
local last_press = nil -- a nowMs() value
function on_frame(t_ms)
local r = templateMatch("icon", 1500, 950, 1900, 1070, 0.8, config.template.value)
local visible = r ~= nil and r.matched
local now = nowMs()
if visible and not was_visible
and (last_press == nil or now - last_press >= config.cooldown_ms.value) then
keyPress(config.key.value)
last_press = now
end
was_visible = visible
monitor("Icon visible", visible)
end
#React to text on the HUD
Pauses aim when chosen text appears in a region, for example a "YOU ELIMINATED" banner. It tolerates OCR mistakes and fires once per appearance.
plugin_name = "Text Trigger"
plugin_description = "Pauses aim when chosen text appears in a HUD region"
plugin_author = "Your name"
plugin_version = "1.0"
capabilities = {"vision.ocr", "aim.control"}
config = {
text = { type="text", default="ELIMINATED", label="Text to find", order=10 },
min_similarity = { type="number", default=0.8, min=0.5, max=1.0, label="Similarity",
tooltip="1.0 = exact. Lower accepts more OCR mistakes.", order=20 },
x1 = { type="int", default=760, min=0, max=3840, label="Region left", order=30 },
y1 = { type="int", default=200, min=0, max=2160, label="Region top", order=40 },
x2 = { type="int", default=1160, min=0, max=3840, label="Region right", order=50 },
y2 = { type="int", default=260, min=0, max=2160, label="Region bottom", order=60 },
pause_ms = { type="int", default=800, min=0, max=5000, label="Pause aim for (ms)", order=70 },
rearm_ms = { type="int", default=500, min=100, max=5000, label="Gone for before re-arming (ms)", order=80 },
}
-- Uppercase and keep only letters and digits.
local function norm(s)
return (string.upper(s or ""):gsub("[^A-Z0-9]", ""))
end
-- Similarity from 0 to 1, based on edit distance. For short strings.
local function similarity(a, b)
if a == b then return 1 end
local la, lb = #a, #b
if la == 0 or lb == 0 then return 0 end
local prev = {}
for j = 0, lb do prev[j] = j end
for i = 1, la do
local cur = { [0] = i }
for j = 1, lb do
local cost = (a:byte(i) == b:byte(j)) and 0 or 1
cur[j] = math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
end
prev = cur
end
return 1 - prev[lb] / math.max(la, lb)
end
-- Best similarity of the wanted text against any run of 1 to 3 words in the reading.
local function best_match(reading, wanted)
local words, best = {}, 0
for w in reading:gmatch("%S+") do
if #words < 40 then words[#words + 1] = w end
end
for first = 1, #words do
local phrase = ""
for last = first, math.min(#words, first + 2) do
phrase = phrase .. words[last]
best = math.max(best, similarity(norm(phrase), wanted))
end
end
return best
end
local armed, gone_since = true, nil
function on_frame(t_ms)
local x1, y1, x2, y2 = config.x1.value, config.y1.value, config.x2.value, config.y2.value
drawScreenRectangle(x1, y1, x2, y2, armed and "#a78bfa" or "#50fa7b", 2)
local r = ocr("banner", x1, y1, x2, y2)
if r == nil then return end -- no reading yet
local wanted = norm(config.text.value)
local score = (#wanted > 0) and best_match(r.text, wanted) or 0
monitor("Read", r.text)
monitor("Similarity", score)
local now = nowMs()
if score >= config.min_similarity.value then
gone_since = nil
if armed then
pauseAim(config.pause_ms.value)
armed = false -- once per appearance
end
else
gone_since = gone_since or now
if now - gone_since >= config.rearm_ms.value then armed = true end
end
end
#Read a number from the HUD
Reads an ammo counter and taps the reload key once it has shown 0 for a moment. The same approach reads any number: health, shield, a timer, a score.
plugin_name = "Auto Reload"
plugin_description = "Taps the reload key when the ammo counter reads 0"
plugin_author = "Your name"
plugin_version = "1.0"
capabilities = {"vision.ocr", "input.write"}
config = {
x1 = { type="int", default=1700, min=0, max=3840, label="Counter left", order=10 },
y1 = { type="int", default=980, min=0, max=2160, label="Counter top", order=20 },
x2 = { type="int", default=1800, min=0, max=3840, label="Counter right", order=30 },
y2 = { type="int", default=1040, min=0, max=2160, label="Counter bottom", order=40 },
key = { type="text", default="r", label="Reload key", order=50 },
confirm_ms = { type="int", default=150, min=0, max=1000, label="Must read 0 for (ms)", order=60 },
cooldown_ms = { type="int", default=2000, min=250, max=10000, label="Cooldown (ms)", order=70 },
}
local zero_since, last_press = nil, nil
function on_frame(t_ms)
local r = ocr("ammo", config.x1.value, config.y1.value, config.x2.value, config.y2.value)
if r == nil then return end
-- OCR often reads O for 0 and I or L for 1. Fix those, then take the first number.
local cleaned = string.upper(r.text):gsub("O", "0"):gsub("[IL]", "1")
local value = tonumber(cleaned:match("%d+"))
monitor("Ammo", value or "?")
local now = nowMs()
if value == 0 then
zero_since = zero_since or now
else
zero_since = nil -- a missed read doesn't count as 0
end
if zero_since ~= nil and now - zero_since >= config.confirm_ms.value
and (last_press == nil or now - last_press >= config.cooldown_ms.value) then
keyPress(config.key.value)
last_press = now
end
end
Requiring the value to stay at 0 for a while is what makes this reliable: a single result can be an old, repeated reading, and a misread can briefly look like 0.
#Hold a key while an icon is visible
Holds a key for exactly as long as an icon is on screen, and releases it when the icon goes, or when the key setting changes.
plugin_name = "Hold While Visible"
plugin_description = "Holds a key while a HUD icon is visible"
plugin_author = "Your name"
plugin_version = "1.0"
capabilities = {"vision.template", "input.write"}
config = {
template = { type="text", default="icons/zoom.png", label="Template (in plugins folder)", order=10 },
min_score = { type="number", default=0.85, min=0.5, max=1.0, label="Match score", order=20 },
key = { type="text", default="shift", label="Key to hold", order=30 },
x1 = { type="int", default=860, min=0, max=3840, label="Region left", order=40 },
y1 = { type="int", default=440, min=0, max=2160, label="Region top", order=50 },
x2 = { type="int", default=1060, min=0, max=3840, label="Region right", order=60 },
y2 = { type="int", default=640, min=0, max=2160, label="Region bottom", order=70 },
}
local held = nil -- the key we're holding, or nil
function on_frame(t_ms)
local r = templateMatch("icon", config.x1.value, config.y1.value, config.x2.value, config.y2.value,
config.min_score.value, config.template.value)
local visible = r ~= nil and r.matched
local key = config.key.value
if held ~= nil and (not visible or held ~= key) then
setKey(held, 0) -- release
held = nil
end
if visible and held == nil then
setKey(key, 1) -- hold
held = key
end
monitor("Score", r and r.score or 0)
monitor("Holding", held ~= nil)
end
If the plugin is disabled while the key is held, Moonlight releases it for you.
#Health bar with hysteresis
Measures how full a colored health bar is, publishes a "low health" flag other plugins can read, and warns on the overlay. Hysteresis stops the flag from flapping when the bar sits near the threshold.
plugin_name = "Low Health Flag"
plugin_description = "Flags low health from a colored health bar"
plugin_author = "Your name"
plugin_version = "1.0"
capabilities = {"vision.color"}
config = {
x1 = { type="int", default=60, min=0, max=3840, label="Bar left", order=10 },
y1 = { type="int", default=990, min=0, max=2160, label="Bar top", order=20 },
x2 = { type="int", default=360, min=0, max=3840, label="Bar right", order=30 },
y2 = { type="int", default=1004, min=0, max=2160, label="Bar bottom", order=40 },
color = { type="color", default="#3ddc84", label="Bar color", order=50 },
tolerance = { type="int", default=40, min=0, max=255, label="Color tolerance", order=60 },
low_below = { type="int", default=30, min=1, max=99, label="Low below (%)", order=70 },
ok_above = { type="int", default=40, min=2, max=100, label="OK again above (%)", order=80 },
}
local gate, gate_low, gate_ok = nil, nil, nil
function on_frame(t_ms)
local low_below, ok_above = config.low_below.value, config.ok_above.value
if gate == nil or low_below ~= gate_low or ok_above ~= gate_ok then
-- new_hysteresis(on, off) turns on above `on` and off below `off`.
-- Feed it the EMPTY share of the bar, so "on" means low health.
gate = mlib.new_hysteresis(100 - low_below, 100 - ok_above)
gate_low, gate_ok = low_below, ok_above
end
local x1, y1, x2, y2 = config.x1.value, config.y1.value, config.x2.value, config.y2.value
local r = colorMatch("health", x1, y1, x2, y2, config.color.value, config.tolerance.value)
if r == nil then return end
local low = gate:update(100 - r.percent)
shared["acme_low_health"] = low
monitor("Health bar %", r.percent)
monitor("Low health", low)
if low then
drawScreenText("LOW HEALTH", x1, y2 + 22, "#ff5555", 18)
end
end
Fit the region to the bar itself, so a full bar reads close to 100%. Pick the bar color from a capture.
#Count markers on screen
Counts how many markers of a color are in a region, for example enemy pings on a minimap, and shows the count on the overlay.
plugin_name = "Marker Counter"
plugin_description = "Counts colored markers in a region"
plugin_author = "Your name"
plugin_version = "1.0"
capabilities = {"vision.color"}
config = {
x1 = { type="int", default=40, min=0, max=3840, label="Region left", order=10 },
y1 = { type="int", default=40, min=0, max=2160, label="Region top", order=20 },
x2 = { type="int", default=340, min=0, max=3840, label="Region right", order=30 },
y2 = { type="int", default=340, min=0, max=2160, label="Region bottom", order=40 },
color = { type="color", default="#ff3b3b", label="Marker color", order=50 },
tolerance = { type="int", default=35, min=0, max=255, label="Color tolerance", order=60 },
min_area = { type="int", default=20, min=1, max=5000, label="Smallest marker (px)", order=70 },
}
function on_frame(t_ms)
local x1, y1, x2, y2 = config.x1.value, config.y1.value, config.x2.value, config.y2.value
local r = colorContour("markers", x1, y1, x2, y2, config.color.value,
config.tolerance.value, config.min_area.value)
drawScreenRectangle(x1, y1, x2, y2, "#ffb86c", 1)
if r == nil then return end
monitor("Markers", r.count)
drawScreenText(string.format("%d marker(s)", r.count), x1, y2 + 18, "#ffb86c", 16)
end
Markers that overlap count as one. Raise Smallest marker until specks of the same color stop counting.
#Run a timed key sequence
Runs a short press, wait, press, release sequence without blocking the engine, once per appearance of a trigger color. The same state machine works for any timed action.
plugin_name = "Key Sequence"
plugin_description = "Runs a timed key sequence when a colored banner appears"
plugin_author = "Your name"
plugin_version = "1.0"
capabilities = {"vision.color", "input.write"}
config = {
x1 = { type="int", default=800, min=0, max=3840, label="Region left", order=10 },
y1 = { type="int", default=40, min=0, max=2160, label="Region top", order=20 },
x2 = { type="int", default=1120, min=0, max=3840, label="Region right", order=30 },
y2 = { type="int", default=90, min=0, max=2160, label="Region bottom", order=40 },
color = { type="color", default="#ffd24a", label="Trigger color", order=50 },
min_percent = { type="number", default=2.0, min=0.1, max=100.0, label="Coverage to trigger (%)", order=60 },
}
-- Each step runs once `at` milliseconds after the sequence starts.
local steps = {
{ at = 0, run = function() setKey("shift", 1) end },
{ at = 60, run = function() keyPress("e") end },
{ at = 150, run = function() setKey("shift", 0) end },
}
local started, next_step, was_visible = nil, 1, false
function on_frame(t_ms)
local now = nowMs()
local r = colorMatch("trigger", config.x1.value, config.y1.value, config.x2.value, config.y2.value,
config.color.value, 30)
local visible = r ~= nil and r.percent >= config.min_percent.value
if visible and not was_visible and started == nil then
started, next_step = now, 1 -- start on the edge
end
was_visible = visible
if started ~= nil then
while next_step <= #steps and now - started >= steps[next_step].at do
steps[next_step].run()
next_step = next_step + 1
end
if next_step > #steps then started = nil end
end
monitor("Running", started ~= nil)
end
Steps land on the first on_frame after their time, so the timing resolution is about 33 ms. If the plugin is disabled mid-sequence, Moonlight releases the held shift.
#Share a value between plugins
One plugin watches the HUD and publishes a flag. Another reads it and changes the aim. Splitting the job lets a user combine your HUD reader with any aim plugin. Prefix shared keys with your own name.
plugin_name = "Scope Watcher"
plugin_description = "Publishes whether the scope overlay is visible"
plugin_author = "Your name"
plugin_version = "1.0"
capabilities = {"vision.template"}
function on_frame(t_ms)
local r = templateMatch("scope", 900, 480, 1020, 600, 0.85, "icons/scope_center.png")
shared["acme_scoped"] = r ~= nil and r.matched
end
plugin_name = "Scoped Output"
plugin_description = "Softens mouse output while Scope Watcher reports the scope"
plugin_author = "Your name"
plugin_version = "1.0"
function override_output(dx, dy)
if shared["acme_scoped"] == true then
return dx * 0.6, dy * 0.6
end
return nil
end
shared holds numbers, booleans and strings only, and nothing is saved. See Keeping state.
#Case study: Killfeed Pro
Killfeed Pro ships with Moonlight (plugins/killfeed_pro.lua). It reads your killfeed with OCR, and when your name appears it pauses aim briefly. It's a good model for any plugin that watches the screen, so this walks through how it's built. Open it in the Scripter to follow along.
Settings. The text to match, a similarity threshold, a minimum OCR confidence, the region (roi_x, roi_y, roi_w, roi_h in capture-frame pixels), a Show ROI switch, how long to pause aim, and a re-arm time. Everything a user needs to adapt it to their game and resolution is a setting.
Polling. One function, tick(now), does the work. It reads and matches the OCR result at most every 100 ms, so the matching work stays bounded even when it's called from a hook that runs far more often.
Matching. OCR text is normalized: uppercase, only letters and digits. The plugin then compares your name against every run of up to four consecutive words with a Levenshtein similarity, so names that OCR splits into pieces still match. OCR returns the whole region as one line of words, so a run can include words from neighbouring killfeed entries.
Acting once per appearance. On a match while armed, it calls pauseAim(disable_ms) and disarms. It re-arms only after the name has been absent for rearm_ms. A missing or failed read doesn't count as absent, so a flicker can't trigger a second pause.
Showing its work. With Show ROI on, it draws the region with drawScreenRectangle (green on a match, purple otherwise) and writes the status, the raw OCR text and the best match under it with drawScreenText. Monitors show the similarity, status and OCR text in the Library. Users can see exactly what the plugin reads.
Hooks. on_frame drives everything. The plugin also calls tick from override_output when on_frame hasn't run for 250 ms, a fallback from older versions where on_frame depended on the debug view. Today on_frame runs whenever Moonlight is running, so a new plugin needs only on_frame. Its override_output returns the movement unchanged; the plugin never moves your aim.
What to take from it:
| Technique | Where it helps |
|---|---|
| Region as settings, drawn on the overlay | Any screen-reading plugin |
| Poll a slow reading at a fixed interval | OCR and large template regions |
| Normalize and compare with a tolerance | Any OCR text |
| Arm, act once, re-arm after absence | Anything that should fire once per event |
| A failed read isn't evidence | Every vision result |
| Monitors plus overlay text | Letting users tune it themselves |
#Designer
The Designer builds an aim provider from blocks instead of code. It generates a normal Lua plugin that you enable and select like a hand-written one.
#Build a provider
- Open Plugins → Designer.
- Double-click a block in the palette to add it. Connect ports by dragging between them.
- Select a block to edit its properties.
- Connect your final movement to Output.
- Click Generate & Save.
- Enable the plugin in the Library, then select it as the Script provider on the Aim page.
The generated Lua embeds the graph so the Designer can reopen it later. Editing the generated Lua by hand doesn't update the graph, and hand-written Lua can't be opened as a graph.
#Blocks
| Group | Blocks |
|---|---|
| Sources | Detection, Crosshair, Frame, Engine Settings, Constant, Input buttons/keys |
| GUI Controls | Slider, Number, Checkbox, Dropdown, Section, Label, Text input, Color picker, Chart |
| Math | Add, Subtract, Multiply, Divide, Negate, Abs, Min, Max, Hypot, Sqrt, Pow, Exp, Log, Sin, Cos, Tan, Atan2, Degrees, Radians, Floor, Ceil, Round, Sign, Saturate, Lerp, Remap, Clamp, Smoothstep, Step, To Polar, From Polar |
| Curves | Multiplier, Gamma Curve, S-Curve, Tanh Saturation, Softsign, Deadzone, Anti-Deadzone, Lead / Latency Comp, Output Clamp, Gain Scheduling, Piecewise LUT |
| Filters | EMA Smoothing, Simple Smooth, Slew Rate Limit, One-Euro Filter, PID Controller |
| Gates | FOV Gate, Confidence Gate, Deadzone Gate, Class Gate, Hysteresis Gate |
| Logic | Compare, Select, And, Or, Not |
| State | Get State, Set State, Soft Lock Ramp |
| Debug | Monitor, Draw Circle, Draw Line, Chart Feed |
| Code | Expression, Code Block |
| Vision | OCR, Template Match, Color Match, Color Contour |
| Input | Click, Set Mouse Button, Key Press, Set Key, Move Mouse |
| Control | Pause Aim, Disable Aim, Enable Aim |
| Output | Output |
GUI Control blocks become settings in the Library, so users can tune a Designer plugin without opening it.
#Things to know
- Blocks inherit the API's limits. The Frame block's time and number ports, and the Input block's extra buttons, carry the same values as Context, which don't fill those fields for providers.
- Don't connect Output's legacy "restriction" port. Providers must return exactly two values.
- A gate that says "fallback" can produce
return nil, which means no movement for that call. Moonlight doesn't switch back to its built-in aim. - Open the generated file in the Scripter to see exactly what your graph does.
#Testing and debugging
#A safe way to develop
- Watch first. Start with a plugin that only calls
monitor()andsetChartData(), and returnnilfrom any hook. Confirm the hook is called and the values make sense. - Add movement small. Use low gains and a tight
mlib.clamp_maglimit, then raise them. - One plugin at a time. Disable other plugins so chaining doesn't hide what yours does.
- Check the active weapon class. Enabled state and settings are per class.
- Test on the real device. Validate proves the script compiles, not that your mouse device accepts a command.
#Seeing what's happening
| Tool | Use |
|---|---|
monitor(label, value) | Live values in the Library. The main debugging tool. |
A chart setting + setChartData | Watch a value over time: error, speed, confidence. |
drawCircle, drawLine, … | Show points and vectors on Moonlight's preview. |
| The Library details panel | Shows why enabling failed and runtime errors. |
print() and log() don't show anything.
function override_aim_provider(t)
local ex, ey = t.target.x - t.crosshair.x, t.target.y - t.crosshair.y
monitor("err", string.format("%.1f, %.1f", ex, ey))
monitor("dt ms", t.frame.delta_ms)
drawLine(t.crosshair.x, t.crosshair.y, t.target.x, t.target.y, "#50fa7b", 1)
return nil -- watch only, no movement yet
end
#When a hook errors
A Lua error inside a hook is caught; that call's result is ignored. After three errors in a row, Moonlight disables that hook (the rest of the plugin keeps running). Fix the code and reload the plugin. Returning an invalid value (NaN, a string, three values) is ignored rather than counted as an error, so also check your returns.
Common causes:
- Reading
config.<key>.valueat file scope, before it exists. - Indexing a
nilvision result: always checkif r ~= nil then. - Using
t.frame.timeort.weapon, which aren't filled int. Useaim.frame.time/aim.weapon. - Dividing by
delta_mswhen it's 0: guard withmath.max(t.frame.delta_ms, 1).
#Validating Lua before you load it
The Scripter's Validate compiles your plugin in the sandbox. You can also compile it with any standard Lua 5.4 (luac -p my_plugin.lua) to catch syntax errors outside Moonlight. Stub out the host functions if you want to unit-test hook logic yourself.
#Performance and limits
#Every call is on the aim path
All plugin calls go through one lock shared with the aim loop and mouse output (see How plugins run). Time spent in your hook is added to aim latency. Aim for microseconds, not milliseconds. That includes on_frame: while it runs, aim waits.
Screen reading itself runs on a separate worker, so a vision call is cheap for your hook. What costs is what you do with the result, such as long string matching on OCR text. Do that work at a bounded rate, like Killfeed Pro does.
| Do | Don't |
|---|---|
| Keep a hook's work proportional to the targets it gets | Loop over large tables or run searches every call |
| Reuse tables and filter objects kept at file scope | Build new tables or closures on every call |
Build strings only for monitor values you need | Concatenate or format strings every frame "just in case" |
| Use a few fixed vision slot names | Create a new slot name per call |
| Act on changes (edges) and use cooldowns | Send input every call |
Use elapsed time (delta_ms, nowMs()) | Busy-wait, spin, or try to sleep |
#Hard limits
| Limit | Value |
|---|---|
| Instruction budget | 1,000,000 Lua VM instructions per hook call. Exceeding it stops the call with an error. |
| Time per hook call | 100 ms of wall-clock time. A call that runs longer is stopped with an error. |
| Loading | The file body gets 1 second when the plugin is enabled or reloaded. |
| Memory | 16 MiB per plugin. An allocation past it fails with an error; Moonlight isn't affected. |
| Strings | At most 256 KiB per string, including string.rep results. |
| Pattern matching | string.find, match, gmatch and gsub stop with an error after 4,000,000 matching steps in one call. |
| Errors | Three consecutive errors disable that hook until the plugin is reloaded. |
| Drawing | 256 queued draw commands; extra ones are dropped. |
| Input queue | 256 queued input commands; extra ones are dropped. |
| Charts | 120 points per chart. |
on_frame rate | Up to 30 calls per second. |
| Vision | Results are asynchronous and may be from an earlier frame. Every slot you've used keeps running until the plugin is disabled or reloaded. |
Long operations inside the standard library (sorting, table.concat, big string operations) count against these limits too, so a single call can't stall the engine.
#The sandbox
Each plugin runs in its own Lua 5.4 state with:
- Available: Lua's base functions,
math,stringandtable, plus Moonlight's API. - Not available:
os,io,package,require,debug,load,loadfile,dofile,loadstring,collectgarbage,getmetatable,setmetatable,rawget,rawset,rawequal,rawlen,string.dumpandcoroutine. There's no file, network or process access, and no LuaJIT or FFI.
Warning
pcall and xpcall work for your own errors, but they can't catch a limit being hit: running out of instructions, time or memory stops the whole call.
#Memory
Each plugin has 16 MiB, far more than any plugin should need: the shipped plugins use well under 1 MiB. There's no garbage-collection control. Avoid unbounded growth: cap any history you keep (a ring buffer of the last N values), and clear tables keyed by target ID when targets change, since IDs keep increasing over a session.
local history, head, size = {}, 0, 60 -- ring buffer of the last 60 errors
local function push(v)
head = head % size + 1
history[head] = v
end
#Sharing and the Marketplace
#Sharing a plugin file
A plugin is one .lua file, so you can share it directly. The receiver:
- Puts the file in Moonlight's
pluginsfolder (Plugins → Library → Open Folder). - Clicks Reload All and enables the plugin, for each weapon class they want it on.
- For an aim provider, selects Script on the Aim page and chooses the plugin as the provider.
Local plugins get every permission. Only share and install plugins from people you trust: a plugin can press keys and buttons through the user's device.
#Publishing on the Marketplace
Vendors publish plugins from their Vendor dashboard on moonlight.zip, as a Plugin asset. On upload, the plugin is checked, encrypted and stored.
#Upload checks
| Check | Requirement |
|---|---|
| Size | 512 KB of source at most. |
| Instruction budget line | The file must contain --@moonlight-budget <n> near the top, with n from 1 to 1,000,000. |
| Blocked names | The source must not use os, io, require, dofile, loadfile, load, loadstring, package, debug, coroutine, collectgarbage, newproxy, getfenv, setfenv, rawget, rawset, rawequal, rawlen, python, _G, _ENV, or string.dump as names, even in dead code or as your own variable names. Using them as a table field (t.load) is fine. |
| Capabilities | Declare every capability the plugin needs. |
--@moonlight-budget 200000
plugin_name = "Banner Pause"
plugin_version = "1.0"
capabilities = {"vision.color"}
-- …
#What buyers see
Buyers import Marketplace plugins in the app. If a plugin asks for anything beyond drawing and aim, Moonlight lists the permissions and asks the buyer to keep or decline it. The plugin then appears in Plugins → Library, where it's enabled per weapon class like any other.
Marketplace plugins run from memory in encrypted form. Buyers can't open or edit their source.
#Capabilities
Moonlight enforces these for Marketplace plugins. A call without its capability does nothing (vision functions return nil). Local plugins, files you put in the plugins folder yourself, have every capability.
| Capability | Allows | Granted |
|---|---|---|
aim.write | Aim, prediction, recoil and output hooks | Automatically |
draw | drawCircle, drawRectangle, drawLine, drawText, drawScreenRectangle, drawScreenText | Automatically |
input.write | moveMouse, setMouseButton, click, setKey, keyPress | Declared, then approved by the buyer |
vision.ocr (or screen.read) | ocr | Declared, then approved |
vision.template | templateMatch | Declared, then approved |
vision.color | colorMatch, colorContour | Declared, then approved |
aim.control | pauseAim, disableAim, enableAim | Declare it so buyers are told; listed in the approval prompt |
Declare capabilities in the plugin with capabilities = { … } as well as on the upload form. A script can't grant itself permissions: Moonlight uses the signed list from the Marketplace, not the file's capabilities table.
#Before you publish
- Test live on your own device with a clean setup: one plugin enabled, default settings.
- Give every setting a sensible default, a clear
labeland atooltip. - Keep setting keys stable across versions, or use
renamed_from. - Bump
plugin_versionwith every release. - Describe in
plugin_descriptionwhat the plugin does and which hook it uses (for example "Script aim provider" or "Output modifier"), so buyers know what to enable and select.
#Reference
#Known limitations
Things that exist in the API but aren't connected, or behave differently from what their names suggest.
| Item | Current behavior |
|---|---|
override_smoothing | Recognized but never called by the aim pipeline. Use a provider or override_output. |
print(), log() | Accepted, show nothing. Use monitor(). |
t.frame.time, t.frame.number, t.weapon | Not filled in the provider context t. Use aim.frame.time, aim.frame.number, aim.weapon. |
t.input.rc, mc, bb, fb (and in aim.input) | Always 0. Only lc and the aim/trigger/flick keys are live. |
aim.mouse.x, aim.mouse.y | Always 0; not mouse telemetry. |
aim.weapon.name | Holds the active weapon-class name, same as aim.weapon.class. |
Candidate velocity_x, velocity_y | Always 0 in override_filter_targets / override_select_target. The provider's t.target.velocity_* is live. |
aim.has_target | Doesn't exist. aim can be stale. |
mlib.angle_gate | Returns its input unchanged. |
| Designer Output "restriction" port | Not supported; don't connect it. |
| Two clocks | on_frame's t_ms and override_rcs's now_ms count from engine start; nowMs() and aim.frame.time use the steady clock. |
| Pixels and raw detector output | Not available to plugins. Use the Screen reading functions for regions. |
| Controller output | override_humanization / override_output apply to mouse output only. Input automation doesn't work with a controller output device. |
| Frame size | No function reports the capture resolution. Put regions in settings. |
| Vision slots | Can't be removed one by one. Every slot used keeps running until the plugin is disabled or reloaded. |
| OCR | English recognizer only. Lines are joined into one string with spaces. confidence is 1 or 0, not a probability, and the min_confidence argument doesn't filter. |
colorContour | Reports a count, not positions. Touching blobs count as one. |
templateMatch | No scaling or rotation. Templates must match the capture resolution and HUD scale. |
| Keys on Moonlink | Only letters, digits, space, tab, enter, esc, shift, ctrl, alt and arrows. |
| Named draw colors | Work in the standard debug view only. Use "#rrggbb" everywhere. |
#Troubleshooting
| Symptom | What to check |
|---|---|
| The plugin is enabled but never aims | It must also be selected: Aim page → Script → your provider. Check the active weapon class, that the aim key is held and that a target is inside the FOV. |
| Settings don't appear | config must be a literal top-level table with supported types and plain values. Reload after changing it. |
| A setting changes in the Library but the behavior doesn't | Read config.<key>.value inside the hook. Rebuild filters or controllers built from settings when they change. |
| Enabling fails | Open the plugin in the Library to see the error. Usually a syntax error or an error in file-scope code. |
| A hook stopped running | Three consecutive errors disabled it. Fix the error and reload. |
| No debug output | print()/log() show nothing. Use monitor(). |
| One axis is wrong, or movement is shifted | Return exactly two values. Keep pixel error, sensitivity scaling and mouse counts separate. |
| The fire-button check is always true | lc is a number and 0 is true in Lua. Test aim.input.lc ~= 0. |
| Aim feels different at different frame rates | Use elapsed time (delta_ms), not a fixed amount per call. |
Timers misbehave in on_frame | Don't mix t_ms (engine clock) with nowMs() (steady clock). |
A vision function always returns nil | Is Moonlight running and capture delivering frames? Is the region inside the frame, as corners? Does the template file exist? For Marketplace plugins, is the capability granted? Results take a moment to arrive. |
| The region is in the wrong place | Vision uses capture-frame pixels, not aim space. Draw the region with drawScreenRectangle and adjust it in the debug view. |
| OCR reads the wrong text | Tighten the region around the text, normalize the reading, and compare with a tolerance. Check that the text is large and high-contrast. |
| A template never matches | It must be cut from a capture at the same resolution and HUD scale. Monitor score to see how close it gets, and lower min_score or recut the template. |
colorMatch percent is always near 0 | Captured colors differ from the game's. Pick the color from a capture and raise the tolerance. |
| A key or click fires repeatedly | Act on an edge and add a cooldown. |
| A key or click does nothing | Input needs a mouse output device; controllers aren't supported. Check the key name and what your device supports. For Marketplace plugins, is input.write granted? |
| An overlay isn't visible | Open the debug view. Use "screen" space for HUD regions and "crop" for targets. Use "#rrggbb" colors. Shapes from hooks other than on_frame must be drawn on every call. |
on_frame never runs | It runs only while Moonlight is running (started). Check the plugin is enabled for the active weapon class. |
| It passes Validate but does nothing | Validate only compiles. Check that the hook you use is called (override_smoothing isn't), and look for hook errors. |
| Behavior changes after a class switch | Enabled plugins, settings and the Script provider are per weapon class. |
| A Designer plugin behaves unexpectedly | Open the generated Lua in the Scripter. Check fallback gates (they return no movement) and that the restriction port isn't connected. |
Stuck on a step? Ask in the Moonlight Discord and a staff member will help.