Skip to content

🎭 Spooni Character Suite

A complete character creation and selection system for RedM. The Creator replaces your framework's native editor with live camera views, item-gated sections, NPC hairstyles & beards, body size tuning, and vanilla store takeover. The Selector adds an atmospheric in-world campfire scene where characters stand in their real look with ambient music and a spawn location picker.

1. Installation

1.1 Requirements

ResourceVersionSource
jo_libsv2.12+GitHub
spooni_libslatestdiscord.gg/spooni

No SQL needed. Database tables are created automatically on first start.

1.2 server.cfg

⚠️ Order is critical. Wrong order = character creation silently breaks.

cfg
ensure jo_libs
ensure spooni_libs

ensure spooni_charcreator
ensure spooni_charselector      # optional — remove if you don't want the selection scene

Correct load order:

1. Framework Core          (vorp_core / rsg-core / redem_roleplay / rpx-core)
2. Framework Character     (vorp_character / rsg-multicharacter / ...)
3. Framework Inventory     (vorp_inventory / rsg-inventory / ...)
4. jo_libs
5. spooni_libs
6. spooni_charcreator
7. spooni_charselector     (optional)

Why does order matter? jo_libs must start after Core + Inventory for the framework bridge. spooni_libs builds on jo_libs. Creator needs all three. Selector needs Creator for look previews.

1.3 Framework-Specific Guards

After copying the folders, add guards to prevent your native creator/selector from running alongside Spooni. Then run /spooni_check in the server console — every line must show PASS.


VORP

Creator — copy these 4 patch files from spooni_charcreator/docs/patches/vorp_character/:

Copy this fileOver this file
client/createCharacter.luavorp_character/client/createCharacter.lua
client/commands.luavorp_character/client/commands.lua
client/client.luavorp_character/client/client.lua
server/server.luavorp_character/server/server.lua

Modified vorp_character yourself? Follow the manual merge in docs/INSTALL-VORP.md Appendix B.

Selector — add ONE line in vorp_character/client/client.lua, handler vorpcharacter:selectCharacter (~line 90):

lua
RegisterNetEvent("vorpcharacter:selectCharacter", function(myCharacters, mc, rand)
    if GetResourceState('spooni_charselector') == 'started' then return end -- NEW
    -- ... rest of the handler ...
end)

RSG

Creator — copy these 2 patch files from spooni_charcreator/docs/patches/:

Copy this fileOver this file
rsg-multicharacter/client/main.luarsg-multicharacter/client/main.lua
rsg-appearance/client/creator.luarsg-appearance/client/creator.lua

Files in resource root instead of client/? Copy there instead.

Selector — add ONE line in rsg-multicharacter/client/main.lua, handler rsg-multicharacter:client:chooseChar (~line 196):

lua
RegisterNetEvent('rsg-multicharacter:client:chooseChar', function()
    if GetResourceState('spooni_charselector') == 'started' then return end -- NEW
    -- ... rest of the handler ...
end)

Do not swap the guard resource names. Selector guard checks spooni_charselector, creator guard checks spooni_charcreator. Swapped = naked characters.


RedEM:RP

Single guard in redemrp_identity/client/cl_main.lua, handler redemrp_identity:openSelectionMenu (~line 72):

lua
if GetResourceState('spooni_charselector') == 'started' then return end

Always run Creator + Selector together on RedEM:RP.


RPX

Single guard in rpx-multicharacter/client.lua, handler CLIENT:MultiCharacter:LoadCharacters (~line 177):

lua
if GetResourceState('spooni_charselector') == 'started' then return end

Always run Creator + Selector together on RPX. RPX has no SecondChance item support — use the /secondchance command or exports.spooni_charcreator:OpenSecondChance().


Custom Framework

If your own code controls the character flow, trigger directly instead of patching:

lua
-- Creator
TriggerClientEvent('spooni_charcreator:start', src)

-- Selector
TriggerClientEvent('spooni_charselector:open', src, chars, maxSlots, rand)

Wire your core in framework.lua — see Section 6.


2. Creator Configuration (spooni_charcreator/config.lua)

2.1 Framework & Language

