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 algorithmoverride_aim_provider — see Hooks
Ignore some detections, or choose which target to lockoverride_filter_targets, override_select_target
Move the aim point, or add your own leadoverride_aim_point, override_prediction
Shape recoil compensationoverride_rcs
Shape every mouse movement Moonlight sendsoverride_humanization, override_output
Build an aim provider without writing codeThe Designer

The screen and the HUD

You want to…Use
Read text: a killfeed, an ammo counter, a timer, a bannerocr — see Screen reading
Spot an icon, a crosshair or a scope overlaytemplateMatch
Measure a health bar, detect a colored bannercolorMatch
Count markers or pips of a colorcolorContour
Hold Moonlight's aim off while something showspauseAim, disableAim, enableAim — see Aim control

Automation

You want to…Use
Tap or hold a key when something happenskeyPress, setKey — see Input automation
Click or hold a mouse button, or move the mouseclick, setMouseButton, moveMouse
Run a timed sequence of pressesA 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 previewdrawRectangle, drawText, drawScreenRectangle… — see Overlays
Show live values and charts in the Librarymonitor, setChartData
Pass a value from one plugin to anothershared — see Keeping state
Behave differently per weapon classSettings 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:

my_monitor.lua
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_frame is 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.
  • aim is the global context table: the latest aim state Moonlight published.

#2. Save and enable it

  1. Click Save in the Scripter (it validates and reloads the plugin), or click Reload All in the Library after saving from another editor.
  2. In Plugins → Library, tick the plugin to enable it.
  3. 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:

my_monitor.lua
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 plugins folder after you edit files outside Moonlight.
  • Open Folder opens the plugins folder.

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.

ButtonWhat it does
NewStarts a new plugin from the template picked next to it
Save (Ctrl+S)Validates, saves and hot-reloads the plugin into the Library
Save AsSaves under a new file name
ValidateCompiles the script in the sandbox and reports errors
ReloadDiscards unsaved edits and reloads the file from disk
DeleteDeletes the plugin file (a backup is kept)
FolderOpens 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.

anatomy.lua
-- 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

DeclarationMeaning
plugin_nameDisplay name. Recommended; without it the file name is used.
plugin_descriptionOne-line explanation shown in the Library.
plugin_authorYour name or handle.
plugin_versionA version string, e.g. "1.2".
aim_use_humanizationFor aim providers: the default of Script Aim's Apply our humanization option (true if absent). The user can change it.
capabilitiesThe 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:

  1. Creates a fresh, sandboxed Lua 5.4 state for it.
  2. Runs the file from top to bottom. This defines your hooks and settings table.
  3. Fills each config.<key>.value from default, then applies the user's saved values.
  4. 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
StageHook
Filter targetsoverride_filter_targets
Select targetoverride_select_target
Aim pointoverride_aim_point
Predictionoverride_prediction
Aim movementoverride_aim_provider (only when your plugin is the selected Script provider)
Recoiloverride_rcs
Humanizationoverride_humanization
Outputoverride_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

HookThreadCalled
override_filter_targets, override_select_targetEngine loopEvery detector frame that has candidates
override_aim_pointEngine loopEvery fresh frame with a target
override_predictionEngine loopEvery fresh frame with a target, even with built-in prediction turned off
override_aim_providerEngine loopEvery 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_rcsOutput 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_outputOutput threadWhen queued mouse movement is sent. Not called while aim is paused.
on_frameIts own threadUp 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 pluginsFrame plugins
Hooksoverride_*on_frame
RunsAt a pipeline stage, when that stage runs: with a target, while output is sent, and so onUp to 30 times a second while Moonlight is running, target or not
Typical workChange targets, aim points, movement, recoil, outputRead the screen, press keys, draw overlays, publish values
ReturnsNew values for its stageNothing
Used withThe hook's arguments and aimScreen 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

StageWith several enabled plugins
Aim point, prediction, recoil, humanization, outputChained: each plugin receives the previous plugin's result, in enabled order.
Filter targets, select targetOnly the first enabled plugin that defines the hook runs it, not all of them.
Aim movementExactly 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.class tells 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

