Using Classes

Learn how to use class wrappers for cleaner, object-oriented plugin APIs in Lua.

Classes in Plugify provide an object-oriented way to work with plugin resources, offering automatic lifecycle management and cleaner APIs. Instead of manually managing handles and calling functions with explicit pointers, you can use intuitive class-based interfaces.

Why Use Classes?

Working with handle-based APIs in the traditional C-style can be verbose and error-prone:

Without Classes (C-style)
local s2sdk = require("s2sdk")

-- Create a KeyValues handle manually
local kv_handle = s2sdk.Kv1Create("MyConfig")

-- Set properties using the handle
s2sdk.Kv1SetName(kv_handle, "ServerSettings")

-- Find a subkey
local subkey_handle = s2sdk.Kv1FindKey(kv_handle, "Players")

-- Easy to forget cleanup!
s2sdk.Kv1Destroy(subkey_handle)
s2sdk.Kv1Destroy(kv_handle)

With classes, the same code becomes much cleaner and safer:

With Classes (OOP-style)
local s2sdk = require("s2sdk")

-- Create using a class constructor (Lua 5.4+ with <close>)
local kv <close> = s2sdk.KeyValues.new("MyConfig")

-- Use intuitive methods
kv:SetName("ServerSettings")

-- Find a subkey - returns a KeyValues instance
local subkey <close> = kv:FindKey("Players")

-- Automatic cleanup when variables go out of scope!

Benefits:

  • Cleaner syntax - Methods instead of functions with explicit handles
  • Automatic resource management - No need to manually call destroy functions
  • Scope-based cleanup - Lua 5.4's <close> provides deterministic resource cleanup
  • Fallback cleanup - __gc finalizer as safety net for older Lua versions
  • Less error-prone - Harder to forget cleanup or mix up handles
  • Lua-idiomatic API - Feels natural to Lua developers

How Classes Work

When a plugin defines classes in its manifest, Plugify automatically generates Lua class wrappers that:

  1. Wrap the underlying handle - Store the raw pointer internally
  2. Bind methods - Convert function calls to method calls with automatic handle passing
  3. Manage lifecycle - Automatically call destructor when the object is no longer needed
  4. Provide utility methods - get(), release(), valid(), close(), reset() for handle management
  5. Support scope-based cleanup - Implement __close for Lua 5.4+ to-be-closed variables
  6. Provide fallback cleanup - Implement __gc for garbage collector finalization

Defining Classes in Your Manifest

To create classes for your plugin, add a classes section to your manifest:

plugin.pplugin
{
  "name": "example_plugin",
  "version": "1.0.0",
  "language": "cpp",
  "methods": [
    {
      "name": "Kv1Create",
      "funcName": "Kv1Create",
      "paramTypes": [
        { "name": "setName", "type": "string" }
      ],
      "retType": { "type": "ptr64" }
    },
    {
      "name": "Kv1Destroy",
      "funcName": "Kv1Destroy",
      "paramTypes": [
        { "name": "kv", "type": "ptr64" }
      ],
      "retType": { "type": "void" }
    },
    {
      "name": "Kv1GetName",
      "funcName": "Kv1GetName",
      "paramTypes": [
        { "name": "kv", "type": "ptr64" }
      ],
      "retType": { "type": "string" }
    },
    {
      "name": "Kv1SetName",
      "funcName": "Kv1SetName",
      "paramTypes": [
        { "name": "kv", "type": "ptr64" },
        { "name": "name", "type": "string" }
      ],
      "retType": { "type": "void" }
    }
  ],
  "classes": [
    {
      "name": "KeyValues",
      "description": "RAII wrapper for KeyValues handle",
      "handleType": "ptr64",
      "invalidValue": "0",
      "constructors": ["Kv1Create"],
      "destructor": "Kv1Destroy",
      "bindings": [
        {
          "name": "GetName",
          "method": "Kv1GetName",
          "bindSelf": true
        },
        {
          "name": "SetName",
          "method": "Kv1SetName",
          "bindSelf": true
        }
      ]
    }
  ]
}