lua
Config.Framework = 'auto'   -- 'auto', 'vorp', 'rsg', 'rpx', 'redemrp'
Config.Locale    = 'auto'   -- 'auto' (follows spooni_libs) or 'de', 'en', 'fr', ...
Config.Debug     = false    -- true for testing only

2.2 Editor Sections (Config.Categories)

Each section toggles individually. true = visible, false = hidden.

lua
Config.Categories = {
    -- Top-level
    skintone    = true,    -- Skin tone
    body        = true,    -- Body build + size
    face        = true,    -- Face morphs (8 groups)
    eyesteeth   = true,    -- Eyes & teeth
    hairbeard   = true,    -- Hair & beard (master switch)
    skindetails = true,    -- Skin details (freckles, pores)
    scars       = true,    -- Scars
    makeup      = true,    -- Makeup
    clothing    = true,    -- Clothing
    identity    = true,    -- Name/age (creation only)

    -- Hair & Beard sub-sections
    hair        = true,    -- Hair styles
    beard       = true,    -- Beard styles
    hairColor   = true,    -- Hair color swatches
    npcHair     = true,    -- 🏆 NPC hairstyles (drawable-based)
    bonnet      = true,    -- Hat/bonnet hair (female)
    npcBeard    = true,    -- 🏆 NPC full beards (drawable-based)
    stubble     = true,    -- Beard stubble
    eyebrows    = true,    -- Eyebrows

    -- Body sub-sections
    bodyBuild   = true,    -- Body build presets
    bodyMorphs  = true,    -- Body shape sliders
    bodySize    = true,    -- Character height

    -- Eyes & Teeth sub-sections
    eyeRedness  = true,    -- Bloodshot eyes
    eyeColor    = true,    -- Eye color
    teeth       = true,    -- Teeth model

    -- Free color sliders
    dye         = true,    -- Free color (beyond preset swatches)
}

Inheritance: hairColor, npcHair, bonnet inherit from hairhairbeard. npcBeard, stubble inherit from beardhairbeard. eyebrows inherits from both hair and makeup (OR — visible if either parent is on).

2.3 NPC Hair & Beards

The editor includes NPC hairstyles (npcHair) and NPC full beards (npcBeard) - caution it can clip with certain hats/hair!

lua
-- Enable NPC hair/beards (both on by default)
Config.Categories = {
    npcHair  = true,    -- NPC hairstyles visible in Hair & Beard menu
    npcBeard = true,    -- NPC full beards visible in Hair & Beard menu
}

NPC hair lives under Hair & Beard → inherits from hair. NPC beards inherit from beard. Turn either parent off and its NPC variant hides too.

2.4 Item-Gated Sections (Config.UseItems)

Items that open the editor when used. Perfect for barber kits, makeup kits, or paid re-customization.

lua
Config.UseItems = {
    -- Full editor, one-time use
    { item = 'secondchance', consumable = true,  sections = nil },

    -- Barber kit: hair & beard only, reusable
    { item = 'shaving_kit', consumable = false,
      sections = { 'hair', 'hairColor', 'npcHair', 'bonnet',
                   'beard', 'stubble', 'npcBeard', 'dye' } },

    -- Makeup kit: makeup only, reusable
    { item = 'makeup_kit',  consumable = false,
      sections = { 'makeup', 'dye' } },
}
FieldDescription
itemItem name — must exist in your database
consumabletrue = used up on use, false = stays in inventory
sectionsnil = full editor, list = only those sections

After adding a new item: Full server restart (inventories cache items at boot).

2.5 SecondChance Command

lua
Config.SecondChanceCommand = 'secondchance'   -- players type /secondchance turn this false because this is not gated! Use item for players!

2.6 Name Rules

lua
Config.RequireName = true   -- first + last name required
Config.NameBlacklist = {
    'John Marston', 'Arthur Morgan', 'Dutch van der Linde',
    'Micah Bell', 'John Doe', 'Max Mustermann',
}

2.7 Editor Scene

lua
Config.Scene = {
    mode    = 'simple',  -- 'simple' | 'room' | 'cinematic'
    coords  = vector3(2546.3027, -1307.7715, 48.5166),
    heading = 357.8737,
    hour    = 20,
}
  • 'simple' — direct entry with camera movement
  • 'room' — Sheriff office interior
  • 'cinematic' — Full cinematic intro with gender selection