ValueSpace
t.target.x/y, t.crosshair.x/y, candidate x/yAim space: pixels of the image the detector sees (the capture crop). Not desktop pixels.
t.target.width/height, distancePixels in the same aim space.
Vision regions x1, y1, x2, y2Capture-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, drawScreenTextCapture-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

QuantityUnit
Positions, sizes, errorsPixels (aim space)
Target velocity (velocity_x/y)Pixels per second
Movement you return from override_aim_provider, override_humanization, override_output, override_rcsRelative mouse counts
moveMouse(dx, dy)Relative mouse counts (rounded to integers)
t.frame.delta_msMilliseconds
Confidence0 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.

ValueClock
nowMs()The system's steady clock, in milliseconds.
aim.frame.timeThe 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_msMilliseconds 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 returnResult
Two finite numbersUsed as the stage's new values.
nil (or nothing) from a modifier hookThe stage keeps its incoming values, as if your hook weren't there.
nil from the active aim providerNo provider movement for this call. Moonlight does not fall back to its built-in aim.
0, 0A real zero: no movement (or the point 0,0 from a point hook).
NaN, infinity, or a non-numberRejected; 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.
  • t is 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.

  • targets is a normal 1-based array of candidate tables.
  • Return the original tables (don't build new ones), and keep their _idx field 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_lock is the currently locked candidate table, or nil. 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 = false when it stops. Reset any recoil state on that call.
  • now_ms counts milliseconds since the engine started, not nowMs() 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_ms is milliseconds since the engine started. It's a time, not a delta, and it's a different clock from nowMs().
  • 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

NameStatus
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_lostDon'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).

FieldMeaning
t.target.x, t.target.yThe resolved aim point: target with the bone/offset choice applied, before built-in prediction lead.
t.target.center_x, t.target.center_yCenter of the target's box.
t.target.width, t.target.heightBox size in pixels.
t.target.distanceDistance from the crosshair to the aim point, in pixels.
t.target.velocity_x, t.target.velocity_yMoonlight's motion estimate, in pixels per second.
t.target.confidenceDetection score, 0–1.
t.target.class_idDetector class ID.
t.target.idTracked target ID. Changes when the lock moves to another target.
t.crosshair.x, t.crosshair.yCrosshair position.
t.frame.delta_msTime since the previous context update, in milliseconds.
t.frame.fps1000 / delta_ms. Not the mouse report rate.
t.settings.sensActive sensitivity scale. Apply once when converting pixels to counts.
t.settings.smooth_x, t.settings.smooth_yThe class's smoothing values.
t.settings.fovActive FOV radius.
t.input.lcLeft button (fire) state as a number. Test t.input.lc ~= 0.
t.input.aim_key, t.input.trigger_key, t.input.flick_keyActivation 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.

TableFields
aim.targetx, y, center_x, center_y, width, height, distance, velocity_x, velocity_y, confidence, class_id, id
aim.crosshairx, y
aim.framedelta_ms, fps, time (milliseconds on the nowMs() clock), number (update counter)
aim.settingssens, smooth_x, smooth_y, fov
aim.inputlc (number), aim_key, trigger_key, flick_key; rc, mc, bb, fb are always 0
aim.weaponname, class: both hold the active weapon-class name
aim.mousex, 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:

FieldMeaning
x, yCandidate center in aim space.
width, heightBox size in pixels.
distanceDistance from the crosshair, in pixels.
confidenceDetection score, 0–1.
class_idDetector class ID.
idTracked target ID.
_idxMoonlight's reference for this call. Leave it untouched and never keep it across calls.
velocity_x, velocity_yAlways 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 },
}
TypeControl.value
numberSlider in steps of 0.01number
intSlider in steps of 1integer
boolCheckboxtrue or false
enumDropdown of optionsstring
textText boxstring
colorColor pickerhex string, e.g. "#a78bfa"
sectionHeadingnone
labelExplanatory textnone
chartLive chart fed by setChartDatanone
FieldUsed byMeaning
typeallOne of the types above.
labelallText shown next to the control.
defaultvalue typesThe starting value.
min, maxnumber, intSlider range.
optionsenumList of strings.
tooltipallHover help.
orderallSort position. Use unique, increasing numbers.
renamed_fromvalue typesEarlier 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 .value at 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:

