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

A walkthrough of a small Lua binder, from a single chat command to tags and delays modelled on Police Tools Lite.

Author: Published: Updated: 3 min read

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, and it's the only part of the big script that 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). The code follows my 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.

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.

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:

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:

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.

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

Frequently asked questions

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.