Skip to content

Multicharacter Developer API ​

For anyone reading or extending the code rather than configuring it. To install and configure the script, start at Installation and Configuration.

VORP Fully open source RedM b1491

Nothing is escrowed ​

fxmanifest.lua ends with an escrow_ignore block covering seven patterns:

lua
escrow_ignore {
    '*.lua',
    'client/*.lua',
    'server/*.lua',
    'shared/*.lua',
    '**/*.lua',
    '*',
    '**/*',
}

The last two — '*' and '**/*' — are the ones that matter. Between them they name every file in the resource at every depth, not only the Lua. The manifest's own comment says Asset Escrow encrypts nothing here. Every file ships readable and editable: the client modules, the server handlers, the NUI page, the stylesheets, the fonts.

This is not the same deal as the animation packs

The RedMorrow animation packs are escrowed. This resource is not. If you intend to rewrite the selection screen rather than configure it, that is supported by the licence terms and by the fact that the code is in front of you.

What the manifest globs, and what it does not ​

lua
client_scripts {
    '@vorp_core/client/dataview.lua',
    'shared/clothing.lua',
    'shared/hairs.lua',
    'client/*.lua',
}

server_scripts {
    'logs.lua',
    '@oxmysql/lib/MySQL.lua',
    'server/*.lua',
}

A new .lua file dropped into client/ or server/ loads on the next restart without touching the manifest. That is the intended way to add your own code: a new file beside the existing ones, not an edit inside them, so an update to the resource does not land on top of your work.

Files under html/ are listed one by one

The files block names each page asset individually — html/app.js, html/style.css, each .woff2 font, each .webp plate. A new image or script under html/ that is not in that block returns 404 to the page and fails silently. The two button plates in html/img/btn/ are listed by name for exactly this reason, and a third one needs a line in files as well as a row in ConfigSelector.ButtonSkins.

The shared_scripts block is ordered deliberately, and the order is load-bearing: config_scenes.lua must come straight after config.lua because it appends to Config.SpawnPosition; shared/redm_keys.lua must come before anything that binds a key; animations.lua must come after config_selector.lua.

The three exports ​

That is the whole export surface. All three are inherited from the stock VORP vorp_character and concern clothing, not the selection screen.

ExportSideReturnsDoes
GetPlayerComponent(category)ClienttableOne clothing component of the player
GetAllPlayerComponents()ClienttableEvery clothing component of the player
OpenOutfitsMenu(source)ServernothingOpens the outfits menu for that player
lua
-- client
local hat = exports.vorp_character:GetPlayerComponent('hats')
local all = exports.vorp_character:GetAllPlayerComponents()

-- server
exports.vorp_character:OpenOutfitsMenu(source)

Exports are keyed on the folder name. This resource must stay named vorp_character — it replaces the stock one — so that name is safe to hard-code.

There is no export to open or close the selector

The selection screen is driven by the normal VORP character flow. To send a player back to it, use VORP's own route (vorp_character:server:GoToSelectionMenu) rather than reaching into this resource. Anything else that wants to know whether the screen is up can read the global IsInCharacterSelector, which client/selector_nui.lua sets to true while the page has focus.

Client modules ​

One global table per file, so a module is found by the name it is used under.

GlobalFileMain functions
SelectorBridgeclient/client.luaGetChars, SetChars, GetIndex, SetIndex, GetMaxCharacters, GetLocationIndex, GetSceneData, GetMainCam, GetLastCam, CreateMainCam, Restage, FinishScene, CleanupScene, Spawn
SelectorSceneclient/selector_scene.luaFocusCharacter, FocusWide, ApplyFraming, ApplyFramingAll, ApplyCameraOffset, ApplyZoom, ApplyRotation, CyclePose, ChangePose, ApplyPose, ApplyPosePair, PairSceneAvailable, ApplyEnvironment, ApplyFilter, SetMusic, CycleLocation, IsGliding
SelectorPartnerclient/selector_partner.luaStageAll, PosePair, GetPed, HasPed, PoseList, SetPoseByKey, Reposition, SetVisibility, SetPresent, Diagnostics, Clear
SelectorSettingsclient/selector_settings.luaGet, Set, Reset, GetPose, SetPose, Languages, Locale
SceneCreatorclient/scene_creator.luaOpen, Close, IsOpen, Preview, ApplyEnvironment, Validate, Diagnostics
SceneRuntimeclient/scene_runtime.luaApply, ToConfigEntry, ApplyMarks, DressScene, PlayTravel, SceneAt, Restore
SceneWorldclient/scene_world.luaSpawn, SpawnProp, SpawnVehicle, SpawnNpc, FindSeat, AttachProp, Handles, Counts, Clear
SceneTravelclient/scene_travel.luaPlay, Stop, IsRunning
SceneAdjusterclient/scene_adjuster.luaBegin, Drag, Nudge, Turn, SetAxis, SetMode, HoldPose, MatchPhase, Revert, Finish, IsActive