Key fields explained:

  • name: The Lua class name (PascalCase recommended)
  • handleType: The type of the underlying handle (usually ptr64 or ptr32)
  • invalidValue: What value represents an invalid handle (usually "0" or "-1")
  • constructors: Array of method names that create instances
  • destructor: Method name that cleans up resources (optional)
  • bindings: Array of methods available on the class
    • bindSelf: If true, automatically passes the handle as the first parameter

Generated Lua Code

When you define classes in your manifest, Plugify generates Lua class wrappers with several built-in methods:

Generated Class (Conceptual)
local KeyValues = {}
KeyValues.__type = "KeyValues"
KeyValues.__index = KeyValues

-- Constructor
function KeyValues.new(...)
    local self = setmetatable({}, KeyValues)

    -- Initialize to invalid state first
    self._handle = 0  -- invalid_value
    self._owned = Ownership.BORROWED

    local args = {...}

    -- Direct handle construction
    -- Pattern: KeyValues.new(handle_value, Ownership.OWNED)
    if #args >= 2 and Ownership.is(args[2]) then
        self._handle = args[1]
        self._owned = args[2]
        return self
    end

    -- Constructor call mode
    local success, result = pcall(_plugin.Kv1Create, table.unpack(args))
    if success then
        self._handle = result
        self._owned = Ownership.OWNED
        return self
    else
        error(result)
    end
end

-- close method
function KeyValues:close()
    if not self._handle then
        return
    end

    if self._handle ~= 0 and self._owned == Ownership.OWNED then
        _plugin.Kv1Destroy(self._handle)
    end
    self._handle = 0
    self._owned = Ownership.BORROWED
end

-- <close> metamethod for Lua 5.4+ to-be-closed variables
KeyValues.__close = function(self)
    self:close()
end

-- __gc metamethod (garbage collector finalizer)
KeyValues.__gc = function(self)
    self:close()
end

-- Utility methods
function KeyValues:get()
    if not self._handle then
        return 0
    end
    return self._handle
end

function KeyValues:release()
    if not self._handle then
        return 0
    end
    local tmp = self._handle
    self._handle = 0
    self._owned = Ownership.BORROWED
    return tmp
end

function KeyValues:reset()
    self:close()
end

function KeyValues:valid()
    if not self._handle then
        return false
    end
    return self._handle ~= 0
end

-- Bound methods (with automatic handle validation)
function KeyValues:GetName()
    if not self._handle or self._handle == 0 then
        error("KeyValues handle is closed or not initialized")
    end
    return _plugin.Kv1GetName(self._handle)
end

function KeyValues:SetName(name)
    if not self._handle or self._handle == 0 then
        error("KeyValues handle is closed or not initialized")
    end
    _plugin.Kv1SetName(self._handle, name)
end

function KeyValues:FindKey(keyName)
    if not self._handle or self._handle == 0 then
        error("KeyValues handle is closed or not initialized")
    end
    local result = _plugin.Kv1FindKey(self._handle, keyName)
    -- Automatically wraps return value based on retAlias
    if result ~= 0 then
        return KeyValues.new(result, Ownership.OWNED)
    end
    return nil
end

function KeyValues:AddSubKey(subKey)
    if not self._handle or self._handle == 0 then
        error("KeyValues handle is closed or not initialized")
    end
    -- Automatically releases ownership from subKey parameter
    local handle = (type(subKey) == "table" and subKey.release) and subKey:release() or subKey
    _plugin.Kv1AddSubKey(self._handle, handle)
end

Built-in Utility Methods

Every generated class includes several utility methods for handle management:

valid() - Check Handle Validity

Returns true if the handle is valid (not equal to invalidValue):

Using valid()
local s2sdk = require("s2sdk")

local kv = s2sdk.KeyValues.new("Config")

if kv:valid() then
    kv:SetName("ServerConfig")
    print("Handle is valid")
else
    print("Handle is invalid")
end

-- After release, handle becomes invalid
local handle = kv:release()
print(kv:valid())  -- false

get() - Access Raw Handle

Returns the underlying handle value. Use this when you need to pass the raw handle to C-style functions:

Using get()
local s2sdk = require("s2sdk")

local kv = s2sdk.KeyValues.new("Config")

-- Get the raw handle
local raw_handle = kv:get()
print("Handle value: " .. tostring(raw_handle))