FunctionAnswers
ocrWhat text is in this region?
templateMatchIs this picture (an icon, a crosshair, a scope overlay) in this region, and where?
colorMatchHow much of this region is this color?
colorContourHow 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) and x2, y2 (bottom-right), with 0, 0 at 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 nil if 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 fieldMeaning
textEverything recognized in the region, as one string. Separate lines are joined with spaces. Empty when nothing was read.
confidence1 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.
wordCountThe number of words in text.
  • min_confidence (default 0.5) is accepted but doesn't filter anything. Check r.text yourself.
  • 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 fieldMeaning
matchedtrue when the best spot's score is at least min_score.
scoreHow closely the best spot matches: 1.0 is identical, unrelated content scores near 0, and it can go below 0.
x, yTop-left corner of the match, in capture-frame pixels. Filled only when matched.
w, hThe template's size in pixels. Filled only when matched.
  • min_score defaults to 0.7. Crisp HUD icons usually match at 0.85 or more; monitor score with the icon shown and hidden, and pick a value between.
  • path is an image file; PNG is recommended. A relative path is looked up in the plugins folder, so "icons/scope.png" means plugins/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 fieldMeaning
percentThe share of the region's pixels that match, from 0 to 100.
totalThe number of pixels in the region.
matchedtrue when percent is above 0.5.
  • color is a hex color, "#rrggbb" (default "#ffffff").
  • tolerance (0–255, default 30) is how far each of the red, green and blue values may be from color. A pixel matches only when all three are within it.
  • Set your own threshold on percent; don't rely on matched. Monitor percent with 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 fieldMeaning
countThe number of separate areas of matching pixels that are at least min_area pixels large.
percentThe share of the region's pixels that match, from 0 to 100, as in colorMatch.
matchedtrue when count is at least 1.
  • color and tolerance work as in colorMatch. min_area defaults 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:

PatternUse it toHow
EdgeAct once when something appearsRemember the last state; act when it goes from not seen to seen.
Hold timeIgnore flickersAct only once the state has been true for a while (nowMs() - since >= 150).
HysteresisStop a value near its threshold from flappingmlib.new_hysteresis(on, off): turns on above on, off only below off.
Re-armAct once per appearanceAfter acting, wait until the thing has been gone for a while before acting again.
CooldownLimit how often you actKeep 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

FunctionWhat 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 deviceMouse: moveMouse, buttons, clickKeys: setKey, keyPress
Serial devices (Ferrum, MAKCU, KMBox B+, Medius)YesYes, when the device's firmware supports keyboard commands
MoonlinkYesLetters, digits, space, tab, enter, esc, shift, ctrl, alt (left and right) and the arrow keys
Controller output (Titan Two, Makcu Controller)NoNo

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.

KeysNames
Letters and digitsa–z, 0–9
Punctuation- = [ ] \ ; ' ` , . / (or minus, equals, lbracket, rbracket, backslash, semicolon, quote, grave, comma, period, slash)
Editingenter (return), esc (escape), backspace, tab, space, capslock
Function keysf1–f12
Navigationup, down, left, right, insert, home, end, pageup (pgup), pagedown (pgdn), delete (del)
Systemprintscreen, scrolllock, pause, numlock
Modifiersctrl (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) and setMouseButton(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.
  • keyPress taps immediately; the device decides how long the tap lasts.
  • click is timed by the engine: the release goes out hold_ms after 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.

FunctionEffect
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_humanization and override_output aren'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.

FunctionArguments
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_frame stay on screen until your next on_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

WhereLifetime and use
File-scope local variablesPrivate to your plugin. Kept until the plugin is disabled or reloaded.
stateA ready-made private table: state.count = (state.count or 0) + 1. Same lifetime as locals.
sharedA 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

FunctionMeaning
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

FunctionResult
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:

FunctionResult
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.

CreateUpdateReset
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 · dtCreate 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 offCreate 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.

exp_aim.lua
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.

pid_aim.lua
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.

simple_lead.lua
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.

sticky_targets.lua
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.

recoil_ramp.lua
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.

output_shaper.lua
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.

aim_scope.lua
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.

banner_pause.lua
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.

icon_key.lua
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.

text_trigger.lua
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.

auto_reload.lua
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.

hold_while_visible.lua
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.

low_health.lua
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.

marker_counter.lua
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.

key_sequence.lua
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.

scope_watcher.lua
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
scoped_output.lua
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:

TechniqueWhere it helps
Region as settings, drawn on the overlayAny screen-reading plugin
Poll a slow reading at a fixed intervalOCR and large template regions
Normalize and compare with a toleranceAny OCR text
Arm, act once, re-arm after absenceAnything that should fire once per event
A failed read isn't evidenceEvery vision result
Monitors plus overlay textLetting 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

  1. Open Plugins → Designer.
  2. Double-click a block in the palette to add it. Connect ports by dragging between them.
  3. Select a block to edit its properties.
  4. Connect your final movement to Output.
  5. Click Generate & Save.
  6. 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

GroupBlocks
SourcesDetection, Crosshair, Frame, Engine Settings, Constant, Input buttons/keys
GUI ControlsSlider, Number, Checkbox, Dropdown, Section, Label, Text input, Color picker, Chart
MathAdd, 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
CurvesMultiplier, Gamma Curve, S-Curve, Tanh Saturation, Softsign, Deadzone, Anti-Deadzone, Lead / Latency Comp, Output Clamp, Gain Scheduling, Piecewise LUT
FiltersEMA Smoothing, Simple Smooth, Slew Rate Limit, One-Euro Filter, PID Controller
GatesFOV Gate, Confidence Gate, Deadzone Gate, Class Gate, Hysteresis Gate
LogicCompare, Select, And, Or, Not
StateGet State, Set State, Soft Lock Ramp
DebugMonitor, Draw Circle, Draw Line, Chart Feed
CodeExpression, Code Block
VisionOCR, Template Match, Color Match, Color Contour
InputClick, Set Mouse Button, Key Press, Set Key, Move Mouse
ControlPause Aim, Disable Aim, Enable Aim
OutputOutput

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

  1. Watch first. Start with a plugin that only calls monitor() and setChartData(), and return nil from any hook. Confirm the hook is called and the values make sense.
  2. Add movement small. Use low gains and a tight mlib.clamp_mag limit, then raise them.
  3. One plugin at a time. Disable other plugins so chaining doesn't hide what yours does.
  4. Check the active weapon class. Enabled state and settings are per class.
  5. Test on the real device. Validate proves the script compiles, not that your mouse device accepts a command.

#Seeing what's happening

ToolUse
monitor(label, value)Live values in the Library. The main debugging tool.
A chart setting + setChartDataWatch a value over time: error, speed, confidence.
drawCircle, drawLine, …Show points and vectors on Moonlight's preview.
The Library details panelShows 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>.value at file scope, before it exists.
  • Indexing a nil vision result: always check if r ~= nil then.
  • Using t.frame.time or t.weapon, which aren't filled in t. Use aim.frame.time / aim.weapon.
  • Dividing by delta_ms when it's 0: guard with math.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.

DoDon't
Keep a hook's work proportional to the targets it getsLoop over large tables or run searches every call
Reuse tables and filter objects kept at file scopeBuild new tables or closures on every call
Build strings only for monitor values you needConcatenate or format strings every frame "just in case"
Use a few fixed vision slot namesCreate a new slot name per call
Act on changes (edges) and use cooldownsSend input every call
Use elapsed time (delta_ms, nowMs())Busy-wait, spin, or try to sleep

#Hard limits

LimitValue
Instruction budget1,000,000 Lua VM instructions per hook call. Exceeding it stops the call with an error.
Time per hook call100 ms of wall-clock time. A call that runs longer is stopped with an error.
LoadingThe file body gets 1 second when the plugin is enabled or reloaded.
Memory16 MiB per plugin. An allocation past it fails with an error; Moonlight isn't affected.
StringsAt most 256 KiB per string, including string.rep results.
Pattern matchingstring.find, match, gmatch and gsub stop with an error after 4,000,000 matching steps in one call.
ErrorsThree consecutive errors disable that hook until the plugin is reloaded.
Drawing256 queued draw commands; extra ones are dropped.
Input queue256 queued input commands; extra ones are dropped.
Charts120 points per chart.
on_frame rateUp to 30 calls per second.
VisionResults 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, string and table, plus Moonlight's API.
  • Not available: os, io, package, require, debug, load, loadfile, dofile, loadstring, collectgarbage, getmetatable, setmetatable, rawget, rawset, rawequal, rawlen, string.dump and coroutine. 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:

  1. Puts the file in Moonlight's plugins folder (Plugins → Library → Open Folder).
  2. Clicks Reload All and enables the plugin, for each weapon class they want it on.
  3. 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

CheckRequirement
Size512 KB of source at most.
Instruction budget lineThe file must contain --@moonlight-budget <n> near the top, with n from 1 to 1,000,000.
Blocked namesThe 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.
CapabilitiesDeclare 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.

CapabilityAllowsGranted
aim.writeAim, prediction, recoil and output hooksAutomatically
drawdrawCircle, drawRectangle, drawLine, drawText, drawScreenRectangle, drawScreenTextAutomatically
input.writemoveMouse, setMouseButton, click, setKey, keyPressDeclared, then approved by the buyer
vision.ocr (or screen.read)ocrDeclared, then approved
vision.templatetemplateMatchDeclared, then approved
vision.colorcolorMatch, colorContourDeclared, then approved
aim.controlpauseAim, disableAim, enableAimDeclare 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 label and a tooltip.
  • Keep setting keys stable across versions, or use renamed_from.
  • Bump plugin_version with every release.
  • Describe in plugin_description what 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.

ItemCurrent behavior
override_smoothingRecognized 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.weaponNot 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.yAlways 0; not mouse telemetry.
aim.weapon.nameHolds the active weapon-class name, same as aim.weapon.class.
Candidate velocity_x, velocity_yAlways 0 in override_filter_targets / override_select_target. The provider's t.target.velocity_* is live.
aim.has_targetDoesn't exist. aim can be stale.
mlib.angle_gateReturns its input unchanged.
Designer Output "restriction" portNot supported; don't connect it.
Two clockson_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 outputNot available to plugins. Use the Screen reading functions for regions.
Controller outputoverride_humanization / override_output apply to mouse output only. Input automation doesn't work with a controller output device.
Frame sizeNo function reports the capture resolution. Put regions in settings.
Vision slotsCan't be removed one by one. Every slot used keeps running until the plugin is disabled or reloaded.
OCREnglish 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.
colorContourReports a count, not positions. Touching blobs count as one.
templateMatchNo scaling or rotation. Templates must match the capture resolution and HUD scale.
Keys on MoonlinkOnly letters, digits, space, tab, enter, esc, shift, ctrl, alt and arrows.
Named draw colorsWork in the standard debug view only. Use "#rrggbb" everywhere.

#Troubleshooting

SymptomWhat to check
The plugin is enabled but never aimsIt 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 appearconfig 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'tRead config.<key>.value inside the hook. Rebuild filters or controllers built from settings when they change.
Enabling failsOpen the plugin in the Library to see the error. Usually a syntax error or an error in file-scope code.
A hook stopped runningThree consecutive errors disabled it. Fix the error and reload.
No debug outputprint()/log() show nothing. Use monitor().
One axis is wrong, or movement is shiftedReturn exactly two values. Keep pixel error, sensitivity scaling and mouse counts separate.
The fire-button check is always truelc is a number and 0 is true in Lua. Test aim.input.lc ~= 0.
Aim feels different at different frame ratesUse elapsed time (delta_ms), not a fixed amount per call.
Timers misbehave in on_frameDon't mix t_ms (engine clock) with nowMs() (steady clock).
A vision function always returns nilIs 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 placeVision uses capture-frame pixels, not aim space. Draw the region with drawScreenRectangle and adjust it in the debug view.
OCR reads the wrong textTighten 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 matchesIt 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 0Captured colors differ from the game's. Pick the color from a capture and raise the tolerance.
A key or click fires repeatedlyAct on an edge and add a cooldown.
A key or click does nothingInput 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 visibleOpen 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 runsIt runs only while Moonlight is running (started). Check the plugin is enabled for the active weapon class.
It passes Validate but does nothingValidate only compiles. Check that the hook you use is called (override_smoothing isn't), and look for hook errors.
Behavior changes after a class switchEnabled plugins, settings and the Script provider are per weapon class.
A Designer plugin behaves unexpectedlyOpen 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.