Every function carries LuaLS annotations (---@param, ---@return), so an editor with the Lua language server will complete and type-check calls into these tables without any extra setup.

client/selector_debug.lua holds the debug commands and DumpSelectorState(). The remaining client files — createCharacter.lua, creator_functions.lua, menu.lua, functions.lua, clothingstore.lua, commands.lua — are the original VORP creator, menus, stores and commands, and still use vorp_menu.

Server files ​

FileRole
server/server.luaOriginal VORP server, plus the selector additions: the character list with partner data attached, and the random location pick
server/selector.luaPlaytime, delete clean-up, starter items, webhooks, logout, the server-side debug switch
server/partner.luaPartner requests and pairings
server/scenecreator.luaSaved scenes — the database table, the permission checks, and sending LIVE scenes to clients
server/pose_editor.luaPose editor permissions and partner_poses.json

Network events the client sends, by file:

EventFile
vorp_character:partner:presentserver/partner.lua
vorp_character:creator:fetch, :save, :delete, :toggle, :requestScenesserver/scenecreator.lua
vorp_character:poseEditor:load, :request, :saveserver/pose_editor.lua
vorp_character:selector:requestSelector, :requestLogout, :debugserver/selector.lua

VORP callbacks registered with Core.Callback.Register:

CallbackDoes
vorp_character:partner:getState, :send, :respond, :withdraw, :unpairThe partner panel
vorp_characters:getMaxCharactersThe character limit, read from vorp_core
vorp_character:callback:GetOutfits, SetOutfit, DeleteOutfit, PayToShop, CanPayForSecondChance, PayForSecondChanceOriginal stores and outfits

Every Scene Creator handler re-checks permission on the server

ConfigCreator.AdminGroups is checked inside each handler, not once at open. The pose editor checks ConfigSelector.Partner.editor on open and on every save, and validates the pose name and the numbers before they reach partner_poses.json. Do not add a handler that trusts the client's claim to be staff.

The NUI bridge ​

One page, html/index.html, hosts both the selection screen and the Scene Creator panel.

The page calls Lua ​

html/app.js wraps the call in a post() helper:

js
function post(name, data) {
    return fetch(`https://${RESOURCE}/${name}`, {
        method: "POST",
        headers: { "Content-Type": "application/json; charset=UTF-8" },
        body: JSON.stringify(data || {}),
    }).then((r) => r.json());
}

RESOURCE comes from GetParentResourceName(), so the URL is https://<resource>/<name> at runtime. Lua answers through the callback's cb:

lua
RegisterNUICallback("name", function(data, cb)
    -- ...
    cb({ ok = true })
end)

Every callback in this resource answers with at least { ok = ... }, and post() resolves to that object, so the page can branch on the result of a call instead of guessing. setSetting goes further and returns the whole settings table and the active locale, which is how the panel redraws itself after a change without a second round trip.

Lua talks to the page ​

lua
SendNUIMessage({ action = "open", characters = ..., settings = ... })

Selection screen callbacks ​

Registered in client/selector_nui.lua:

