# A SA:MP key binder in Lua: chat commands and RP lines

> How to write a SA:MP binder on MoonLoader: a chat command, several RP lines with delays in lua_thread, $ID$, $NAME$ and $ARGS$ tags with string.gsub, and a key bind.

- URL: https://pakulichev.com/en/wiki/lua-binder
- Author: Pakulichev (https://pakulichev.com/)
- Language: en
- Updated: 2026-10-16
- Whole site for LLMs: https://pakulichev.com/llms.txt (index), https://pakulichev.com/llms-full.txt (full text)

# A SA:MP key binder in Lua: chat commands and RP lines

A binder sends prewritten lines to chat on a command or a key press. On role-play servers it handles repetitive RP actions: cuffing a suspect, checking documents, reporting on the radio. A binder was the core of my [Police Tools](https://pakulichev.com/en/projects/police-tools), and it's the only part of the big script that [Police Tools Lite](https://pakulichev.com/en/projects/police-tools-lite) kept. Below is a simplified version of the same mechanism: a command, several lines with pauses, and the tags `$ID$`, `$NAME$`, `$ARGS$` and `$SLEEP:ms$`.

You need MoonLoader and SAMPFUNCS ([how to install them](https://pakulichev.com/en/wiki/moonloader-install)). The code follows my [Lua code style](https://pakulichev.com/en/wiki/lua-code-style).

## A chat command

`sampRegisterChatCommand` binds a command to a function. The function gets a single string: everything the player typed after the command.

```lua
script_name("mini-binder")
script_author("Pakulichev")

function main()
  while not isSampAvailable() do wait(100) end
  sampRegisterChatCommand("hi", function(args)
    sampSendChat("Hello, " .. args)
  end)
  wait(-1)
end
```

`/hi John` sends "Hello, John" to chat. If you call `/hi` with no arguments, `args` is an empty string, not `nil`. Register commands after `isSampAvailable()`: chat functions aren't available until SA:MP has loaded.

On Russian-language servers, text in the code only comes out right if the file is saved in CP1251. Why, and what to do with UTF-8, is covered in the article on [inicfg and encoding](https://pakulichev.com/en/wiki/moonloader-config).

## Several lines with a delay

Three RP lines sent at once look like spam and may trip the server's anti-flood. You need a pause between lines, and `wait` doesn't work inside a command callback. Pauses go into a separate thread created with `lua_thread.create`:

```lua
local function play_lines(lines)
  lua_thread.create(function()
    for i, line in ipairs(lines) do
      if i > 1 then wait(1000) end
      sampSendChat(line)
    end
  end)
end
```

One second between lines is also the default in Police Tools Lite. On servers with strict anti-flood, raise it.

## Tags: ID, name and arguments

A tag is a marker in the bind text that the script replaces before sending. `sampGetPlayerIdByCharHandle(PLAYER_PED)` gives your character's ID, and `sampGetPlayerNickname` gives the nickname for an ID. Role-play nicknames look like `Firstname_Lastname`, so the first and last name can be cut out with a pattern:

```lua
local function apply_tags(line, args)
  local _, id = sampGetPlayerIdByCharHandle(PLAYER_PED)
  local nick = sampGetPlayerNickname(id)
  local name = nick:match("^([^_]+)") or nick
  local surname = nick:match("_(.+)$") or ""
  line = line:gsub("%$ID%$", function() return tostring(id) end)
  line = line:gsub("%$NAME%$", function() return name end)
  line = line:gsub("%$SURNAME%$", function() return surname end)
  line = line:gsub("%$ARGS%$", function() return args end)
  return line
end
```

In Lua patterns `$` means end of string, so inside tags it's escaped as `%$`. The replacement is a function because a `%` typed by the player in the arguments would be mangled by a string replacement.

## The full bind

Now put it together and add `$SLEEP:ms$`: the tag at the start of a line sets the pause before that line instead of the default second. The `busy` flag stops a second bind from starting while the first is still running; otherwise lines from two RP sequences get mixed up.

```lua
script_name("mini-binder")
script_author("Pakulichev")

local vkeys = require("vkeys")

local binds = {
  cuff = {
    "/me takes the handcuffs off the belt",
    "$SLEEP:1500$/me puts the handcuffs on $ARGS$",
    "/do The handcuffs are locked.",
  },
  doc = {
    "Hello, I'm $NAME$ $SURNAME$, please show your documents.",
  },
}

local busy = false

local function apply_tags(line, args)
  local _, id = sampGetPlayerIdByCharHandle(PLAYER_PED)
  local nick = sampGetPlayerNickname(id)
  local name = nick:match("^([^_]+)") or nick
  local surname = nick:match("_(.+)$") or ""
  line = line:gsub("%$ID%$", function() return tostring(id) end)
  line = line:gsub("%$NAME%$", function() return name end)
  line = line:gsub("%$SURNAME%$", function() return surname end)
  line = line:gsub("%$ARGS%$", function() return args end)
  return line
end

local function play_bind(lines, args)
  if busy then return end
  busy = true
  lua_thread.create(function()
    for i, raw in ipairs(lines) do
      local delay = tonumber(raw:match("^%$SLEEP:(%d+)%$"))
      local line = raw:gsub("^%$SLEEP:%d+%$", "")
      wait(delay or (i > 1 and 1000 or 0))
      sampSendChat(apply_tags(line, args))
    end
    busy = false
  end)
end

function main()
  while not isSampAvailable() do wait(100) end
  for cmd, lines in pairs(binds) do
    sampRegisterChatCommand(cmd, function(args)
      play_bind(lines, args)
    end)
  end
  while true do
    wait(0)
    local typing = sampIsChatInputActive() or sampIsDialogActive()
    if isKeyJustPressed(vkeys.VK_F2) and not typing then
      play_bind(binds.doc, "")
    end
  end
end
```

The key bind is checked in the endless loop in `main`. `isKeyJustPressed` fires once per press, and the key codes come from the `vkeys` library. The `sampIsChatInputActive()` and `sampIsDialogActive()` checks keep F2 from starting an RP sequence while the player is typing in chat or filling in a dialog.

## Where to take it next

If `/cuff` is called without arguments, `$ARGS$` turns into a blank. A simple guard is to check `args == ""` in `play_bind` and show a hint with `sampAddChatMessage` without sending anything to chat. Police Tools Lite has a separate `$NOSEND$` tag for lines only you see; it can be built the same way: find the tag, strip it from the line, and call `sampAddChatMessage` instead of `sampSendChat`.

The next step is moving the `binds` table out of the code into a file in `moonloader/config/`, so binds can be changed without editing the script, and adding an editor window in [ImGui](https://pakulichev.com/en/wiki/imgui-for-gta-sa). That's how Police Tools Lite works: binds in `config/PToolsLite.list`, the editor in the `/plite` window.

A binder saves time on routine RP lines, but it shouldn't make decisions for the player. Check the server rules before using one: they almost always limit message frequency and automation.

## FAQ

### Why can't I send the lines in a loop without lua_thread?

A command callback runs outside the script's coroutine, so wait can't be called there. lua_thread.create starts a separate MoonLoader thread where pauses between lines work.

### Why pass a function to gsub instead of a string?

In a replacement string, % is a special character. If the command arguments contain %, a string replacement will mangle the text or raise an error. A function returns the text as is.

### Are binders allowed on SA:MP servers?

Many role-play servers allow RP binders, but every project has its own rules. Check them before use, especially limits on flooding and message frequency.
