Cursor Rules for Lua: 6 Rules That Stop AI From Writing Spaghetti Lua in Your Game
Cursor writes Lua fast. The problem? It writes Lua like someone who read the docs once — global variables leaking across modules, nil access crashing your game loop, string concatenation in hot paths killing your frame rate, tables used as both arrays and dictionaries simultaneously, and zero error handling anywhere.
Whether you're building in Roblox, Love2D, Defold, or embedding Lua in a C application, bad Lua is uniquely painful to debug. There's no compiler to catch type mismatches, no strict mode by default, and nil propagates silently until something explodes far from the source.
You can fix this by adding targeted rules to your .cursorrules or .cursor/rules/*.mdc files. Here are 6 rules I use on every Lua project, with bad vs. good examples showing exactly what changes.
Rule 1: Always Use local — Never Pollute the Global Table
Every variable must be declared with local.
Every function must be declared with local.
Never write to _G directly.
Use a module table pattern to export values: local M = {} ... return M
Global variables in Lua are stored in _G and visible to every file. A global count in your player module clobbers count in your enemy module.
Without this rule, Cursor generates globals everywhere:
-- ❌ Bad: all globals, cross-module leaks
function createPlayer(name, x, y)
player = {
name = name,
x = x,
y = y,
health = 100,
speed = 200
}
return player
end
function updatePlayer(dt)
player.x = player.x + player.speed * dt
end
score = 0
function addScore(points)
score = score + points
end
Every variable and function pollutes _G. Load two modules and they silently overwrite each other's player variable.
With this rule, Cursor writes proper modules:
-- ✅ Good: all locals, module pattern
local M = {}
local function createPlayer(name, x, y)
local player = {
name = name,
x = x,
y = y,
health = 100,
speed = 200,
}
return player
end
local function updatePlayer(player, dt)
player.x = player.x + player.speed * dt
end
M.createPlayer = createPlayer
M.updatePlayer = updatePlayer
return M
Nothing escapes the module. You require what you need, and name collisions are impossible.
Rule 2: Always Check for nil Before Access
Never index a table without checking if it exists first.
Use guard clauses at function entry for required parameters.
Use the pattern: value = tbl and tbl.field or default
For nested access, check each level: a and a.b and a.b.c
Lua doesn't throw on nil table access — indexing nil crashes with "attempt to index a nil value", and it happens three call stacks away from the actual bug.
Without this rule:
-- ❌ Bad: no nil checks, crashes at runtime
local function dealDamage(attacker, target)
local damage = attacker.stats.attack - target.stats.defense
target.health = target.health - damage
target.onHit.callback(damage)
if target.health <= 0 then
target.inventory.dropAll()
attacker.stats.kills = attacker.stats.kills + 1
end
end
If target.stats is nil, you crash. If target.onHit is nil, you crash. If target.inventory is nil, you crash. Three different nil access points in eight lines.
With this rule:
-- ✅ Good: nil checks at every access point
local function dealDamage(attacker, target)
if not attacker or not target then
return
end
local atk = attacker.stats and attacker.stats.attack or 0
local def = target.stats and target.stats.defense or 0
local damage = math.max(0, atk - def)
target.health = (target.health or 0) - damage
if target.onHit and target.onHit.callback then
target.onHit.callback(damage)
end
if target.health <= 0 then
if target.inventory and target.inventory.dropAll then
target.inventory.dropAll()
end
if attacker.stats then
attacker.stats.kills = (attacker.stats.kills or 0) + 1
end
end
end
Every access is guarded. The function degrades gracefully instead of crashing the game loop.
Rule 3: Use table.concat for String Building, Never the .. Operator in Loops
Never use the .. operator to build strings inside loops.
Use table.insert() and table.concat() for any string that grows iteratively.
The .. operator is fine for single-expression concatenation outside loops.
Each .. operation allocates a new string. Building a 1000-line log with .. creates 999 intermediate strings that the garbage collector has to clean up — causing frame drops in games.
Without this rule:
-- ❌ Bad: O(n²) string building, GC spikes every frame
local function buildLeaderboard(players)
local result = ""
for i, player in ipairs(players) do
result = result .. i .. ". " .. player.name .. " - " .. player.score .. "\n"
end
return result
end
local function serializeInventory(items)
local json = "["
for i, item in ipairs(items) do
json = json .. '{"id":' .. item.id .. ',"name":"' .. item.name .. '"}'
if i < #items then json = json .. "," end
end
return json .. "]"
end
With 100 players, this creates ~500 temporary strings. Your frame time spikes every time the leaderboard updates.
With this rule:
-- ✅ Good: table.concat, linear time, minimal allocations
local function buildLeaderboard(players)
local parts = {}
for i, player in ipairs(players) do
parts[#parts + 1] = i .. ". " .. player.name .. " - " .. player.score
end
return table.concat(parts, "\n")
end
local function serializeInventory(items)
local parts = {}
for _, item in ipairs(items) do
parts[#parts + 1] = string.format('{"id":%d,"name":"%s"}', item.id, item.name)
end
return "[" .. table.concat(parts, ",") .. "]"
end
One allocation at the end. Consistent frame times even with large data sets.
Rule 4: Separate Arrays and Dictionaries — Never Mix Them
A table is either an array (integer keys from 1 to n) or a dictionary (string keys).
Never mix integer and string keys in the same table.
Use ipairs() for arrays and pairs() for dictionaries.
Document which type a table is expected to be.
Lua uses tables for everything, and mixing array and dictionary behavior causes # to return wrong lengths and ipairs to stop early.
Without this rule:
-- ❌ Bad: mixed table, # returns wrong count
local playerData = {
"warrior",
"mage",
"rogue",
leader = "warrior",
maxSize = 5,
active = true,
}
print(#playerData) -- prints 3, ignoring leader/maxSize/active
for i, v in ipairs(playerData) do
print(v) -- only prints warrior, mage, rogue
end
The string keys are invisible to # and ipairs. Data silently disappears depending on how you iterate.
With this rule:
-- ✅ Good: separate array and dictionary concerns
local partyMembers = { "warrior", "mage", "rogue" }
local partyConfig = {
leader = "warrior",
maxSize = 5,
active = true,
}
print(#partyMembers) -- prints 3, correct
for i, class in ipairs(partyMembers) do
print(i, class) -- iterates all members
end
for key, value in pairs(partyConfig) do
print(key, value) -- iterates all config
end
Clear data structures, predictable iteration, correct length operator.
Rule 5: Use Metatables for OOP — Follow a Consistent Pattern
Use one OOP pattern consistently: the Self-referencing metatable pattern.
Always define a :new() constructor that sets the metatable.
Use colon syntax (:) for methods, dot syntax (.) for static functions.
Never mix OOP patterns within a project.
Cursor generates three different OOP patterns in three different files — closure-based, prototype-based, and metatable-based — making the codebase impossible to follow.
Without this rule:
-- ❌ Bad: inconsistent OOP, mixed patterns
-- File 1: closure pattern
function newEnemy(name, hp)
local self = {}
self.takeDamage = function(amount)
hp = hp - amount
end
return self
end
-- File 2: weird prototype thing
Enemy = {}
function Enemy.new(name, hp)
local o = {name = name, hp = hp}
setmetatable(o, {__index = Enemy})
return o
end
With this rule:
-- ✅ Good: consistent metatable OOP pattern everywhere
local Enemy = {}
Enemy.__index = Enemy
function Enemy:new(name, hp)
local instance = setmetatable({}, self)
instance.name = name
instance.hp = hp
instance.maxHp = hp
return instance
end
function Enemy:takeDamage(amount)
self.hp = math.max(0, self.hp - amount)
return self.hp <= 0
end
function Enemy:isAlive()
return self.hp > 0
end
return Enemy
One pattern across the entire project. Every developer (and Cursor) knows how to read and extend it.
Rule 6: Use pcall for Error Handling in Critical Paths
Wrap external calls, file I/O, and network operations in pcall or xpcall.
Never let an error in a callback crash the main game loop.
Use xpcall with a traceback handler for debugging.
Return success, result pattern for functions that can fail.
A single unhandled error in Lua kills the entire program. In a game, that means a crash to desktop because one NPC had bad data.
Without this rule:
-- ❌ Bad: one bad file crashes the entire game
local function loadLevel(path)
local file = io.open(path, "r")
local content = file:read("*a")
file:close()
local data = json.decode(content)
return data
end
local function gameLoop()
local level = loadLevel("levels/level_" .. currentLevel .. ".json")
for _, entity in ipairs(level.entities) do
spawnEntity(entity)
end
end
Missing file? Crash. Malformed JSON? Crash. Missing entities key? Crash.
With this rule:
-- ✅ Good: pcall protection, graceful degradation
local function loadLevel(path)
local file, err = io.open(path, "r")
if not file then
return nil, "Cannot open level file: " .. err
end
local content = file:read("*a")
file:close()
local ok, data = pcall(json.decode, content)
if not ok then
return nil, "Invalid JSON in level file: " .. tostring(data)
end
if not data.entities then
return nil, "Level file missing 'entities' key"
end
return data, nil
end
local function gameLoop()
local level, err = loadLevel("levels/level_" .. currentLevel .. ".json")
if not level then
log.error("Failed to load level: " .. err)
loadFallbackLevel()
return
end
for _, entity in ipairs(level.entities) do
local ok, spawnErr = pcall(spawnEntity, entity)
if not ok then
log.warn("Failed to spawn entity: " .. tostring(spawnErr))
end
end
end
Bad data gets logged. The game keeps running. Players never see a crash.
Copy-Paste Ready: All 6 Rules
Drop this into your .cursorrules or .cursor/rules/lua.mdc:
# Lua Rules
## Scope
- Every variable and function must use local
- Use module table pattern: local M = {} ... return M
- Never write to _G
## Nil Safety
- Check for nil before indexing tables
- Use guard clauses for required parameters
- Use: value = tbl and tbl.field or default
## Performance
- Never use .. in loops for string building
- Use table.insert + table.concat for iterative strings
- Profile before optimizing, but avoid known O(n²) patterns
## Tables
- A table is either an array or a dictionary, never both
- Use ipairs for arrays, pairs for dictionaries
- The # operator only counts integer keys
## OOP
- Use self-referencing metatable pattern consistently
- Constructor: function Class:new() with setmetatable
- Methods use colon syntax, static functions use dot syntax
## Error Handling
- Wrap I/O and external calls in pcall or xpcall
- Never let callbacks crash the main loop
- Return (result, err) pattern for fallible functions
The ROI: 6 Rules, Hours Saved Every Week
A nil crash in a game loop takes 30-60 minutes to trace because the error fires far from the source. A global variable leak takes even longer — it only shows up when two systems interact. If these rules prevent 3 crashes per week, that's 3+ hours saved. Over a month, that's almost two full workdays.
At $27 for the complete rules pack, it pays for itself the first time it catches a global leak before your playtesters do.
Want 50+ Production-Tested Rules?
These 6 rules are a starting point. My Cursor Rules Pack v2 includes 50+ rules covering Lua, Python, TypeScript, React, Docker, and more — organized by language and priority so Cursor applies them consistently.
Stop debugging nil crashes at 2am. Give Cursor the rules it needs to write solid Lua from the start.
Top comments (0)