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.