-- Pass to a function expecting a raw handle
some_c_function(kv:get())

release() - Transfer Ownership

Releases ownership of the handle and returns it. After calling release(), the class instance becomes invalid and will not call the destructor:

Using release()
local s2sdk = require("s2sdk")

local function create_and_release()
    local kv = s2sdk.KeyValues.new("Config")
    kv:SetName("ServerConfig")

    -- Transfer ownership out
    local handle = kv:release()

    -- kv is now invalid, won't clean up
    print(kv:valid())  -- false

    return handle
end

-- We now own the handle and must clean it up manually
local raw_handle = create_and_release()
-- ... use raw_handle ...
s2sdk.Kv1Destroy(raw_handle)  -- Manual cleanup required!

Use release() when you need to:

  • Transfer ownership to another system
  • Store the handle in a long-lived data structure
  • Interface with C-style code that takes ownership

close() - Manual Cleanup

Explicitly closes the handle and calls the destructor if the object owns it:

Using close()
local s2sdk = require("s2sdk")

local kv = s2sdk.KeyValues.new("Config")
kv:SetName("ServerConfig")

-- Explicitly close the handle now
kv:close()

-- Handle is now invalid
print(kv:valid())  -- false

-- Methods will raise error
local success, err = pcall(function()
    kv:SetName("Test")
end)
if not success then
    print("Error: " .. err)  -- "KeyValues handle is closed"
end

The close() method is:

  • Idempotent - Safe to call multiple times
  • Called automatically - By __close and __gc
  • Ownership-aware - Only calls destructor if object owns the handle

reset() - Alias for Close

Convenience method that calls close():

Using reset()
local s2sdk = require("s2sdk")

local kv = s2sdk.KeyValues.new("Config")
kv:SetName("ServerConfig")

-- Reset is equivalent to close
kv:reset()

print(kv:valid())  -- false

Resource Management

Lua classes generated by Plugify support multiple cleanup patterns:

Lua 5.4 introduced to-be-closed variables using the <close> annotation. This provides deterministic cleanup similar to other languages' RAII:

To-be-closed Variables (Lua 5.4+)
local s2sdk = require("s2sdk")

do
    local kv <close> = s2sdk.KeyValues.new("Config")
    kv:SetName("ServerConfig")
    kv:SetString("hostname", "My Server")
    -- ... use kv ...
    -- kv:close() is automatically called when leaving this scope
end
-- Definitely cleaned up here!

This is the recommended pattern for Lua 5.4+ because:

  • Guaranteed cleanup even if errors occur
  • Clear scope of resource lifetime
  • Deterministic cleanup timing
  • Lua-idiomatic for modern code

Automatic Cleanup (All Lua Versions)

The garbage collector finalizer (__gc) is called automatically when the object is garbage collected:

Garbage Collector Cleanup
local s2sdk = require("s2sdk")

local function process_config()
    local kv = s2sdk.KeyValues.new("Config")
    kv:SetName("ServerConfig")
    -- ... use kv ...
    -- kv will be destroyed when garbage collected
end

process_config()
-- kv will be cleaned up eventually by GC

Manual Cleanup

For immediate resource cleanup in any Lua version, use close() explicitly:

Manual Cleanup
local s2sdk = require("s2sdk")

local kv = s2sdk.KeyValues.new("Config")
kv:SetName("ServerConfig")
-- ... use kv ...

-- Explicitly cleanup now
kv:close()
-- Handle is destroyed immediately

No Destructor Case

If a class doesn't have a destructor defined in the manifest, it acts as a simple wrapper without automatic cleanup:

Wrapper Without Destructor
local s2sdk = require("s2sdk")

-- Class with no destructor - just a convenience wrapper
local wrapper = s2sdk.SomeWrapper.new()

-- Still has utility methods
if wrapper:valid() then
    local handle = wrapper:get()
end

-- No automatic cleanup - handle persists
-- Useful for stateless wrappers or global resources

Working with Ownership

Some methods transfer ownership of resources. The manifest specifies this with the owner field in paramAliases and retAlias.

Taking Ownership (Method Parameters)

When a method takes ownership of a resource, you should transfer it and not use it afterward:

Ownership Transfer
local s2sdk = require("s2sdk")

-- Create parent and child
local parent = s2sdk.KeyValues.new("Parent")
local child = s2sdk.KeyValues.new("Child")

-- AddSubKey takes ownership of child
parent:AddSubKey(child)

-- child is now owned by parent
-- child:valid() may still be true, but don't use it!
-- parent will handle cleanup

In the manifest, this is defined as:

Manifest Ownership
{
  "name": "AddSubKey",
  "method": "Kv1AddSubKey",
  "bindSelf": true,
  "paramAliases": [
    {
      "name": "subKey",
      "owner": true  // This parameter takes ownership
    }
  ]
}

Best practice: After transferring ownership, avoid using the object:

Proper Ownership Handling
local s2sdk = require("s2sdk")

local parent = s2sdk.KeyValues.new("Parent")
local child = s2sdk.KeyValues.new("Child")

-- Transfer ownership
parent:AddSubKey(child)

-- Release our reference to prevent accidental use
local child_handle = child:release()  -- Now child:valid() == false

-- Only use through parent
local found = parent:FindKey("Child")

Returning Ownership (Return Values)

When a method returns a new resource with ownership:

Return Ownership
local s2sdk = require("s2sdk")

local parent = s2sdk.KeyValues.new("Parent")

-- FindKey returns a NEW KeyValues that we own
local child = parent:FindKey("Settings")

if child and child:valid() then
    child:SetName("UpdatedSettings")
    -- We're responsible for child's lifecycle
    -- It will be cleaned up automatically when it goes out of scope (Lua 5.4+)
    -- Or by GC (all Lua versions)
end

In the manifest:

Manifest Return Ownership
{
  "name": "FindKey",
  "method": "Kv1FindKey",
  "bindSelf": true,
  "retAlias": {
    "name": "KeyValues",
    "owner": true  // Caller owns the returned object
  }
}

Non-Owning References

When owner: false, the method returns a reference without transferring ownership:

Non-Owning Reference
local s2sdk = require("s2sdk")

local parent = s2sdk.KeyValues.new("Parent")

-- GetFirstSubKey returns a reference, parent still owns it
local child_ref = parent:GetFirstSubKey()

if child_ref and child_ref:valid() then
    -- Use the reference, but DON'T close it
    local name = child_ref:GetName()
    -- Don't call child_ref:close() or child_ref:release()
    -- child_ref will be cleaned up by parent
end

Plugin Lifecycle and Global Objects

When using classes in plugins, you must be careful about object cleanup during plugin unload. Lua's garbage collector finalizers (__gc) are called when objects are collected, but if objects are still alive when your plugin is unloaded, their finalizers may be called after the plugin's code is no longer in memory, leading to undefined behavior or crashes.

Managing Global Objects

If you store class instances in global variables or module-level tables, you must explicitly clean them up in your plugin's PluginEnd() function:

plugin.lua
-- Global object - dangerous if not cleaned up!
local g_config = nil

function PluginStart()
    -- Create global configuration
    g_config = s2sdk.KeyValues.new("GlobalConfig")
    g_config:SetName("ServerSettings")
    print("Plugin started with global config")
end

function PluginEnd()
    -- CRITICAL: Clean up global objects before plugin unload
    if g_config then
        g_config:close()
        g_config = nil
    end
    print("Plugin ended, global config cleaned up")
end

Safe Patterns

✅ Safe: Local scope with to-be-closed (Lua 5.4+)

Safe Pattern
function OnCommand(args)
    -- Local object with <close> - automatically cleaned up
    local kv <close> = s2sdk.KeyValues.new("TempConfig")
    kv:SetName("CommandConfig")
    -- ... use kv ...
    -- Automatically destroyed when leaving scope - safe!
end

✅ Safe: Local scope with manual cleanup

Safe Pattern
function OnCommand(args)
    local kv = s2sdk.KeyValues.new("TempConfig")
    kv:SetName("CommandConfig")
    -- ... use kv ...
    kv:close()  -- Explicit cleanup - safe!
end

✅ Safe: Module variable with cleanup

Safe Pattern
local plugin_config = nil

