Languages No. 112

Lua Scripting skill for Claude Code

Lua for Neovim, Redis and game hosts, minus the globals. Claude first pins down which Lua it is writing for (LuaJIT in Neovim, 5.1 in Redis, Luau in Roblox, 5.4 or 5.5 standalone) and avoids syntax the host cannot run. It keeps every variable local, uses one shape per table, builds classes with __index, passes Redis keys through KEYS for cluster safety, and catches errors with pcall at the host boundary.

  • namelua-scripting
  • size258 lines · ≈2,550 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes correct, fast Lua for embedded hosts — Neovim configuration and plugins (vim.keymap, autocmds, vim.lsp.config), Redis EVAL and Functions, game and app scripting (LÖVE, Roblox Luau, Defold), and C/C++ host embedding — with locals over globals, tables as arrays, maps, and objects, metatables and __index classes, closures, coroutines, and error handling with pcall. Use when writing or debugging Lua scripts, init.lua configs, Redis Lua scripts, game scripting code, metatable-based OOP, or when the code hits nil indexing, global leaks, or off-by-one table bugs.

Try asking

  • Convert my vimscript init.vim to init.lua using the native LSP config
  • Write an atomic Redis Lua script for a sliding-window rate limiter
  • My Lua class method says attempt to index a nil value self — what is wrong?

Use it when

  • Writing Neovim configuration or plugin code in Lua
  • Writing Redis EVAL scripts or Redis Functions
  • Scripting game logic in LÖVE, Defold, or a custom C/C++ engine
  • Debugging nil-index errors, global leaks, or table length surprises

Skip it when

How to check the result

luacheck or selene and stylua --check pass, and the host accepts the code: nvim --headless loads the module, or redis-cli --eval returns the expected values.

The common mistake it prevents

Omitting local, which silently creates globals that leak between Neovim plugins or game scripts and are rejected outright by Redis.

~/.claude/skills/lua-scripting/SKILL.md

--- name: lua-scripting description: Writes correct, fast Lua for embedded hosts — Neovim configuration and plugins (vim.keymap, autocmds, vim.lsp.config), Redis EVAL and… license: MIT ---

Done well means: every variable is local, the target runtime (Lua 5.1/LuaJIT, 5.4, 5.5, or Luau) is known before writing a line, tables have one shape (array or map, not a mix), errors are raised with error() and caught with pcall at the host boundary, and luacheck or selene plus the host’s own check (:checkhealth, a Redis test call) pass.

First: identify the runtime

HostLua versionConsequences
NeovimLuaJIT (5.1 semantics + some 5.2 extensions)No integer division //, no goto in plain 5.1 (LuaJIT has it), use bit lib, vim.* API
Redis (EVAL, Functions)5.1Sandboxed: no io, os mostly removed, no globals allowed, cjson, cmsgpack, bit available
LÖVELuaJITFFI available, love.* callbacks
RobloxLuau (5.1-derived, gradual types, continue, compound ops)Not standard Lua; use Luau docs and --!strict
Defold, many enginesLuaJIT or 5.1Check engine docs
Standalone / embedded in C5.4 (current widespread) or 5.5 (released Dec 2025)Integers, <const>, <close>, //, bitwise operators