2.8 Body Sizes

lua
Config.Sizes = {
    presets = {
        { labelKey = 'size_small',  value = 0.95 },
        { labelKey = 'size_normal', value = 1.00 },
        { labelKey = 'size_large',  value = 1.05 },
    },
    allowCustom    = false,
    recommendedMin = 0.92,
    recommendedMax = 1.08,
    hardMin = 0.70,  hardMax = 1.40,
}

2.9 Camera Views

Fine-tune camera per editor section:

lua
Config.CameraViews = {
    head = { offset = vector3(0.0957, 1.6212, 0.5850), fov = 20.01, focus = 1.4 },
    body = { offset = vector3(0.1057, 2.9811, 0.2037), fov = 35.0,  focus = 1.0 },
    hair = { offset = vector3(0.0957, 1.6311, 0.5550), fov = 25.0,  focus = 1.6 },
    legs = { offset = vector3(0.1000, 2.2000, -0.6200), fov = 30.0, focus = 1.2 },
}
Config.DefaultCameraView = 'body'

2.10 Starter Outfit

lua
Config.StarterOutfit = {
    enabled  = true,
    item     = 'starter_outfit',
    quantity = 1,
}

New characters get a re-equip item for their created outfit - good so they don't lose their first outfit. You can turn this off if you don't want to use this.

2.11 Intro Animation

lua
Config.Intro = {
    enabled  = true,
    title    = 'Start your journey', -- name it whatever you want 
    duration = 5500,
    distMult = 4.0,
    font     = 22,
}

2.12 Murphy Compatibility

Only if you also run murphy_clothing / murphy_barber:

lua
Config.Murphy = {
    clothing = false,   -- true if murphy_clothing runs
    barber   = false,   -- true if murphy_barber runs
    clothingResource = 'murphy_clothing',
    barberResource   = 'murphy_barber',
}

Requires murphy_clothing 3.44.0+ / murphy_barber 1.21.0+

2.12 Jo_clothingstore & hairdresser

Compatible out of the box just turn off the VanillaTakeover for shops.


3. Selector Configuration (spooni_charselector/config.lua)

3.1 Framework & Slots

lua
Config.Framework  = 'auto'
Config.Locale     = 'auto'
Config.MaxSlots    = 6         -- max slots shown (must match scene positions) !!!!!!!!!!
Config.AllowDelete = true      -- allow character deletion
Config.DefaultScene = 1        -- starting scene (index in Config.Scenes)
Config.RandomScene = false     -- random scene each time
Config.SkipSelectorUnderSlots = 2 -- If someone has 1 character he skips the scene to faster load in. 
Config.FallbackSpawn = vector4(-275.5, 803.2, 118.4, 0.0)

3.2 Branding

lua
Config.Brand = {
    name     = 'Your Server', -- just leave like '' this for no Branding
    subtitle = 'Year 1899',
    font     = "'RDRLino', serif", -- put any font into html folder and use it 
}

3.3 Ghost Slots (empty places)

lua
Config.Ghost = {
    model    = 'mp_male',
    alpha    = 100,
    scenario = 'MP_LOBBY_SCENARIO_08',
    outfit   = {},
}

3.4 Lighting

lua
Config.FillLight = {
    enabled = true, range = 4.0, intensity = 5.0,
    color = { r = 255, g = 226, b = 190 },
}

Config.Spotlight = {
    enabled = true, range = 6.5, intensity = 22.0,
    color = { r = 255, g = 220, b = 170 },
    dimOthers = true,
}

3.5 Music

lua
Config.DefaultVolume     = 0.10
Config.MaxVolume         = 0.45
Config.AutoPlayFirstSong = true

Config.Songs = {
    { label = 'Blacktop Ember',     file = 'sounds/blacktop_ember.mp3' },
    { label = 'Dust On The Fence',  file = 'sounds/dust_on_the_fence.mp3' },
    { label = 'Welcome Theme',      file = 'sounds/welcome_theme.mp3' },
}

Adding your own music: Drop .mp3/.ogg into html/sounds/, add filenames to html/sounds/sounds.json, then restart spooni_charselector.