CallbackDoes
focusCharacterHighlight a different slot and move the camera onto its ped
focusWideThe wide shot, used for an empty slot
playSpawn the highlighted character
createCharacterStart the character creator on an empty slot
deleteCharacterDelete, after the first-name confirmation
cyclePose, listPoses, pickPoseChange pose, list the poses, pick one by name
cycleLocation, pickLocationChange location
setSettingSave one setting and apply it
resetSettingsBack to the defaults, then re-apply everything
camMoveShift plus arrows — which way the camera is held, as -1, 0 or 1 per axis
partnerState, partnerSend, partnerRespond, partnerWithdraw, partnerUnpairThe partner panel

Messages sent the other way: open, refresh, close, partnerRefresh, poseEditor, debug.

open and refresh carry the same payload. OpenSelectorUI() sends refresh instead of open when the page is already up — a restage after a delete, for example — so the data is replaced without taking NUI focus a second time.

Both natives are wrapped locally for logging

client/selector_nui.lua shadows RegisterNUICallback and SendNUIMessage with local functions that log the callback name and the incoming JSON when debug is on, then call the real native. That is why /selectordebug shows the whole conversation between page and Lua, in order, with <- for inbound and -> for outbound.

Scene Creator callbacks ​

Every Scene Creator callback name starts with creator: creatorNew, creatorLoad, creatorSave, creatorDelete, creatorToggle, creatorClose, creatorFly, creatorWalk, creatorPlace, creatorPlaceChar, creatorCapture, creatorPreview, creatorAddSlot, creatorRemoveSlot, creatorClone, creatorUseEmote, creatorSnapSeat, the creatorAdjust family, creatorLock, creatorHold, creatorGoto, and the travel ones.

Messages back to the page are prefixed the same way — creatorOpen, creatorScene, creatorMode, creatorToast, creatorGizmo, creatorPreview, creatorHold. The split is enforced in one line of app.js:

js
if (typeof data.action === "string" && data.action.startsWith("creator")) return;

app.js drops every action starting with creator; creator.js handles them. Keep that prefix on anything you add to the editor panel, or the selection screen's own handler will try to process it.

The CEF cache and the ?v= trick ​

CEF caches the page's assets by URL, and a changed file at the same URL is not re-read. The shipped tags carry a version query:

text
<script src="scale.js?v=46"></script>
<link rel="stylesheet" href="style.css?v=46">
<link rel="stylesheet" href="creator.css?v=46">
<script src="app.js?v=49"></script>
<script src="creator.js?v=46"></script>

After editing app.js, creator.js or either stylesheet, raise the number on its own tag in html/index.html. A resource restart on its own is not enough. Editing the file and seeing no change is almost always this.

RegisterNUICallback adds a handler, it does not replace one

Register the same name twice and both functions run on every call, in registration order, and both try to answer cb. The symptom is an action happening twice, or a reply that looks like it came from the wrong handler. Register each callback name exactly once across the whole resource.

How the selection screen runs ​

  1. The server picks. server/server.lua reads the player's characters, attaches partner data through AttachPartners, picks one location at random — counting the LIVE database scenes alongside the 13 shipped ones — and sends vorpcharacter:selectCharacter with the characters, the character limit and the location index.

  2. The client stages. With no characters, client/client.lua opens the character creator instead. Otherwise StartSwapCharacters() sets weather and time through weathersync, moves the hidden player to the location's playerpos, and stages one ped per character on the location's positions.

  3. Handover. At the end of staging it calls OpenSelectorUI() in client/selector_nui.lua — or the original vorp_menu list, when ConfigSelector.Enabled is false.

  4. The scene is made ready while the screen is still faded out. OpenSelectorUI() clears the staging filter, applies the player's own filter, resets poses, builds the partner peds with SelectorPartner.StageAll() (which blocks while models stream), applies weather and time, hides every character except the highlighted one, and applies the saved rotation.

  5. The page opens. The payload goes out as action = "open", SetNuiFocus(true, true) hands over mouse and keyboard, music starts, and SelectorPartner.SetPresent(true) tells the server this player is on the screen and can be searched for by a partner request.

  6. The page calls back. html/app.js renders and posts as the player moves — focusCharacter between slots, setSetting from the Settings panel, cyclePose on E, play to spawn.

  7. The camera follows. SelectorScene.FocusCharacter flies to the slot; SelectorScene.ApplyFraming works out the shot and hands it to shoot(), which is the single place orbit and height are added.

Closing reverses it: the filter is cleared so nothing bleeds into gameplay, the screen effects stop, the pose animation dictionaries are released, SetPresent(false) goes to the server, close goes to the page, and NUI focus is dropped.

Extending ​

Add a player setting ​

Five files, in this order. The first one is not optional — SelectorSettings.Set() rejects any key with no entry in defaults and logs rejected unknown setting, so a setting added anywhere else first will appear to do nothing.

  1. client/selector_settings.lua — add the key and its default to the defaults table. If it is numeric, add its clamp to load() and the matching branch in SelectorSettings.Set(), beside the ones for soundVolume, timeHour, zoom and camHeight.

  2. html/app.js — add a row to the settings panel, and for a slider a case in noteText() so the row has a readable value beside it.

  3. config_selector.lua — add the label to ConfigSelector.Locales, at least to the English table. All seven locale tables hold 89 keys each; a missing key falls back to the string passed as the second argument of t() in app.js.

  4. client/selector_nui.lua — in the setSetting callback, apply the change when your key arrives. The callback is a plain chain of if data.key == ... then blocks; add yours to the end.

  5. html/index.html — raise ?v= on the app.js tag.

A key in ConfigSelector.UI becomes the server owner's default

load() copies the hard-coded defaults, then overlays any key of the same name found in ConfigSelector.UI, then overlays the player's stored KVP on top of that. So naming your key in ConfigSelector.UI makes it configurable by the server owner for nothing extra, with the player's own choice still winning.

Settings live in the client KVP vorp_character_selector_settings_v1 as JSON, including the last pose per character. There is no server-side copy — a player who clears their game cache goes back to your defaults.

Add a page callback ​

lua
-- client/my_selector_extra.lua  (loads via client/*.lua, no manifest edit)
RegisterNUICallback("myThing", function(data, cb)
    -- do the work
    cb({ ok = true })
end)
js
// html/app.js
post("myThing", { value: 1 }).then((res) => {
    if (!res.ok) return;
    // ...
});

Then raise ?v= on app.js. Register the name once only, for the reason above.

Add config content ​

WhatWhereShape
Framing presetConfigSelector.Camera.framing{ id, label, bone, look, dist, camLift }
FilterConfigSelector.Filters.list{ id, label, fx }
Music trackConfigSelector.Music.tracks{ id, label, event }
Button skinConfigSelector.ButtonSkins{ id, label, file }, plus a line in the manifest's files block
Solo or duo poseanimations.luaSee Poses and Partner system
Scene Creator poseconfig_emotes.luaSee Emote library
LanguageConfigSelector.LocalesA table of 89 keys, named for the language
LocationConfig.SpawnPosition, ConfigScenes.List, or the Scene CreatorSee Locations

The 38 filters are not written out one by one. A loop at the bottom of config_selector.lua builds them from three groups — Game with 18, Modern with 10, Vintage with 10 — appending names like PhotoMode_FilterGame01 to a list that starts with nothing but { id = "none", label = "None" }. Reading the literal table alone undercounts; add your own entries with an fx name and they sit alongside the generated ones.

A music track with event = nil means "whatever the location defines" and event = false means silence. Five of the seven shipped tracks are real audio events; stopEvent is MC_MUSIC_STOP.

Frame a new kind of shot ​

SelectorScene.ApplyFraming builds a shot table and hands it to shoot():

lua
---@param shot table { x, y, z, pitch, roll, yaw, pivotX, pivotY, people }
--- `people` are the characters in shot, as { x, y } points.
local function shoot(slot, shot)

shoot() stores the table on the slot as baseShot, then adds the player's camera orbit and height on top of it. Building a new shot the same way gets three behaviours for free:

  • Shift plus arrows work on it. The stored baseShot is re-read every frame by SelectorScene.ApplyCameraOffset, so the swing never re-reads the swaying skeleton and the shot does not drift while the player holds a key.
  • The orbit pivots correctly. pivotX and pivotY are the point the camera swings around. For a single character that is the ped; for a pair it is the midpoint between the two.
  • Orbiting never pushes the camera into someone. people lets shoot() measure the nearest character's depth in the framed shot and step back if swinging brought anyone closer than that.

Omit pivotX and the orbit is skipped entirely and only height is applied. Omit people and the step-back check does not run. Both are fine for a deliberately fixed shot.

RedM notes ​

Behaviour the code relies on, all observed on build b1491.

No synchronised scene natives. The scene natives are not exposed, so a duo pose is two independent TaskPlayAnim clips with the partner placed from its attach vector, and their playback phase pushed back into step every 400 ms. SelectorScene.PairSceneAvailable() checks at runtime and uses real synchronised scenes on a build that has them.

TaskPlayAnim takes thirteen arguments on RDR3, and the twelfth is a string. The flag values also differ from GTA V's. Both call sites in this resource carry a comment saying so, because the GTA V form crashes the client rather than failing quietly:

lua
TaskPlayAnim(pedA, dictA, animA, blendIn, blendOut, -1, flag, 0.0, false, 0, false, "", false)

spawn.z is origin level, roughly 0.9 m above the feet — not foot level. Confusing the two is what makes a character hover or sink.

SetEntityCoords lifts a ped by that offset; SetEntityCoordsNoOffset does not. The resource uses the latter throughout. Mixing them is the other half of the hovering problem.

Moving a ped cancels its current task. Place a ped first, then start its pose. Never the other way round.

Some BOOL natives return 0 or 1 rather than a boolean, and 0 is truthy in Lua. Compare explicitly or the check always passes.

Scenarios are started with TaskStartScenarioInPlaceHash and a conditional of 0.

Keys behave differently under NUI focus. While the page holds focus, IsControlPressed stops reporting. IsDisabledControlPressed still reports held keys, and IsRawKeyPressed — Windows key codes, from RawKeys in shared/redm_keys.lua — works regardless. That is why the Scene Creator's escape key is a raw key code rather than a control hash.

GET_SCREEN_COORD_FROM_WORLD_COORD is unreliable on this build, so SceneAdjuster projects points itself from the camera instead of asking the engine. /scenegizmo prints what the native returns, for comparison on your own build.

Cross-resource functions arrive as tables. A function reference passed between resources comes through as a table, so type() returns "table". Check for callability, not for "function".

UI scaling uses CSS zoom. html/scale.js sets --ui-zoom so the page reads correctly from 1080p up to 4K. getBoundingClientRect() returns zoomed values; offset* properties do not. Mixing the two in a measurement is a bug that only shows up on a 1440p or 4K monitor.

Debugging ​

Set ConfigSelector.Debug = true, or type /selectordebug in game to switch client and server logging together. Every log line goes through SelectorLog(tag, ...) from shared/selector_log.lua.

CommandDoes
/selectordebug [on|off]Logging on, off, or toggled
/selectordumpPrints the selector's whole state — config, scene, settings, characters, partner, Scene Creator, natives
/selectoroverlayOn-screen line with ground and pose information for the partner
/selectornativesWhich of the natives the resource probes for are present on this build
/selectorpose [text]Lists the duo poses, or stages the one whose label contains the text
/selectorposenext [back]Steps forward, or back, through every duo pose
/selectorkit <emote> [upper]Plays one kit emote on the highlighted character
/selectoranim <dict> <clip>Plays one clip on the highlighted character
/scenedebugValidates the scene currently loaded in the Scene Creator
/scenedumpPrints the Scene Creator's state
/scenekeysWatches for 10 seconds and prints which of the editor's keys arrive, in three columns — control, disabled, raw
/scenegizmoPrints what the screen projection native returns
scenecreatorcloseForce-closes the Scene Creator

The server half of /selectordebug needs the admin group

The client half always works. The server half checks the caller's VORP group and refuses anyone who is not admin. From the server console there is no group to check, so selectordebug on typed there always works, and prints the new state.

/selectorkit and /selectoranim are the fastest way to find out whether a pose you are about to add to animations.lua reads correctly on a staged character. /scenekeys answers the other common question — whether a key is being swallowed by NUI focus, and which of the three read methods still sees it.

Support: https://discord.gg/w4Ew4k3Exn

Documentation for RedMorrow. Scripts are licensed per server — redistribution is not permitted.