Copied to clipboard!

Build addons
with imLib

A modular, object-oriented library for Garry's Mod. Themes, networking, database, UI elements, state sync, HTTP — everything you need to ship professional addons without reinventing the wheel.

OO Architecture Shared Realm Themed UI Zero Dependencies

📦 Installation

Drop the imlib folder into your server's addons/ directory. imLib auto-loads via autorun/imlib.lua and is available on both client and server as the global imLib table. When imLib finishes loading, it fires the imLib:Loaded hook.

SHARED
addons/
└── imlib/
    └── lua/
        ├── autorun/
        │   └── imlib.lua          -- Entry point
        └── imlib/
            ├── sh_init.lua        -- Load orchestrator
            ├── modules/           -- Core modules
            ├── elements/          -- VGUI elements
            ├── themes/            -- Built-in themes
            ├── languages/         -- Translation files
            └── utilities/         -- Drawing, scale, etc.

🚀 Addon Bootstrap

imLib.Addon(id, globalName) is the single entry point for addon authors. It creates your global table, enables subsystems, queues files, and boots everything in one clean chain.

SHARED
-- addons/myaddon/lua/autorun/myaddon.lua

local addon = imLib.Addon("myaddon", "MYADDON")

addon:SetName("My Addon")
addon:SetAuthor("YourName")
addon:SetVersion("1.0.0")
addon:SetDescription("A cool addon built with imLib")

-- Enable subsystems (order doesn't matter)
addon:UseLogger()
addon:UseDatabase()
addon:UseConfig()
addon:UseLanguages()
addon:UseThemes()         -- Overrides imLib element themes!
addon:UseStateSync()
addon:UseHTTP({ base_url = "https://api.example.com" })

-- Queue files & folders
addon:AddFolder("modules",   imLib.FileLoader.REALM_SHARED, true)
addon:AddFolder("themes",    imLib.FileLoader.REALM_SHARED)
addon:AddFolder("languages", imLib.FileLoader.REALM_SHARED)
addon:AddFile("sh_config",   imLib.FileLoader.REALM_SHARED)

-- Queue a raw function to execute at this point in the load order
addon:AddFunction(function()
    print("Loaded up to this point!")
end)

-- Boot everything
addon:Load()
Theme Override: When an addon calls UseThemes(), it registers as a theme provider. All imLib UI elements (Frame, Button, Checkbox, etc.) automatically use that addon's active theme. The last-loaded addon with UseThemes() wins.

What :Load() Creates

SubsystemCreates on GlobalDescription
UseLogger()MYADDON.LoggerConsoleLogger instance
UseDatabase()MYADDON.DatabaseSQLite / MySQLOO instance
UseConfig()MYADDON.ConfigSettings instance
UseLanguages()MYADDON.L(), .Languages, .SetLanguage(), .GetLanguage(), .GetLanguages(), .RegisterLanguage()Translation system
UseThemes()MYADDON.Themes, .SetTheme(), .GetTheme(), .GetActiveTheme(), .GetThemes()Theme registry + provider
UseStateSync()MYADDON.StateSyncIsolated state manager
UseHTTP()MYADDON.HTTPHTTP client with defaults

Addon Instance Methods

MethodDescription
:GetID()Returns the addon's string identifier
:GetGlobalName()Returns the global table name (e.g. "MYADDON")
:GetTable()Returns the global table reference (_G[globalName])
:GetName()Returns the display name
:GetAuthor()Returns the author string
:GetVersion()Returns the version string
:GetDescription()Returns the description string
:AddFunction(fn)Queue a raw function in the load order

📂 File Loader

The FileLoader handles include() and AddCSLuaFile() automatically based on realm and filename prefix conventions.

SHARED
local loader = imLib.FileLoader()
    :setDirectory("myaddon")
    :setLogger(imLib.Logger)

    -- Realm constants
    -- REALM_AUTO   = detect from filename (sv_, cl_, sh_)
    -- REALM_SERVER = server only
    -- REALM_CLIENT = client only (auto AddCSLuaFile)
    -- REALM_SHARED = both realms

    :addFolder("modules", imLib.FileLoader.REALM_SHARED, true)
    :addFile("config", imLib.FileLoader.REALM_SHARED)

:run()
MethodDescription
:setDirectory(dir)Set the base directory for file resolution
:setLogger(logger)Attach a ConsoleLogger for file-load messages
:addFile(path, realm, opts)Queue a single Lua file. Extension is auto-appended.
:addFolder(path, realm, recursive, opts, ignore)Queue all .lua files in a folder. ignore is a table of filenames to skip.
:addFunction(fn)Queue a raw function to execute at this point in the load chain
:run()Execute all queued files/functions in order

📋 Logger

Colored console output with configurable log levels. Each addon gets its own prefixed logger.

SHARED
local log = imLib.ConsoleLogger("MyAddon", {
    log     = true,
    debug   = true,
    error   = true,
    warning = true,
    success = true,
})

log:log("Player connected")
log:debug("Processing inventory...")
log:success("Data saved")
log:warning("Rate limit approaching")
log:error("Database connection failed")

⚙️ Settings

Typed, validated configuration system with serialize/deserialize for DB persistence. Supports bool, int, float, string, model, select, and table types.

SHARED
local cfg = imLib.Settings("myaddon")

cfg:Register("max_items", {
    type    = "int",
    default = 50,
    min     = 1,
    max     = 500,
    desc    = "Maximum inventory slots",
})

cfg:Register("vip_enabled", {
    type    = "bool",
    default = false,
})

cfg:Register("difficulty", {
    type    = "select",
    default = "normal",
    options = { "easy", "normal", "hard" },
})

-- Get / Set
local max = cfg:Get("max_items")             -- 50
local ok, err = cfg:Set("max_items", 200)   -- true
local ok, err = cfg:Set("max_items", 9999)  -- false, MAX_NUMBER

-- Export / Import (for saving)
local data = cfg:Export()                    -- { max_items = 200, ... }
cfg:Import(data)

🌍 Languages

Per-addon translation system with phrase lookup, format arguments, and fallback to a default language.

SHARED
-- languages/en.lua
local lang = imLib.Language("en")
lang:SetName("English")
lang:SetPhrases({
    greeting    = "Hello, %s!",
    balance     = "Balance: %s%d",
    shop_title  = "Item Shop",
})
MYADDON.RegisterLanguage(lang)

-- Usage anywhere
local text = MYADDON.L("greeting", "Player")   -- "Hello, Player!"
local bal  = MYADDON.L("balance", "$", 5000)    -- "Balance: $5000"

-- Switch language
MYADDON.SetLanguage("pl")
Fallback: If a phrase isn't found in the active language, imLib falls back to the default language (first registered, or set via UseLanguages("en")). If still missing, the raw key is returned.

🎨 Themes

Color, font, and material theming. Themes can be registered per-addon or globally. All imLib elements resolve their colors via imLib.GetTheme().

Built-in Themes

Default (Dark)
Warm Sunset
Light
Neon Dark

Creating a Custom Theme

SHARED
local theme = imLib.Theme("my_dark")
theme:SetName("My Dark Theme")
theme:SetColors({
    background  = Color(20, 20, 25),
    primary     = Color(40, 40, 48),
    accent      = Color(90, 200, 160),
    text        = Color(230, 230, 235),
    textdark    = Color(140, 140, 150),
    -- ... all color keys
})
theme:SetBoxRoundness(8)
theme:Register()   -- Register globally on imLib

-- Or register on your addon
MYADDON.RegisterTheme(theme)
MYADDON.SetTheme("my_dark")

Static Color Helpers

SHARED
local c = imLib.Theme.LerpColor(0.5, colorA, colorB)
local d = imLib.Theme.Darken(color, 30)
local l = imLib.Theme.Lighten(color, 20)
local a = imLib.Theme.Alpha(color, 128)

🗃️ Database

Fluent query builder supporting both SQLite and MySQLOO. Chainable select, insert, update, delete, upsert, and DDL operations.

SERVER
-- SQLite (default)
local db = imLib.Database()
db:Connect()

-- MySQLOO
local db = imLib.Database({
    driver   = "mysqloo",
    host     = "127.0.0.1",
    port     = 3306,
    database = "gmod",
    username = "root",
    password = "",
})
db:Connect(function(success, err) end)
SERVER
db:Select("players")
    :Columns("steamid", "name", "balance")
    :Where("balance", ">", 100)
    :OrderBy("balance", "DESC")
    :Limit(10)
    :Execute(function(rows)
        PrintTable(rows)
    end)
SERVER
db:Insert("players")
    :Set("steamid", ply:SteamID64())
    :Set("name", ply:Nick())
    :Set("balance", 1000)
    :Execute()
SERVER
db:CreateTable("players")
    :Column("id", "INTEGER", { primary = true, autoincrement = true })
    :Column("steamid", "VARCHAR(32)", { unique = true, notnull = true })
    :Column("balance", "INTEGER", { default = 0 })
    :IfNotExists()
    :Execute()

⏱️ Timers

OO timer manager with cooldowns, debounce, throttle, grouping, pause/resume, and error handling. Wraps GMod's timer library with a clean instance-based API.

Basic Timers

SHARED
-- One-shot delayed callback
imLib.Timers:Simple("greet", 5, function(tmr)
    print("Hello after 5 seconds!")
end)

-- Repeating timer (10 times, every 1 second)
imLib.Timers:Create("heartbeat")
    :SetDelay(1)
    :SetRepetitions(10)
    :SetCallback(function(tmr)
        print("Beat #" .. tmr:GetElapsedReps())
    end)
    :SetOnComplete(function(tmr)
        print("All 10 beats complete!")
    end)
    :Start()

-- Infinite repeat (0 = forever)
imLib.Timers:Create("autosave")
    :SetDelay(300)
    :SetRepetitions(0)
    :SetCallback(function() SaveData() end)
    :Start()

Cooldowns

SHARED
-- After firing, cannot be restarted for 10 seconds
imLib.Timers:Create("ability")
    :SetDelay(0)
    :SetCooldown(10)
    :SetCallback(function(tmr)
        print("Ability used!")
    end)
    :Start()

-- Later...
local tmr = imLib.Timers:Get("ability")
if tmr:IsOnCooldown() then
    print("On cooldown for " .. tmr:GetCooldownRemaining() .. "s")
end

Debounce & Throttle

SHARED
-- Debounce: fires AFTER 0.3s of inactivity (search input)
hook.Add("OnTextChanged", "Search", function()
    imLib.Timers:Debounce("search", 0.3, function()
        RunSearch(GetSearchText())
    end)
end)

-- Throttle: fires AT MOST once every 5 seconds
imLib.Timers:Throttle("save", 5, function()
    AutoSave()
end)

Groups & Lifecycle

SHARED
-- Assign timers to groups
imLib.Timers:Create("anim_1"):SetGroup("ui"):SetDelay(1):SetCallback(fn):Start()
imLib.Timers:Create("anim_2"):SetGroup("ui"):SetDelay(2):SetCallback(fn):Start()

-- Bulk operations
imLib.Timers:PauseGroup("ui")
imLib.Timers:ResumeGroup("ui")
imLib.Timers:RemoveGroup("ui")

-- Individual control
local t = imLib.Timers:Get("heartbeat")
t:Pause()
t:Resume()
t:Stop()
t:Destroy()

📡 Network Messages

Typed network message system that replaces net.WriteTable / net.ReadTable. Adds compressed table serialization, per-player cooldowns, rate limiting, debounce, middleware hooks, and field-level validation.

SHARED
imLib.Network:Register("Shop:BuyItem")
    :Field("item_id",   "uint",   { bits = 16 })
    :Field("quantity",  "uint",   { bits = 8, min = 1, max = 64 })
    :Field("gift_to",   "entity", { optional = true })
    :SetCooldown(1)              -- 1s per player
    :SetRateLimit(10, 30)       -- Max 10 per 30s
    :SetDebounce(0.5)            -- 0.5s debounce on send
    :SetValidation(function(data, ply)
        return data.quantity > 0
    end)
    :OnReceive(function(data, ply)
        print(ply:Nick(), "buys", data.item_id, "x", data.quantity)
    end)
CLIENT
-- Client → Server
imLib.Network:Send("Shop:BuyItem", {
    item_id  = 42,
    quantity = 3,
})
SERVER
-- Server → specific client(s)
imLib.Network:Send("Shop:BuyItem", {
    item_id  = 42,
    quantity = 3,
}, ply)

-- Server → all clients
imLib.Network:Broadcast("Shop:BuyItem", {
    item_id  = 42,
    quantity = 3,
})
SHARED
-- Define a message with a "table" field
imLib.Network:Register("Sync:Inventory")
    :Field("inventory", "table")
    :SetCooldown(5)
    :OnReceive(function(data, ply)
        -- data.inventory is auto-decompressed
        PrintTable(data.inventory)
    end)

-- Tables are: JSON-encoded → util.Compress → net.WriteData
-- Far more efficient than net.WriteTable
SHARED
-- BeforeSend: runs before data is written to the net message
imLib.Network:Register("Chat:Send")
    :Field("message", "string")
    :BeforeSend(function(data)
        data.message = string.Trim(data.message)
        return data
    end)

-- BeforeReceive: runs after reading but before OnReceive callback
    :BeforeReceive(function(data, ply)
        data.message = string.sub(data.message, 1, 256)
        return data
    end)
    :OnReceive(function(data, ply)
        print(ply:Nick() .. ": " .. data.message)
    end)
Middleware Order: BeforeSend → net write → net read → BeforeReceive → validation → OnReceive. Use these hooks for data transformation, sanitization, or logging.

Supported Field Types

TypeWrites / ReadsOptions
"string"net.WriteString / ReadString
"int"net.WriteInt / ReadIntbits (default 32), min, max
"uint"net.WriteUInt / ReadUIntbits (default 32), min, max
"float"net.WriteFloat / ReadFloat
"double"net.WriteDouble / ReadDouble
"bool"net.WriteBool / ReadBool
"entity"net.WriteEntity / ReadEntity
"vector"net.WriteVector / ReadVector
"angle"net.WriteAngle / ReadAngle
"color"net.WriteColor / ReadColor
"table"JSON → Compress → WriteData

Network Utility Methods

MethodDescription
:Get(id)Retrieve a registered message definition by ID
:IsRegistered(id)Check whether a message ID has been registered
:IsOnCooldown(id, ply)Check if a message is on cooldown for a player
:GetAll()Returns a table of all registered message definitions
:CompressTable(tbl)Manually JSON-encode and compress a table
:DecompressTable(data, len)Manually decompress and decode table data

🔄 State Sync

Server-authoritative reactive state. Define a schema, set values on the server, clients receive compressed delta updates automatically. Eliminates 80% of boilerplate net messages.

Defining Stores

SERVER
-- Global state (broadcast to everyone)
imLib.StateSync:Define("economy", {
    scope  = "global",
    schema = {
        tax_rate     = "float",
        bonus_active = "bool",
        motd         = "string",
    },
    defaults = {
        tax_rate     = 0.1,
        bonus_active = false,
        motd         = "Welcome!",
    },
})

-- Per-player private data (only that player sees it)
imLib.StateSync:Define("wallet", {
    scope  = "player",
    schema = {
        balance = "int",
        vip     = "bool",
        items   = "table",
    },
    defaults = {
        balance = 1000,
        vip     = false,
        items   = {},
    },
})

-- Shared per-player (everyone sees everyone's data)
imLib.StateSync:Define("scoreboard", {
    scope  = "shared",
    schema = {
        kills  = "int",
        deaths = "int",
    },
    defaults = { kills = 0, deaths = 0 },
})
Scopes at a glance: "global" — one copy, everyone gets it. "player" — per-player, private to that player. "shared" — per-player, but broadcast to all (scoreboards, player stats).

Setting & Reading Values

SERVER
-- Single field
imLib.StateSync:Set("economy", nil, "tax_rate", 0.15)

-- Multiple fields at once (one compressed update)
imLib.StateSync:Set("wallet", ply, {
    balance = 5000,
    vip     = true,
    items   = { "sword", "shield" },
})

-- Reset to defaults
imLib.StateSync:Reset("wallet", ply, "balance")
SHARED
-- Reading values (both realms)
local tax = imLib.StateSync:Get("economy", nil, "tax_rate")      -- 0.15
local bal = imLib.StateSync:Get("wallet", nil, "balance")        -- client reads own
local all = imLib.StateSync:GetAll("wallet", ply)              -- full snapshot

Client Subscriptions

CLIENT
-- Subscribe to all changes on a store
local subID = imLib.StateSync:Subscribe("wallet",
    function(storeName, owner, key, newVal, oldVal)
        print(key, "changed:", oldVal, "→", newVal)
    end
)

-- Subscribe to a specific key only
imLib.StateSync:Subscribe("economy", function(_, _, _, newMotd)
    ShowMOTD(newMotd)
end, "motd")

-- Unsubscribe
imLib.StateSync:Unsubscribe(subID)

🌐 HTTP Client

Promise-style HTTP client with retry, exponential backoff, response caching, base URLs, and concurrent request patterns. Wraps GMod's HTTP() with a builder API.

SHARED
-- Simple GET
imLib.HTTP:Get("https://api.example.com/users")
    :Then(function(res)
        print(res.code)           -- 200
        PrintTable(res.json)      -- auto-parsed JSON
        print(res.elapsed)        -- 0.152 (seconds)
    end)
    :Catch(function(err)
        print("Failed:", err)
    end)

-- Global Defaults & Base URL
imLib.HTTP:SetBaseURL("https://api.example.com")
imLib.HTTP:SetDefaultHeader("X-Addon", "MyAddon")
imLib.HTTP:SetDefaultTimeout(15)
imLib.HTTP:SetDefaultRetry(2, 0.5)

-- Now use relative paths
imLib.HTTP:Get("/users/123"):Then(fn)
SHARED
imLib.HTTP:Post("https://api.example.com/items")
    :JSON({ name = "Sword", damage = 50 })
    :Header("Authorization", "Bearer " .. token)
    :Timeout(15)
    :Then(function(res)
        print("Created:", res.json.id)
    end)
    :Catch(function(err)
        print("Error:", err)
    end)
    :Finally(function(res, err)
        print("Request complete")
    end)
SHARED
-- Retry 3 times on failure, 1s base delay (doubles each retry)
imLib.HTTP:Get("https://api.example.com/config")
    :Retry(3, 1)
    :Cache(120)                 -- Cache for 2 minutes
    :Tag("config")              -- Tag for bulk invalidation
    :Then(onSuccess)

-- Invalidate cache by tag or URL
imLib.HTTP:InvalidateTag("config")
imLib.HTTP:InvalidateURL("https://api.example.com/config")
imLib.HTTP:ClearCache()
SHARED
-- All: wait for all to complete
imLib.HTTP:All({
    imLib.HTTP:Get("/users"),
    imLib.HTTP:Get("/items"),
    imLib.HTTP:Get("/config"),
}, function(results)
    local users = results[1].json
    local items = results[2].json
end, function(err)
    print("One failed:", err)
end)

-- Race: first to succeed wins
imLib.HTTP:Race({
    imLib.HTTP:Get("https://cdn1.example.com/data"),
    imLib.HTTP:Get("https://cdn2.example.com/data"),
}, function(res, index)
    print("Winner: CDN" .. index)
end)

Response Object

FieldTypeDescription
res.codenumberHTTP status code
res.bodystringRaw response body
res.headerstableResponse headers
res.jsontable|nilAuto-parsed JSON (nil if not JSON)
res.cachedbooltrue if served from cache
res.urlstringThe requested URL
res.methodstringHTTP method used
res.elapsednumberRequest duration in seconds

🖼️ UI Elements

All elements are VGUI panels registered under the imLib. prefix. They automatically use the active theme (including addon overrides). All elements support smooth hover/press animations out of the box.

imLib.Frame

Draggable frame with themed header, close button, and drop shadow.

CLIENT
local frame = vgui.Create("imLib.Frame")
frame:SetSize(600, 400)
frame:Center()
frame:SetTitle("My Panel")
frame:SetDraggable(true)
frame:SetRemoveOnClose(true)
frame:MakePopup()

-- Optional branding icon
frame:SetBrandingMaterial(Material("icon16/star.png"))

imLib.Button

Themed button with hover/press animation, optional icon, color override, and disabled state.

CLIENT
local btn = vgui.Create("imLib.Button", parent)
btn:Dock(TOP)
btn:DockMargin(0, 0, 0, 8)
btn:SetText("Save Changes")
btn:SetColor(Color(46, 204, 113))       -- green override (nil = accent)
btn:SetIcon("https://example.com/save.png")  -- URL or Material
btn:SetDisabled(false)

btn:SetCallback(function()
    print("Saved!")
end)

imLib.Checkbox

Animated toggle checkbox with label.

CLIENT
local cb = vgui.Create("imLib.Checkbox", parent)
cb:Dock(TOP)
cb:SetLabel("Enable notifications")
cb:SetChecked(true)

cb:SetCallback(function(checked)
    print("Notifications:", checked)
end)

-- Toggle programmatically
cb:Toggle()

imLib.Dropdown

Animated dropdown selector with simple or key-value options, popup overlay, and scrollbar.

CLIENT
local dd = vgui.Create("imLib.Dropdown", parent)
dd:Dock(TOP)
dd:SetTall(imLib.Scale(36))

-- Simple string options
dd:SetOptions({ "Option A", "Option B", "Option C" })
dd:SetSelected("Option A")

-- Or key-value options
dd:SetOptionsKV({
    { label = "English",  value = "en" },
    { label = "Français", value = "fr" },
    { label = "Polski",   value = "pl" },
})
dd:SetSelectedValue("en")

dd:SetCallback(function(value, index)
    print("Selected:", value)   -- "en"
end)

imLib.Sidebar

Vertical navigation sidebar with icons, smooth selection indicator, and language-aware labels.

CLIENT
local sb = vgui.Create("imLib.Sidebar", frame)
sb:Dock(LEFT)
sb:SetWide(imLib.Scale(220))
sb:SetHeaderText("Navigation")

sb:AddItem("home",     "Home",     "https://example.com/home.png")
sb:AddItem("settings", "Settings", "https://example.com/cog.png")
sb:AddItem("players",  "Players",  "https://example.com/users.png")

sb:SetSelected("home")

sb:SetItemCallback(function(id, data)
    print("Navigated to:", id)
end)

imLib.Toast

Slide-in notification toasts anchored to a parent frame. Auto-stack, auto-dismiss, progress bar countdown.

CLIENT
-- Simple toast (5 second default)
imLib.AddToast(frame, "Settings saved!")

-- Custom duration + icon
imLib.AddToast(frame, "Language changed to English.", 5,
    "https://example.com/globe.png")

-- Short error toast
imLib.AddToast(frame, "Error: invalid value.", 3)

Images

imLib.Images:Get(url) fetches and caches remote images as Materials for use in VGUI panels.

CLIENT
local mat = imLib.Images:Get("https://example.com/icon.png")
surface.SetMaterial(mat)
surface.SetDrawColor(color_white)
surface.DrawTexturedRect(0, 0, 32, 32)

Scale

Resolution-independent scaling based on a reference resolution.

CLIENT
local h = imLib.Scale(40)  -- Scales 40px relative to reference res

Drawing Utilities

Convenience wrappers for rounded boxes, text sizing, and font generation.

CLIENT
-- Rounded box (uses theme roundness)
imLib:RoundedBox(x, y, w, h, color)
imLib:RoundedBox(x, y, w, h, color, true, true, false, false) -- per-corner

-- Text size
local tw, th = imLib:GetTextSize("Hello", "imLib.Frame.Title")

-- Font generation
imLib:GenerateFont("MyFont", nil, 18)

💡 Full Addon Example

A complete example showing how all systems work together in a real addon.

AUTORUN
-- addons/myshop/lua/autorun/myshop.lua

local addon = imLib.Addon("myshop", "MYSHOP")

addon:SetName("My Shop")
addon:SetAuthor("Developer")
addon:SetVersion("1.0.0")

addon:UseLogger()
addon:UseDatabase()
addon:UseConfig()
addon:UseLanguages()
addon:UseThemes()
addon:UseStateSync()
addon:UseNetwork()
addon:UseHTTP({ base_url = "https://api.myshop.com", timeout = 10 })

addon:AddFolder("modules",   imLib.FileLoader.REALM_SHARED, true)
addon:AddFolder("themes",    imLib.FileLoader.REALM_SHARED)
addon:AddFolder("languages", imLib.FileLoader.REALM_SHARED)
addon:AddFile("sh_config",   imLib.FileLoader.REALM_SHARED)
addon:AddFile("cl_ui",       imLib.FileLoader.REALM_CLIENT)

addon:Load()
SERVER
-- addons/myshop/lua/myshop/modules/sv_economy.lua

-- Define player wallet state
MYSHOP.StateSync:Define("wallet", {
    scope   = "player",
    schema  = { balance = "int", items = "table" },
    defaults = { balance = 1000, items = {} },
})

-- Register purchase message
imLib.Network:Register("MyShop:Buy")
    :Field("item_id", "uint", { bits = 16 })
    :SetCooldown(0.5)
    :SetRateLimit(20, 60)
    :OnReceive(function(data, ply)
        local bal = MYSHOP.StateSync:Get("wallet", ply, "balance")
        local price = GetItemPrice(data.item_id)

        if bal < price then
            MYSHOP.Logger:warning(ply:Nick() .. " can't afford item")
            return
        end

        -- Deduct and give item
        local items = MYSHOP.StateSync:Get("wallet", ply, "items")
        table.insert(items, data.item_id)

        MYSHOP.StateSync:Set("wallet", ply, {
            balance = bal - price,
            items   = items,
        })

        MYSHOP.Logger:success(ply:Nick() .. " bought item " .. data.item_id)
    end)
CLIENT
-- addons/myshop/lua/myshop/cl_ui.lua

-- Subscribe to wallet changes for live HUD
MYSHOP.StateSync:Subscribe("wallet", function(_, _, key, val)
    if key == "balance" then
        UpdateBalanceHUD(val)
    end
end)

-- Build the shop UI
local function OpenShop()
    local frame = vgui.Create("imLib.Frame")
    frame:SetSize(700, 500)
    frame:Center()
    frame:SetTitle(MYSHOP.L("shop_title"))
    frame:MakePopup()

    local sidebar = vgui.Create("imLib.Sidebar", frame)
    sidebar:Dock(LEFT)
    sidebar:SetWide(imLib.Scale(200))
    sidebar:AddItem("weapons", "Weapons", "https://cdn.ex.com/sword.png")
    sidebar:AddItem("armor",   "Armor",   "https://cdn.ex.com/shield.png")

    local buyBtn = vgui.Create("imLib.Button", frame)
    buyBtn:Dock(BOTTOM)
    buyBtn:DockMargin(210, 8, 8, 8)
    buyBtn:SetText("Buy Selected")
    buyBtn:SetCallback(function()
        imLib.Network:Send("MyShop:Buy", { item_id = selectedItem })
        imLib.AddToast(frame, "Purchasing...", 3)
    end)
end