function PluginStart()
    plugin_config = s2sdk.KeyValues.new("PluginConfig")
    plugin_config:SetName("Settings")
end

function PluginEnd()
    -- Clean up module variables
    if plugin_config then
        plugin_config:close()
        plugin_config = nil
    end
end

❌ Unsafe: Global without cleanup

Unsafe Pattern
-- DANGEROUS: Global object
g_config = s2sdk.KeyValues.new("GlobalConfig")

function PluginStart()
    g_config:SetName("ServerSettings")
end

function PluginEnd()
    -- MISSING: No cleanup!
    -- g_config finalizer will run after plugin unload - CRASH!
end

❌ Unsafe: Module-level cache without cleanup

Unsafe Pattern
-- DANGEROUS: Module-level cache
local kv_cache = {}

function CacheConfig(name)
    kv_cache[name] = s2sdk.KeyValues.new(name)
end

function PluginEnd()
    -- MISSING: No cache cleanup!
    -- Cached objects will finalize after plugin unload - CRASH!
end

Cleanup Checklist

Before your plugin unloads (PluginEnd()), ensure:

  1. ✅ All global class instances are explicitly closed or set to nil
  2. ✅ All module-level tables containing class instances are cleared
  3. ✅ No references to class instances remain in any long-lived data structures
  4. ✅ Circular references are broken (if any)

Complete Example: Configuration System

Here's a complete example showing how to use classes for a configuration system:

config_manager.lua
local s2sdk = require("s2sdk")

local ConfigManager = {}
ConfigManager.__index = ConfigManager

function ConfigManager.new(config_name)
    local self = setmetatable({}, ConfigManager)
    self.root = s2sdk.KeyValues.new(config_name)

    if not self.root:valid() then
        error("Failed to create config: " .. config_name)
    end

    return self
end

function ConfigManager:close()
    if self.root then
        self.root:close()
        self.root = nil
    end
end

-- Lua 5.4+ support
ConfigManager.__close = function(self)
    self:close()
end

function ConfigManager:create_section(section_name)
    local section = s2sdk.KeyValues.new(section_name)

    if not section:valid() then
        return false
    end

    self.root:AddSubKey(section)
    return true
end

function ConfigManager:get_section(section_name)
    local section = self.root:FindKey(section_name)

    if section and section:valid() then
        return section
    end
    return nil
end

function ConfigManager:set_value(section_name, key, value)
    local section = self:get_section(section_name)

    if section then
        section:SetString(key, value)
        return true
    end
    return false
end

function ConfigManager:get_value(section_name, key, default)
    default = default or ""
    local section = self:get_section(section_name)

    if section then
        return section:GetString(key, default)
    end
    return default
end

function ConfigManager:save(filename)
    if not self.root:valid() then
        return false
    end
    return self.root:SaveToFile(filename)
end

function ConfigManager:load(filename)
    if not self.root:valid() then
        return false
    end
    return self.root:LoadFromFile(filename)
end

-- Usage (Lua 5.4+)
do
    local config <close> = ConfigManager.new("ServerConfig")
    config:create_section("Server")
    config:set_value("Server", "hostname", "My Server")
    config:set_value("Server", "maxplayers", "32")

    config:create_section("Game")
    config:set_value("Game", "mode", "competitive")

    config:save("config.kv")
    -- Automatically cleaned up when leaving scope
end

Best Practices

  1. Use <close> for Lua 5.4+ - Use to-be-closed variables for guaranteed cleanup
  2. Use close() for immediate cleanup - More explicit than relying on garbage collection
  3. Clean up global objects in PluginEnd() - Critical: Explicitly clean up all global or module-level class instances before plugin unload to avoid crashes
  4. Prefer local scope - Keep class instances in local scope when possible for automatic cleanup
  5. Check valid() for safety - Especially after operations that might fail
  6. Respect ownership - Don't use objects after transferring ownership
  7. Use release() sparingly - Only when you need manual control
  8. Avoid mixing styles - Prefer class API over raw C-style functions
  9. Don't rely on __gc timing - Garbage collection is non-deterministic, use <close> or close() for immediate cleanup
  10. Handle errors - Be prepared to catch errors from methods called on closed handles
  11. Check returned objects - Methods might return nil on failure

