Docs

X3D Character Skin

A 77-slot character editor with an orbiting camera, poses and lighting. No skinchanger, no database, any framework.

Buy / Get freeConfig builder

Installation

  1. Unzip and rename the folder to x3d_skin (other scripts call the exports by this name)
  2. Drop it in resources/
  3. Add ensure x3d_skin to server.cfg

That is the whole setup: no database, no extra resource, and nothing on the server has to change. Works on ESX, QBCore and standalone.

Players open it with /skin; an admin opens it for someone else with /skin <id>.

Grant the admin permission:

add_ace group.admin x3d_skin.admin allow

⚠ On a server that already runs esx_skin, it registers /skin too — whichever resource is ensured last answers it. Put ensure x3d_skin after ensure esx_skin, or change Config.Command.

What can be edited

77 slots in the default mode, grouped into the six tabs on the bottom bar:

Group Slots
Basics Gender · face · skin tone
Hair & eyes Hair style, colour, highlight · eye colour · eyebrow style, colour, opacity
Clothing Undershirt, torso, decals, arms, body armour, pants, shoes (each with its colour slot)
Accessories Mask, chain, hat, glasses, watch, bracelet, earrings
Makeup Makeup, lipstick, blush, beard
Details Age, blemishes, complexion, sun damage, moles, chest hair, body blemishes

Every ceiling is asked from the game at runtime (GetNumberOfPedDrawableVariations and friends) rather than hardcoded — install an add-on clothing pack and players can reach all of it without a config change.

Set Config.AdvancedFace = true to swap the 46 preset faces for the GTA Online heritage system: mother, father, blend weights and per-feature nose/cheek/chin sliders.

⚠ Flipping that after the server has been live breaks the faces players already saved — the two modes store different value sets.

Panel controls

Input What it does
LMB + drag Rotate the character
RMB + drag Pan the camera (bounded — it cannot be used to look around the map)
Scroll Zoom in / out
Q / E Rotate a degree at a time
Click a slot + Scroll Change that slot's value
Shift + Scroll Step by ±10
Arrow keys Fine adjustment

The bottom-left toolbar: focus head · focus body · reset camera · character lighting · cycle pose (4) · hide every player within 50 m.

The camera moves to whatever is being edited, but the moment the player rotates or zooms by hand it stops moving on its own within that group, until a slot from another group is selected.

Opening it from a shop (barber, clothing store)

exports['x3d_skin']:OpenEditor({
    title    = 'Barber',
    price    = 500,
    restrict = { 'hair_1', 'hair_2', 'hair_color_1', 'hair_color_2',
                 'beard_1', 'beard_2', 'beard_3', 'beard_4' },
    onSubmit = function() TriggerServerEvent('myshop:charge', 500) end,
    onCancel = function() print('cancelled') end,
})

restrict limits which slots can be edited; price turns the confirm button into "Purchase" with the amount on it.

⚠ Calling OpenEditor yourself never charges anyone — take the money in your own onSubmit. The shops in Config.Shops are the other path: there the server charges and saves in one step.

Working with an existing skinchanger / esx_skin

Nothing to configure — the script checks at startup:

  • skinchanger is installed → it dresses the ped and esx_skin keeps storing the skin its own way. x3d_skin is just the UI, the shops and the wardrobe.
  • It is not → x3d_skin dresses the ped itself and stores skins in a JSON file.

Either way nothing has to be stopped and nothing has to be migrated; existing characters are untouched.

Force one side with Config.Backend ('skinchanger' / 'self') if you ever need to.

Bundled in the zip: optional/skinchanger + optional/esx_skin

Most ESX servers already run both — if yours does, skip the optional/ folder entirely; x3d_skin already works with what you have.

If it does not, move them into resources/ and ensure them before x3d_skin:

ensure skinchanger
ensure esx_skin
ensure x3d_skin
  • skinchanger is ESX Legacy's own, with the @include of the es_extended locale file removed so it installs without erroring
  • esx_skin is trimmed to the API only (the skin column plus the old event names) so no second UI competes with the panel, and the player table name is a config value

Both belong to ESX Legacy and are GPL v3 — details in optional/README.md.

The bundled esx_skin keeps esx_skin:save, the esx_skin:getPlayerSkin callback and the old open-menu events — those events now open the x3d_skin panel, so scripts that still call them keep working.

Shops (Config.Shops)

Fourteen clothing stores, seven beauty salons and one mask shop ship in this resource — no separate shop script.

  • Map blips reveal within 120 m (blipDistance = 0 shows them always)
  • A ground marker plus an [E] Clothing Store ($500) prompt
  • Each type sets its own price and editable slots in Config.ShopTypes, with sub-tabs (clothing / accessories, hair / makeup)
  • Config.Shops.enabled = false turns the whole shop layer off and leaves /skin
  • price = 0 makes a shop free and never reaches the money bridge

The charge and the save happen in one server-side step. On confirm the server re-checks that the player is still at that shop and can still pay, then charges and saves. If the charge fails nothing is written; if the write fails the money is refunded.

⚠ The original flow (a separate clothing-shop script + esx_skin) charged from the shop script while the editor saved separately, so a player who blocked the charge event kept the clothes for free. That hole is closed here.

Wire your own money system in addons/framework/server.lua (ESX included, QBCore commented). Until it is wired, a priced shop refuses to open and says so — it never gives clothes away.

Wardrobe (Config.Wardrobe)

Save what you are wearing under a name, wear it again in one click, rename it, overwrite it or delete it. Each player keeps up to maxPerType outfits (default 20) and names must be unique.

Wearing a saved outfit swaps the clothing only — the face is never touched.

The list is owned by the server and pushed to the panel after every change; the panel never decides what exists.

Server exports

exports['x3d_skin']:GetSkin(src)          --> the saved table, or nil
exports['x3d_skin']:SetSkin(src, skin)    --> boolean (validated exactly like a player save)
exports['x3d_skin']:OpenEditor(src)       --> boolean

GetSkin takes either a server id or an identifier. SetSkin is not rate limited, so a uniform system can dress a whole gang at once.

Client exports

exports['x3d_skin']:GetSkin()
exports['x3d_skin']:GetMaxValues()
exports['x3d_skin']:LoadSkin(skin)
exports['x3d_skin']:LoadClothes(playerSkin, clothesSkin)   -- clothing only, the face is left alone
exports['x3d_skin']:Change('torso_1', 5)
exports['x3d_skin']:OpenEditor(opts)
exports['x3d_skin']:CloseEditor()
exports['x3d_skin']:IsOpen()

Hook

-- server-internal, not a net event — a client cannot fire it
AddEventHandler('x3d_skin:onSave', function(src, identifier, skin)
    print(('%s saved a new appearance'):format(identifier))
end)

Client-side there are x3d_skin:opened and x3d_skin:closed so another resource can hide its own HUD while the panel is up.

Where the data lives

Saved appearances are server-side in one JSON file (Config.Storage.File, default data/skins.json), loaded once at resource start and written back every Config.Storage.FlushSeconds, on player drop and on a graceful stop. The key is the player's permanent identifier (license/discord/steam), never a server id.

A corrupt file is preserved as data/skins.broken.json and the store starts empty, with a console warning — it is never overwritten silently.

To use MySQL instead:

  1. Run install.sql through oxmysql
  2. Open addons/storage/server.lua, scroll to the bottom and copy the oxmysql block over the four Storage.Get/Set/All/Flush functions above it

Safety, backed by tests

  • Nothing the panel sends is trusted — the slot name must exist in components.lua, the value must be a number, and it is floored and clamped into range; unknown keys are dropped
  • The storage key is always a stable identifier, never a reusable server id
  • One save per second per player, and a player's cooldown is cleared the moment they disconnect
  • /skin <id> always needs the ace, even when Config.PlayersCanEdit lets everyone edit themselves
  • An out-of-range index is clamped for the native call only, never written back into the saved skin — an add-on pack that has not mounted yet cannot permanently zero a player's outfit
  • The panel closes itself on death and on resource stop, releasing the cursor and the player's controls every time
pip install lupa
python tools/lua-test/x3d_skin_check.py

Config (config.lua)

Key What it does
Config.Locale Language of the Lua-side strings (th/en) — separate from the panel's own switch
Config.Command / Config.Key The open command (/skin) and the key binding (empty = none)
Config.AdminAce The ace required to open the editor for another player
Config.PlayersCanEdit false = only ace holders may edit at all
Config.AdvancedFace Preset faces, or the mother/father heritage system
Config.ApplyOnSpawn / Config.FirstTimeSetup Apply the saved skin on spawn · open the editor for a player who has never saved
Config.Storage.File / .FlushSeconds Where appearances are written and how often
Config.Camera Zoom range, pan limits, character lighting
Config.HideHud Hide the radar/HUD while the panel is open
Config.Shops / Config.ShopTypes / Config.ShopLocations The shops — on/off, prices, editable slots and locations
Config.Wardrobe The wardrobe — on/off and how many outfits each player may keep
Config.Backend auto / skinchanger / self — who dresses the ped and who stores the skin

Support

Discord: discord.gg/x3d-developer · Web: x3d-developer.com

View product page