3.6 Idle Animations (Gear Menu)

lua
Config.SelectableScenarios = {
    { key = 'scenario_stand_calm',      name = 'MP_LOBBY_STANDING_B' },
    { key = 'scenario_fire_sit',        name = 'world_human_fire_sit' },
    { key = 'scenario_drink',           name = 'WORLD_HUMAN_DRINK' },
    { key = 'scenario_smoke_wall',      name = 'WORLD_HUMAN_LEAN_BACK_WALL_SMOKING' },
    { key = 'scenario_lean_railing',    name = 'WORLD_HUMAN_LEAN_BACK_RAILING' },
    { key = 'scenario_write_notebook',  name = 'WORLD_HUMAN_WRITE_NOTEBOOK' },
    { key = 'scenario_clipboard',       name = 'WORLD_HUMAN_CLIPBOARD' },
    { key = 'scenario_coffee_drink',    name = 'WORLD_HUMAN_COFFEE_DRINK' },
    { key = 'scenario_lean_wall_drink', name = 'WORLD_HUMAN_LEAN_WALL_DRINKING' },
}

3.7 Scenes

Two scenes ship pre-installed — Theater (Saint Denis) and Riverside. Add your own in Config.Scenes:

lua
{
    name = 'My Scene',
    env = {
        weather     = { type = 'sunny', transition = 0.0, snow = false },
        time        = { hour = 12, minute = 0 },
        playerSpawn = vector3(0.0, 0.0, 100.0),   -- must be SOLID GROUND
        campfire    = { enabled = true, model = 'p_campfirecombined01x',
                        coords = vector3(0.0, 0.0, 100.0), heading = 0.0 },
    },
    masterCam = { x = 0.0, y = 10.0, z = 102.0, fov = 53.0 },
    positions = {
        { spawn = vector4(2.0, 1.0, 100.0, 180.0), scenario = {
            mp_male = { 'MP_LOBBY_STANDING_B' },
            mp_female = { 'MP_LOBBY_STANDING_B' } } },
        -- ... one position per slot (matches Config.MaxSlots) ...
    },
}

3.8 Spawn Module (Config.Spawn)

Live-camera spawn location picker for new characters:

lua
Config.Spawn = {
    enabled = true,
    locations = {
        { title = "Saint Denis", subtitle = "City of lights and lies",
          coords = vector3(2731.63, -1442.33, 46.26), heading = 224.39,
          cam = vector4(3073.86, -1326.31, 177.92, 85.33),
          job = false },   -- false = everyone

        { title = "Valentine", subtitle = "A rowdy cattle town",
          coords = vector3(-166.19, 633.43, 114.08), heading = 307.67,
          cam = vector4(-420.58, 828.37, 156.65, 243.09),
          job = false },

        -- Job-gated example:
        { title = "Sheriff's Office", coords = vector3(...), heading = 0.0,
          job = 'lawman' },
    },
}

Job-gated locations: job = false for everyone, job = 'lawman' or job = { 'bartender', 'dancer' } for specific jobs. jobGrade = 2 for minimum grade. Always keep at least one location with job = false.

Custom step before spawn menu:

lua
Config.Spawn.beforeMenu = {
    event     = 'myjobs:openSelector',
    doneEvent = 'myjobs:selectorDone',
    timeoutMs = 120000,
}

Can be used for own UIs to select Jobs and handover to spawnselect that can be jobgated.

RSG conflict: disable rsg-spawn (Config.SelectLocations = false) or set Config.Spawn.enabled = false.

3.9 Admin Command

lua
Config.AdminCommand = 'charselect'   -- '' = disabled
Config.AdminGroups  = { 'admin', 'superadmin' }

Usage: /charselect <serverId> in server console.


4. Vanilla Store Takeover

Replaces the built-in clothing/barber/makeup stores of VORP/RSG with the Spooni editor — scoped to just that section, with server-side price calculation. TURN THIS OFF IF YOU HAVE ANOTHER BARBER/HAIRDRESSER/CLOTHINGSTORE

4.1 Configuration