Write to the lowest common denominator when the code must run on several hosts. Never use 5.4-only syntax (<const>, //, &, |) in Neovim or Redis code.

Ground rules

  • local everywhere. An assignment without local creates a global; in Neovim it pollutes every plugin, in Redis it is rejected, in games it causes cross-script bugs.
  • Modules return a table; never set globals from a module.
  • Arrays start at 1. #t is only defined for sequences without holes; never store nil in the middle of an array.
  • Use ipairs for arrays (stops at the first nil), pairs for maps (order undefined).
  • Only nil and false are falsy. 0 and "" are truthy.
  • Strings are immutable; build large strings with a buffer table and table.concat.
  • Raise errors with tables or strings via error(msg, level); level 2 blames the caller.

Module pattern

-- mylib/init.lua  (require("mylib"))
local M = {}

local DEFAULTS = { timeout = 5000, retries = 3 }

local function merge(defaults, opts)
  local out = {}
  for k, v in pairs(defaults) do out[k] = v end
  for k, v in pairs(opts or {}) do out[k] = v end
  return out
end

function M.setup(opts)
  M.config = merge(DEFAULTS, opts)
end

return M

In Neovim use vim.tbl_deep_extend("force", DEFAULTS, opts or {}) instead of hand merging.

Tables, metatables, and classes

local Account = {}
Account.__index = Account

function Account.new(owner, balance)
  return setmetatable({ owner = owner, balance = balance or 0 }, Account)
end

function Account:deposit(amount)           -- colon = implicit self
  if type(amount) ~= "number" or amount <= 0 then
    error("deposit must be a positive number", 2)
  end
  self.balance = self.balance + amount
end

function Account:__tostring()
  return ("Account(%s, %.2f)"):format(self.owner, self.balance)
end

-- Inheritance
local Savings = setmetatable({}, { __index = Account })
Savings.__index = Savings
function Savings.new(owner, balance, rate)
  local self = Account.new(owner, balance)
  self.rate = rate
  return setmetatable(self, Savings)
end
MetamethodUse
__index (table or function)Method lookup, defaults, lazy loading
__newindexRead-only tables, change tracking, strict globals
__callCallable objects / constructors
__eq, __lt, __leComparisons (both operands need the same metamethod in 5.1)
__tostringDebug printing
__gcFinalizers on tables (5.2+; only userdata in 5.1)
__closeTo-be-closed variables (5.4+)
__mode = "k" / "v"Weak tables for caches keyed by objects
  • Calling obj.method() instead of obj:method() passes no self: the error is “attempt to index a nil value (local ‘self’)”.
  • Shared state on the class is a trap: with Account.count = 0, the statement self.count = self.count + 1 reads the class value through __index but writes a new per-instance field. Mutate Account.count explicitly for shared counters.

Closures and coroutines

-- Producer/consumer with coroutines
local function lines_of(path)
  return coroutine.wrap(function()
    for line in io.lines(path) do coroutine.yield(line) end
  end)
end
for line in lines_of("data.txt") do print(line) end
  • coroutine.wrap re-raises errors in the caller; coroutine.resume returns false, err — always check its first return value.
  • You cannot yield across a C call boundary in 5.1 (including pcall in plain 5.1; LuaJIT and 5.2+ allow it). Keep yields out of pcalled callbacks on 5.1 hosts.
  • In Neovim, wrap callback-style APIs into coroutines carefully and resume via vim.schedule when touching the editor from a libuv callback.

Error handling

local ok, result = pcall(risky, arg)
if not ok then
  log("risky failed: " .. tostring(result))
  return nil, result                     -- Lua convention: nil, err
end

-- With traceback for debugging
local ok2, err = xpcall(risky, debug.traceback, arg)
  • Library functions: return nil, "message" for expected failures (file missing), raise error() for programmer errors (wrong argument type).
  • Catch at the host boundary (plugin entry, Redis script top, game update loop) so one error does not kill the host loop.

Neovim (0.11+)

-- init.lua
vim.g.mapleader = " "
vim.opt.number = true
vim.opt.expandtab = true
vim.opt.shiftwidth = 2

vim.keymap.set("n", "<leader>w", "<cmd>write<cr>", { desc = "Write buffer" })

local group = vim.api.nvim_create_augroup("my_config", { clear = true })
vim.api.nvim_create_autocmd("TextYankPost", {
  group = group,
  callback = function() vim.hl.on_yank() end,
})

-- Native LSP setup without nvim-lspconfig boilerplate (0.11+)
vim.lsp.config("lua_ls", {
  cmd = { "lua-language-server" },
  filetypes = { "lua" },
  root_markers = { ".luarc.json", ".git" },
  settings = { Lua = { runtime = { version = "LuaJIT" } } },
})
vim.lsp.enable("lua_ls")
  • Always create augroups with clear = true so re-sourcing does not duplicate autocmds.
  • vim.hl replaced vim.highlight in 0.11; on older versions use vim.highlight.on_yank().
  • Neovim 0.12 ships vim.pack (built-in plugin manager, still marked experimental) with a lockfile; lazy.nvim remains the common choice. Match what the config already uses.
  • Never block the UI: use vim.system({...}, {text = true}, on_exit) for external commands and vim.schedule() before calling vim.api from its callback.
  • Check with :checkhealth, :messages, and nvim --headless -c 'lua require("mymod")' -c q.

Redis Lua

-- Atomic rate limiter: KEYS[1] = counter key, ARGV[1] = limit, ARGV[2] = window seconds
local current = redis.call("INCR", KEYS[1])
if current == 1 then
  redis.call("EXPIRE", KEYS[1], tonumber(ARGV[2]))
end
if current > tonumber(ARGV[1]) then
  return 0
end
return 1
  • Pass every key through KEYS, never build key names inside the script; Redis Cluster routes on declared keys and all keys must hash to the same slot (use {tag} hashtags).
  • ARGV values arrive as strings; convert with tonumber.
  • Scripts block the server while running; keep them O(small). SCAN loops over a big keyspace do not belong in Lua.
  • redis.call raises on error, redis.pcall returns an error table.
  • Lua nil cannot be inside a returned table; returned tables truncate at the first nil. Floats are truncated to integers on return; return strings for decimals.
  • Prefer Redis 7+ Functions (FUNCTION LOAD, FCALL) over EVALSHA caches for scripts that are part of the application; they persist and replicate.
  • Test with redis-cli --eval script.lua key1 , arg1 arg2 (space-comma-space separates keys and args).

Embedding Lua in C (5.4)

lua_State *L = luaL_newstate();
luaL_openlibs(L);
if (luaL_dofile(L, "config.lua") != LUA_OK) {
    fprintf(stderr, "lua: %s\n", lua_tostring(L, -1));
    lua_pop(L, 1);
}
lua_close(L);
  • Track the stack: every push needs a matching pop; use lua_gettop assertions in debug builds.
  • For untrusted scripts, build a sandbox environment table (only safe functions) and load with load(chunk, name, "t", env) — text mode only, never bytecode from untrusted input.
  • Limit runaway scripts with lua_sethook instruction counts.

Performance

  • Cache globals and module functions in locals at the top of hot files: local insert, floor = table.insert, math.floor.
  • t[#t + 1] = v is faster than table.insert(t, v) on most runtimes.
  • Avoid creating closures or tables inside tight loops; reuse buffers.
  • LuaJIT: avoid NYI operations in hot loops (some string patterns, pairs on huge tables in older builds); check with -jv.

Gotchas

  • # on a table with holes is unspecified; keep a separate n field (table.pack sets .n).
  • string.find uses patterns, not regex, and . % - are magic; pass plain = true (4th arg) for literal search.
  • Lua patterns have no alternation (a|b); use multiple matches or LPeg.
  • Integer vs float in 5.3+: 3 / 2 == 1.5, 3 // 2 == 1, math.type(1.0) == "float"; keys 1 and 1.0 are the same table key.
  • tostring(nil) .. "x" is fine but nil .. "x" raises; guard concatenations.
  • Varargs with nils: select("#", ...) counts them, #{...} may not.
  • Shadowed local in nested scopes is legal and silently hides the outer one; luacheck warns.
  • Lua 5.5 makes for loop control variables read-only and adds global declarations; code that assigns to the loop variable breaks when upgrading.

Verification

  1. luacheck . (configure .luacheckrc with std = "luajit" and allowed globals like vim) or selene .; stylua --check . for formatting.
  2. lua-language-server --check . for type annotations (---@param, ---@return).
  3. Tests with busted (standalone) or mini.test/plenary (Neovim) in headless mode.
  4. Host check: Neovim nvim --headless "+checkhealth" +qa; Redis redis-cli --eval with known inputs; game hosts run the scene and watch the error console.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc