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.
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
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
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
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())
Warning: Be careful when using get(). The returned handle is still owned by the class instance and will be destroyed when the instance is cleaned up.
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!
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
local s2sdk = require("s2sdk")
local kv = s2sdk.KeyValues.new("Config")
kv:SetName("ServerConfig")
-- Reset is equivalent to close
kv:reset()
print(kv:valid()) -- false
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:
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
Important - Plugin Lifecycle: The __gc finalizer is called by Lua's garbage collector when the object is collected. However, you must avoid situations where finalizers are called after your plugin is unloaded. If you store objects globally, you are responsible for cleaning them up in PluginEnd(), otherwise behavior is undefined and may cause crashes.
Note: __gc timing is non-deterministic and depends on garbage collection cycles. For immediate cleanup, use <close> (Lua 5.4+) or call close() explicitly.
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
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
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
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")
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
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
Important: With non-owning references, the returned object is still valid (has a handle), but you don't own it. Closing the parent will invalidate these references.
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.
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
Critical: Failing to clean up global objects in PluginEnd() may cause finalizers to run after your plugin is unloaded, resulting in crashes or undefined behavior. Always explicitly clean up global resources!
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
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
Use <close> for Lua 5.4+ - Use to-be-closed variables for guaranteed cleanup
Use close() for immediate cleanup - More explicit than relying on garbage collection
Clean up global objects in PluginEnd() - Critical: Explicitly clean up all global or module-level class instances before plugin unload to avoid crashes
Prefer local scope - Keep class instances in local scope when possible for automatic cleanup
Check valid() for safety - Especially after operations that might fail
Respect ownership - Don't use objects after transferring ownership
Use release() sparingly - Only when you need manual control
Avoid mixing styles - Prefer class API over raw C-style functions
Don't rely on __gc timing - Garbage collection is non-deterministic, use <close> or close() for immediate cleanup
Handle errors - Be prepared to catch errors from methods called on closed handles
Check returned objects - Methods might return nil on failure
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
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
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
Check global objects - Ensure all global class instances are cleaned up in PluginEnd()
Check module-level tables - Clear any tables containing class instances
Add explicit cleanup - Use close() on all long-lived objects before plugin unload
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
-- 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