lua
Config.VanillaStoreTakeover = {
    enabled  = true,
    currency = 0,      -- 0 = dollars, 1 = gold, 2 = rol

    prices = {
        clothing = {
            hats = 3.0, masks = 3.0, eyewear = 3.0, neckwear = 3.0, neckties = 3.0,
            shirts_full = 4.0, vests = 4.0, coats = 5.0, coats_closed = 5.0,
            ponchos = 5.0, suspenders = 3.0, armor = 6.0, dresses = 5.0,
            gloves = 3.0, gauntlets = 3.0,
            pants = 4.0, chaps = 4.0, spats = 3.0, boots = 4.0, boot_accessories = 2.0,
            belts = 3.0, belt_buckles = 2.0, satchels = 4.0, accessories = 3.0,
            jewelry_bracelets = 3.0, jewelry_rings_left = 3.0, jewelry_rings_right = 3.0,
            gunbelts = 4.0, gunbelt_accs = 3.0, holsters_left = 3.0, loadouts = 4.0,
        },
        barber = 5.0,   -- flat fee, once per visit if anything changed
        makeup = 3.0,   -- flat fee, same rule
    },
}

4.2 How It Works

  • Clothing: Price per changed category — swap only your hat, only hat price charged
  • Barber/Makeup: Flat fee per visit, only if anything changed. Close without changes = free
  • Server-side: Prices computed from the actually-persisted look — clients can't fake the price

4.3 Required Guards

StoreVORPRSG
ClothingCopy docs/patches/vorp_character/client/clothingstore.luaCopy docs/patches/rsg-appearance/client/clothes.lua
Barber/HairSame file (vorp handles all shops in clothingstore.lua)Already in creator.lua patch (Step 1.3)
rsg-barbers (standalone)Copy docs/patches/rsg-barbers/client/barber-guard.lua

Without these guards, VanillaStoreTakeover does nothing.


5. Developer API

Everything works through exports & hooks — no source access needed. Version 1.5.16.

5.1 Full Export Reference

spooni_charcreator — Client

ExportSignatureDescription
OpenAppearanceEditor(opts) -> boolStart a scoped editor session
CommitLook(snapshot) -> boolPersist + apply + close session
AbortEditor()Close session, revert look
GetCurrentLook() -> table|nilDeep copy {skin, clothes}
RandomizeAppearance(scope)Randomize during active session
SetEditorMenuVisible(bool)Show/hide menu
IsEditorBusy() -> boolCheck if editor is free
ReapplyLook()Re-apply saved look
OpenSecondChance()Open SecondChance editor
OpenVanillaStore(shopType, storeData?) -> boolStore takeover: 'clothing'/'hair'/'makeup'/'secondchance'
ApplyLookToPed(ped, charId)Apply saved look to any ped
ApplyDefaultLookToPed(ped, gender)Apply default look — for ghost slots
ApplyMurphyToPed(ped, charId)Apply Murphy overlays to ped
IsMurphyEnabled() -> boolMurphy switch state
GetMurphyConfig() -> table|nilLive Config.Murphy
CleanupMurphyPed(ped)Required after deleting a Murphy ped
CleanupMurphyAll()Free all Murphy handles
registerAction(name, fn, priority?)Register hook action
registerFilter(name, fn, priority?)Register hook filter

spooni_charcreator — Server

ExportSignatureDescription
GetMaxSlots(src) -> number|nilResolved max slots for a player
registerAction(name, fn, priority?)Register hook action
registerFilter(name, fn, priority?)Register hook filter

spooni_charselector — Client

ExportSignatureDescription
RegisterPreSpawnStep(fn) -> boolStep before spawn menu, call done() when finished
HandlesNewCharSpawn() -> boolInternal (RSG): selector owns post-create spawn

5.2 Hooks

lua
-- Action
exports.spooni_charcreator:registerAction('spooni:afterSave', function(src, charId)
    print(('Char %s saved by player %s'):format(charId, src))
end, 10)

-- Filter
exports.spooni_charcreator:registerFilter('spooni:sectionAllowed', function(value, token, sessionId)
    if token == 'npcHair' and GetPlayerJob() ~= 'barber' then
        return false   -- NPC hair only for barbers
    end
    return value
end, 10)
HookTypeSideWhen
spooni:beforeEditorOpenActionClientBefore menu build
spooni:afterEditorOpenActionClientAfter successful menu build
spooni:beforeSaveActionClientBefore save (snapshot is read-only)
spooni:editorClosedActionClientOn every editor close
spooni:afterSaveActionServerAfter successful save
spooni:afterCreateActionServerFirst save of a character
spooni:sectionAllowedFilterClientEvery section visibility check
spooni:editorCategoriesFilterClientBefore applying session categories
spooni:maxSlotsFilterServerFinal step of slot resolution

5.3 Practical Examples

Barber NPC:

lua
exports.spooni_charcreator:OpenAppearanceEditor({
    id = 'barber', categories = { hairbeard = true },
    title = 'Barber', subtitle = 'Hair & Beard',
})

Tailor NPC (clothing only):

lua
exports.spooni_charcreator:OpenAppearanceEditor({
    id = 'tailor', categories = { clothing = true, dye = true },
    title = 'Tailor', subtitle = 'Pick your outfit',
})

Eyebrow pencil item:

lua
Config.UseItems = {
    { item = 'eyebrow_pencil', consumable = true,
      sections = { 'eyebrows', 'dye' } },
}

Dynamic slot bonus for supporters:

lua
-- server-side
exports.spooni_charcreator:registerFilter('spooni:maxSlots', function(value, src, identifier)
    if IsSupporter(identifier) then value = (value or 1) + 2 end
    return value
end, 10)

6. Custom Framework

Wire a custom core in framework.lua. Use the existing VORP/RSG/RedEM:RP/RPX branches as a reference.

6.1 Creator (framework.lua in spooni_charcreator)

Client-side:

lua
function Framework.CreateCharacter(data, onSelected)
    -- data = { firstname, lastname, age, gender, model }
    -- Create char, call onSelected(charid)
end

function Framework.OnCharacterSelected(cb)  -- cb(isNew)
function Framework.OnRespawn(cb)            -- cb(reason)
function Framework.IsSessionStarted()       -- bool

Server-side:

lua
function Framework.GetLookForChar(charId, cb)    -- cb(skin, clothes)
function Framework.CheckOwnership(src, id, cb)   -- cb(owned: bool)

6.2 Selector (framework.lua in spooni_charselector)

Client-side:

lua
function Framework.OnCharactersReady(cb)    -- cb(chars, maxSlots)
function Framework.GetMaxSlots()            -- number
function Framework.PlayCharacter(char)      -- select + spawn
function Framework.RequestDelete(char)      -- server-authoritative delete
function Framework.StartCreator(opts)       -- open creator for empty slot
function Framework.ApplyLookToPed(ped, char) -- preview look on slot ped

Character object shape:

lua
{
    charIdentifier = id,
    firstname = "John", lastname = "Doe",
    skin   = { sex = 'mp_male' },
    coords = { x, y, z, heading },
    isDead = false,
    _raw   = <core-native row>,
    _look  = { kind = 'yourcore', ... }
}

7. Troubleshooting

General

SymptomCauseFix
Two creators open / duplicate pedsGuard missing or not first lineRe-check guard, restart
Look reverts after relogWrong core detectedSet Config.Framework to your core
"No supported core"Core name/order wrongCore must start BEFORE spooni, named vorp_core/rsg-core/etc.
Item not usableInventory caches at bootFull server restart
/spooni_check shows FAILGuard missingRe-apply the named guard

VORP

SymptomFix
Naked after /rcRe-copy patch from Step 1.3
attempt to call nil (updateCompTints)Outdated vorp_core — spooni auto-falls-back to DB direct write

RSG

SymptomFix
Naked after selectionGuards swapped. Selector guard = spooni_charselector, Creator guard = spooni_charcreator
Clothing store opens vanilla RSGclothes.lua guard missing or VanillaStoreTakeover.enabled = false

Selector

SymptomFix
Slot peds naked/blankspooni_charcreator not running
Native selection still showsGuard missing or not first line
Two spawn menusRSG: Config.SelectLocations = false in rsg-spawn. RPX: don't start rpx-spawn
No musicFiles must be in html/sounds/ AND listed in sounds.json