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.
- name
lua-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
- Plain shell glue around CLI tools — use bash-scripting
- Designing what to cache in Redis and when to expire it — use cache-invalidation
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.
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
| Host | Lua version | Consequences |
|---|---|---|
| Neovim | LuaJIT (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.1 | Sandboxed: no io, os mostly removed, no globals allowed, cjson, cmsgpack, bit available |
| LÖVE | LuaJIT | FFI available, love.* callbacks |
| Roblox | Luau (5.1-derived, gradual types, continue, compound ops) | Not standard Lua; use Luau docs and --!strict |
| Defold, many engines | LuaJIT or 5.1 | Check engine docs |
| Standalone / embedded in C | 5.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
localeverywhere. An assignment withoutlocalcreates 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.
#tis only defined for sequences without holes; never storenilin the middle of an array. - Use
ipairsfor arrays (stops at the firstnil),pairsfor maps (order undefined). - Only
nilandfalseare falsy.0and""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
| Metamethod | Use |
|---|---|
__index (table or function) | Method lookup, defaults, lazy loading |
__newindex | Read-only tables, change tracking, strict globals |
__call | Callable objects / constructors |
__eq, __lt, __le | Comparisons (both operands need the same metamethod in 5.1) |
__tostring | Debug printing |
__gc | Finalizers on tables (5.2+; only userdata in 5.1) |
__close | To-be-closed variables (5.4+) |
__mode = "k" / "v" | Weak tables for caches keyed by objects |
- Calling
obj.method()instead ofobj:method()passes noself: the error is “attempt to index a nil value (local ‘self’)”. - Shared state on the class is a trap: with
Account.count = 0, the statementself.count = self.count + 1reads the class value through__indexbut writes a new per-instance field. MutateAccount.countexplicitly 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.wrapre-raises errors in the caller;coroutine.resumereturnsfalse, err— always check its first return value.- You cannot yield across a C call boundary in 5.1 (including
pcallin plain 5.1; LuaJIT and 5.2+ allow it). Keep yields out ofpcalled callbacks on 5.1 hosts. - In Neovim, wrap callback-style APIs into coroutines carefully and resume via
vim.schedulewhen 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), raiseerror()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 = trueso re-sourcing does not duplicate autocmds. vim.hlreplacedvim.highlightin 0.11; on older versions usevim.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 andvim.schedule()before callingvim.apifrom its callback. - Check with
:checkhealth,:messages, andnvim --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). ARGVvalues arrive as strings; convert withtonumber.- Scripts block the server while running; keep them O(small).
SCANloops over a big keyspace do not belong in Lua. redis.callraises on error,redis.pcallreturns an error table.- Lua
nilcannot 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) overEVALSHAcaches 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_gettopassertions 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_sethookinstruction 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] = vis faster thantable.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,
pairson huge tables in older builds); check with-jv.
Gotchas
#on a table with holes is unspecified; keep a separatenfield (table.packsets.n).string.finduses patterns, not regex, and.%-are magic; passplain = 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"; keys1and1.0are the same table key. tostring(nil) .. "x"is fine butnil .. "x"raises; guard concatenations.- Varargs with nils:
select("#", ...)counts them,#{...}may not. - Shadowed
localin nested scopes is legal and silently hides the outer one; luacheck warns. - Lua 5.5 makes
forloop control variables read-only and addsglobaldeclarations; code that assigns to the loop variable breaks when upgrading.
Verification
luacheck .(configure.luacheckrcwithstd = "luajit"and allowed globals likevim) orselene .;stylua --check .for formatting.lua-language-server --check .for type annotations (---@param,---@return).- Tests with
busted(standalone) ormini.test/plenary(Neovim) in headless mode. - Host check: Neovim
nvim --headless "+checkhealth" +qa; Redisredis-cli --evalwith 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.