When NOT to Use Classes

Classes are designed for resources that need lifecycle management. Don't define classes for:

  • Stateless utility functions - Simple functions that don't manage resources
  • Functions that return primitive values - No need to wrap simple getters
  • One-off operations - Operations that don't maintain state
  • Global singletons - Resources that live for the entire program lifetime

For these cases, continue using the regular function-based API.

Troubleshooting

"Failed to create X" Error

If a constructor raises an error, the underlying create function returned an invalid handle:

Handle Constructor Failure
local success, err = pcall(function()
    return s2sdk.KeyValues.new("Config")
end)

if not success then
    print("Failed to create KeyValues: " .. err)
    -- Handle error - maybe retry or use default config
end

Using Closed or Released Objects

Attempting to use an object after calling close() or release() will raise an error:

Using Closed Handle
local s2sdk = require("s2sdk")

local kv = s2sdk.KeyValues.new("Config")
kv:close()

-- This raises an error
local success, err = pcall(function()
    kv:SetName("Test")
end)

if not success then
    print(err)  -- "KeyValues handle is closed"
end

-- Check valid() first to avoid errors
if kv:valid() then
    kv:SetName("Test")
else
    print("Handle is closed!")
end

All bound methods automatically validate the handle before calling the underlying C function:

Automatic Validation
local s2sdk = require("s2sdk")

local kv = s2sdk.KeyValues.new("Config")
local handle = kv:release()

-- Any method call will fail
local success, err = pcall(function()
    return kv:GetName()
end)

if not success then
    print(err)  -- "KeyValues handle is closed"
end

Dangling References

Be careful with non-owning references when the owner is destroyed:

Dangling Reference Problem
local function get_child_ref()
    local parent = s2sdk.KeyValues.new("Parent")
    local child = parent:GetFirstSubKey()  -- Non-owning reference
    return child  -- BAD: parent will be destroyed!
end

-- This is dangerous!
local child_ref = get_child_ref()
-- child_ref now points to destroyed memory

-- Better approach:
local function get_child_owned()
    local parent = s2sdk.KeyValues.new("Parent")
    local child = parent:FindKey("Child")  -- Returns owned instance
    -- Need to keep parent alive or ensure child is used before parent cleanup
    return child  -- Safe if FindKey returns owned and used before GC
end

Crashes on Plugin Unload

If your plugin crashes when unloading:

  1. Check global objects - Ensure all global class instances are cleaned up in PluginEnd()
  2. Check module-level tables - Clear any tables containing class instances
  3. Add explicit cleanup - Use close() on all long-lived objects before plugin unload
  4. Force garbage collection - Call collectgarbage() before plugin unload to trigger finalizers early
Fix Unload Crash
-- Global objects that caused crashes
local g_config = nil
local g_cache = {}

function PluginStart()
    g_config = s2sdk.KeyValues.new("Config")
    g_cache["main"] = s2sdk.KeyValues.new("Main")
end

function PluginEnd()
    -- Clean up global objects
    if g_config then
        g_config:close()
        g_config = nil
    end

    -- Clean up cached objects
    for k, v in pairs(g_cache) do
        v:close()
    end
    g_cache = {}

    -- Optional: Force GC to run finalizers
    collectgarbage()

    print("All resources cleaned up safely")
end

Lua Version Compatibility

Lua 5.4+

  • Full support for <close> to-be-closed variables (recommended)
  • __gc finalizer as fallback
Lua 5.4+
-- Recommended: Use <close>
local kv <close> = s2sdk.KeyValues.new("Config")
kv:SetName("ServerConfig")
-- Automatically cleaned up when leaving scope

Lua 5.1, 5.2, 5.3

  • No <close> support
  • __gc finalizer only
  • Explicit close() recommended
Lua 5.1-5.3
-- Recommended: Explicit close
local kv = s2sdk.KeyValues.new("Config")
kv:SetName("ServerConfig")
kv:close()  -- Explicit cleanup

-- Or rely on __gc (non-deterministic)
local kv2 = s2sdk.KeyValues.new("Config2")
-- Will be cleaned up eventually by GC

See Also

  • Learn how to export your own classes
  • Learn about importing functions
  • Plugin manifest reference