DEV Community

Olivia Craft
Olivia Craft

Posted on

Cursor Rules for Lua: 6 Rules That Stop AI From Writing Spaghetti Lua in Your Game

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)