Guide 2026-02-16

Setting Up Multi-Character Systems for FiveM

TDYSKY

TDYSKY

Founder & Lead Developer at Agency Scripts

Why Multi-Character Systems Matter

A multi-character system allows each player to own multiple separate characters on the same server, each with their own identity, inventory, bank account, job, and criminal record. This is a cornerstone feature of serious roleplay servers because it lets players explore different storylines without abandoning their main character. A gang leader can also play a police officer on a separate character, or a business owner can have a second character who is a fresh arrival in the city. Without multi-character support, players either need alt accounts or are locked into a single roleplay path. Building this system correctly requires careful attention to data isolation, database schema design, and a polished character selection UI that sets the tone for the entire server experience.

Database Schema Design

The foundation of any multi-character system is the database schema. You need to separate player-level data from character-level data. The player table stores the license identifier, Steam hex, Discord ID, and account-wide settings. The characters table stores everything specific to a character: name, date of birth, nationality, backstory, appearance, spawn position, and a foreign key linking back to the player. Every other table in your database that previously referenced a player identifier now needs to reference a character_id instead. This includes inventories, bank accounts, vehicles, housing, phone contacts, criminal records, and job assignments. Getting this schema right from the start prevents painful migration later.

-- Database schema (MySQL)
CREATE TABLE players (
    id        INT AUTO_INCREMENT PRIMARY KEY,
    license   VARCHAR(60) NOT NULL UNIQUE,
    steam     VARCHAR(60) DEFAULT NULL,
    discord   VARCHAR(30) DEFAULT NULL,
    max_slots INT DEFAULT 3,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE characters (
    id          INT AUTO_INCREMENT PRIMARY KEY,
    player_id   INT NOT NULL,
    slot        TINYINT NOT NULL DEFAULT 1,
    firstname   VARCHAR(50) NOT NULL,
    lastname    VARCHAR(50) NOT NULL,
    dob         DATE DEFAULT '1990-01-01',
    nationality VARCHAR(50) DEFAULT 'American',
    gender      TINYINT DEFAULT 0,
    backstory   TEXT DEFAULT NULL,
    skin        LONGTEXT DEFAULT NULL,
    job         VARCHAR(50) DEFAULT 'unemployed',
    job_grade   INT DEFAULT 0,
    cash        INT DEFAULT 500,
    bank        INT DEFAULT 5000,
    position    VARCHAR(100) DEFAULT '{"x":-269.4,"y":-955.3,"z":31.2,"heading":205.0}',
    is_dead     TINYINT DEFAULT 0,
    last_played TIMESTAMP NULL,
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE,
    UNIQUE KEY unique_slot (player_id, slot)
);

CREATE TABLE character_inventories (
    id           INT AUTO_INCREMENT PRIMARY KEY,
    character_id INT NOT NULL,
    item         VARCHAR(100) NOT NULL,
    count        INT DEFAULT 1,
    metadata     JSON DEFAULT NULL,
    slot         INT DEFAULT 1,
    FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE
);

Server-Side Character Management

The server handles all character CRUD operations: creating new characters, loading existing ones, saving character data, and deleting characters. When a player connects, the server fetches their player record and all associated characters from the database. This data is sent to the client to populate the character selection UI. Character creation validates input fields like name length and prevents duplicate names if your server enforces unique identities. When a player selects a character, the server loads all related data tables, sets the player's active character ID in memory, and triggers the spawn process. A critical detail is ensuring that only one character per player can be active at any time, and that switching characters properly saves and unloads the previous character's data.

-- server.lua
local activeCharacters = {} -- source -> characterId

RegisterNetEvent('multichar:requestCharacters', function()
    local src = source
    local license = GetPlayerIdentifierByType(src, 'license')
    if not license then return DropPlayer(src, 'No license identifier found.') end

    local player = MySQL.single.await(
        'SELECT * FROM players WHERE license = ?', {license}
    )
    if not player then
        MySQL.insert.await(
            'INSERT INTO players (license, steam, discord) VALUES (?, ?, ?)',
            {license, GetPlayerIdentifierByType(src, 'steam'), GetPlayerIdentifierByType(src, 'discord')}
        )
        player = MySQL.single.await('SELECT * FROM players WHERE license = ?', {license})
    end

    local characters = MySQL.query.await(
        'SELECT id, slot, firstname, lastname, dob, gender, job, job_grade, cash, bank, last_played FROM characters WHERE player_id = ? ORDER BY slot ASC',
        {player.id}
    )

    TriggerClientEvent('multichar:showSelection', src, characters, player.max_slots, player.id)
end)

RegisterNetEvent('multichar:selectCharacter', function(charId)
    local src = source
    local license = GetPlayerIdentifierByType(src, 'license')
    local char = MySQL.single.await(
        'SELECT c.* FROM characters c JOIN players p ON c.player_id = p.id WHERE c.id = ? AND p.license = ?',
        {charId, license}
    )
    if not char then return end

    -- Save previous character if switching
    if activeCharacters[src] then
        saveCharacter(src, activeCharacters[src])
    end

    activeCharacters[src] = charId
    MySQL.update('UPDATE characters SET last_played = NOW() WHERE id = ?', {charId})

    local pos = json.decode(char.position)
    TriggerClientEvent('multichar:spawnCharacter', src, char, pos)
end)

AddEventHandler('playerDropped', function()
    local src = source
    if activeCharacters[src] then
        saveCharacter(src, activeCharacters[src])
        activeCharacters[src] = nil
    end
end)

Character Creation Flow

The character creation process should be intuitive and immersive. When a player clicks an empty character slot, the NUI opens a creation form where they enter a first name, last name, date of birth, gender, and optionally a backstory. After submitting the form, the server validates the input, inserts a new character record, and transitions the player to the appearance editor. The appearance editor lets them customize their ped model using GTA's native component variation system, including face features, hair, clothing, and accessories. Once they confirm their appearance, the skin data is serialized to JSON and stored in the character's skin column. The player is then spawned into the world at the default new-player spawn location.

-- Character creation (server.lua continued)
RegisterNetEvent('multichar:createCharacter', function(data, playerId)
    local src = source
    local license = GetPlayerIdentifierByType(src, 'license')

    -- Validate ownership
    local player = MySQL.single.await(
        'SELECT id, max_slots FROM players WHERE id = ? AND license = ?',
        {playerId, license}
    )
    if not player then return end

    -- Check slot availability
    local charCount = MySQL.scalar.await(
        'SELECT COUNT(*) FROM characters WHERE player_id = ?', {player.id}
    )
    if charCount >= player.max_slots then
        TriggerClientEvent('multichar:error', src, 'Maximum characters reached.')
        return
    end

    -- Validate input
    local firstname = tostring(data.firstname or ''):gsub('[^%a]', '')
    local lastname  = tostring(data.lastname or ''):gsub('[^%a]', '')
    if #firstname < 2 or #lastname < 2 then
        TriggerClientEvent('multichar:error', src, 'Name must be at least 2 characters.')
        return
    end

    local nextSlot = MySQL.scalar.await(
        'SELECT COALESCE(MAX(slot), 0) + 1 FROM characters WHERE player_id = ?',
        {player.id}
    )

    local charId = MySQL.insert.await(
        'INSERT INTO characters (player_id, slot, firstname, lastname, dob, gender, backstory) VALUES (?, ?, ?, ?, ?, ?, ?)',
        {player.id, nextSlot, firstname, lastname, data.dob or '1990-01-01', data.gender or 0, data.backstory or ''}
    )

    TriggerClientEvent('multichar:openAppearanceEditor', src, charId)
end)

Client-Side NUI for Character Selection

The character selection screen is the first thing players see after connecting, so it needs to look polished and load quickly. Use a camera positioned in an interesting location on the map, with the player's character peds rendered in a lineup or carousel format. Each character card displays the name, last played date, job title, and financial overview. Empty slots show a plus icon inviting the player to create a new character. The NUI communicates with the client Lua script through SendNUIMessage and RegisterNUICallback, and the client Lua handles ped spawning, camera setup, and forwarding selections to the server. Freeze the player and hide the HUD during selection to prevent any gameplay interaction before a character is fully loaded.

-- client.lua
local selectionCam = nil
local previewPeds  = {}

RegisterNetEvent('multichar:showSelection', function(characters, maxSlots, playerId)
    SetNuiFocus(true, true)
    DoScreenFadeIn(500)

    -- Setup camera at selection location
    local camPos = vector3(-75.0, -818.0, 326.0)
    selectionCam = CreateCamWithParams('DEFAULT_SCRIPTED_CAMERA',
        camPos.x, camPos.y, camPos.z, -35.0, 0.0, 0.0, 60.0)
    SetCamActive(selectionCam, true)
    RenderScriptCams(true, true, 1000, true, false)

    -- Send data to NUI
    SendNUIMessage({
        action     = 'showCharacterSelect',
        characters = characters,
        maxSlots   = maxSlots,
        playerId   = playerId
    })
end)

RegisterNUICallback('selectCharacter', function(data, cb)
    SetNuiFocus(false, false)
    cleanupSelection()
    TriggerServerEvent('multichar:selectCharacter', data.id)
    cb({ok = true})
end)

RegisterNUICallback('createCharacter', function(data, cb)
    TriggerServerEvent('multichar:createCharacter', data, data.playerId)
    cb({ok = true})
end)

RegisterNUICallback('deleteCharacter', function(data, cb)
    TriggerServerEvent('multichar:deleteCharacter', data.id)
    cb({ok = true})
end)

function cleanupSelection()
    if selectionCam then
        SetCamActive(selectionCam, false)
        RenderScriptCams(false, true, 500, true, false)
        DestroyCam(selectionCam, false)
        selectionCam = nil
    end
    for _, ped in ipairs(previewPeds) do
        if DoesEntityExist(ped) then DeleteEntity(ped) end
    end
    previewPeds = {}
end

Spawn Selection and Data Isolation

After selecting a character, the player needs to choose where to spawn. Common options include their last known position, their apartment or house, the hospital if they were last downed, or a default city spawn point. The spawn selector should only show options relevant to the character, for example an apartment option should only appear if that character actually owns one. Data isolation is the most critical architectural concern in a multi-character system. Every resource on your server that stores per-player data must use the character ID rather than the player's license or server ID as the key. This includes inventories, phone data, banking, vehicle ownership, housing, and criminal records. A common mistake is using the player's server source ID as a database key, which breaks entirely when characters are switched. Audit every resource on your server and ensure they all reference the active character ID, which you expose through an export like exports['multichar']:GetCharacterId(source).

Character Switching and Session Management

Allowing players to switch characters without fully disconnecting and reconnecting is a quality-of-life feature that players greatly appreciate. When a player triggers a character switch, the server saves all data for the current character, clears all character-specific state from memory, and sends the player back to the selection screen. On the client, this means destroying the current ped, removing all blips and markers tied to the character's job or properties, clearing any active NUI interfaces, and resetting the camera to the selection view. Every resource on the server receives a multichar:characterUnloaded event so they can clean up their own state. This is where poor data isolation reveals itself: if a resource caches data by source ID instead of character ID, switching characters will bleed data between characters. Testing character switching thoroughly is essential. Create two characters with different jobs, inventories, and bank balances, then switch back and forth rapidly to confirm that no data leaks between them.

Share this article

Ready to upgrade your server?

Check out our premium FiveM scripts in the Agency Scripts store or join our Discord community for support and updates.