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 b1491Nothing is escrowed
fxmanifest.lua ends with an escrow_ignore block covering seven patterns:
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
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.
| Export | Side | Returns | Does |
|---|---|---|---|
GetPlayerComponent(category) | Client | table | One clothing component of the player |
GetAllPlayerComponents() | Client | table | Every clothing component of the player |
OpenOutfitsMenu(source) | Server | nothing | Opens the outfits menu for that player |
-- 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.
| Global | File | Main functions |
|---|---|---|
SelectorBridge | client/client.lua | GetChars, SetChars, GetIndex, SetIndex, GetMaxCharacters, GetLocationIndex, GetSceneData, GetMainCam, GetLastCam, CreateMainCam, Restage, FinishScene, CleanupScene, Spawn |
SelectorScene | client/selector_scene.lua | FocusCharacter, FocusWide, ApplyFraming, ApplyFramingAll, ApplyCameraOffset, ApplyZoom, ApplyRotation, CyclePose, ChangePose, ApplyPose, ApplyPosePair, PairSceneAvailable, ApplyEnvironment, ApplyFilter, SetMusic, CycleLocation, IsGliding |
SelectorPartner | client/selector_partner.lua | StageAll, PosePair, GetPed, HasPed, PoseList, SetPoseByKey, Reposition, SetVisibility, SetPresent, Diagnostics, Clear |
SelectorSettings | client/selector_settings.lua | Get, Set, Reset, GetPose, SetPose, Languages, Locale |
SceneCreator | client/scene_creator.lua | Open, Close, IsOpen, Preview, ApplyEnvironment, Validate, Diagnostics |
SceneRuntime | client/scene_runtime.lua | Apply, ToConfigEntry, ApplyMarks, DressScene, PlayTravel, SceneAt, Restore |
SceneWorld | client/scene_world.lua | Spawn, SpawnProp, SpawnVehicle, SpawnNpc, FindSeat, AttachProp, Handles, Counts, Clear |
SceneTravel | client/scene_travel.lua | Play, Stop, IsRunning |
SceneAdjuster | client/scene_adjuster.lua | Begin, 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
| File | Role |
|---|---|
server/server.lua | Original VORP server, plus the selector additions: the character list with partner data attached, and the random location pick |
server/selector.lua | Playtime, delete clean-up, starter items, webhooks, logout, the server-side debug switch |
server/partner.lua | Partner requests and pairings |
server/scenecreator.lua | Saved scenes — the database table, the permission checks, and sending LIVE scenes to clients |
server/pose_editor.lua | Pose editor permissions and partner_poses.json |
Network events the client sends, by file:
| Event | File |
|---|---|
vorp_character:partner:present | server/partner.lua |
vorp_character:creator:fetch, :save, :delete, :toggle, :requestScenes | server/scenecreator.lua |
vorp_character:poseEditor:load, :request, :save | server/pose_editor.lua |
vorp_character:selector:requestSelector, :requestLogout, :debug | server/selector.lua |
VORP callbacks registered with Core.Callback.Register:
| Callback | Does |
|---|---|
vorp_character:partner:getState, :send, :respond, :withdraw, :unpair | The partner panel |
vorp_characters:getMaxCharacters | The character limit, read from vorp_core |
vorp_character:callback:GetOutfits, SetOutfit, DeleteOutfit, PayToShop, CanPayForSecondChance, PayForSecondChance | Original 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:
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:
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
SendNUIMessage({ action = "open", characters = ..., settings = ... })Selection screen callbacks
Registered in client/selector_nui.lua:
| Callback | Does |
|---|---|
focusCharacter | Highlight a different slot and move the camera onto its ped |
focusWide | The wide shot, used for an empty slot |
play | Spawn the highlighted character |
createCharacter | Start the character creator on an empty slot |
deleteCharacter | Delete, after the first-name confirmation |
cyclePose, listPoses, pickPose | Change pose, list the poses, pick one by name |
cycleLocation, pickLocation | Change location |
setSetting | Save one setting and apply it |
resetSettings | Back to the defaults, then re-apply everything |
camMove | Shift plus arrows — which way the camera is held, as -1, 0 or 1 per axis |
partnerState, partnerSend, partnerRespond, partnerWithdraw, partnerUnpair | The 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:
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:
<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
The server picks.
server/server.luareads the player's characters, attaches partner data throughAttachPartners, picks one location at random — counting the LIVE database scenes alongside the 13 shipped ones — and sendsvorpcharacter:selectCharacterwith the characters, the character limit and the location index.The client stages. With no characters,
client/client.luaopens the character creator instead. OtherwiseStartSwapCharacters()sets weather and time through weathersync, moves the hidden player to the location'splayerpos, and stages one ped per character on the location's positions.Handover. At the end of staging it calls
OpenSelectorUI()inclient/selector_nui.lua— or the original vorp_menu list, whenConfigSelector.Enabledisfalse.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 withSelectorPartner.StageAll()(which blocks while models stream), applies weather and time, hides every character except the highlighted one, and applies the saved rotation.The page opens. The payload goes out as
action = "open",SetNuiFocus(true, true)hands over mouse and keyboard, music starts, andSelectorPartner.SetPresent(true)tells the server this player is on the screen and can be searched for by a partner request.The page calls back.
html/app.jsrenders and posts as the player moves —focusCharacterbetween slots,setSettingfrom the Settings panel,cyclePoseon E,playto spawn.The camera follows.
SelectorScene.FocusCharacterflies to the slot;SelectorScene.ApplyFramingworks out the shot and hands it toshoot(), 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.
client/selector_settings.lua— add the key and its default to thedefaultstable. If it is numeric, add its clamp toload()and the matching branch inSelectorSettings.Set(), beside the ones forsoundVolume,timeHour,zoomandcamHeight.html/app.js— add a row to the settings panel, and for a slider a case innoteText()so the row has a readable value beside it.config_selector.lua— add the label toConfigSelector.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 oft()inapp.js.client/selector_nui.lua— in thesetSettingcallback, apply the change when your key arrives. The callback is a plain chain ofif data.key == ... thenblocks; add yours to the end.html/index.html— raise?v=on theapp.jstag.
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
-- client/my_selector_extra.lua (loads via client/*.lua, no manifest edit)
RegisterNUICallback("myThing", function(data, cb)
-- do the work
cb({ ok = true })
end)// 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
| What | Where | Shape |
|---|---|---|
| Framing preset | ConfigSelector.Camera.framing | { id, label, bone, look, dist, camLift } |
| Filter | ConfigSelector.Filters.list | { id, label, fx } |
| Music track | ConfigSelector.Music.tracks | { id, label, event } |
| Button skin | ConfigSelector.ButtonSkins | { id, label, file }, plus a line in the manifest's files block |
| Solo or duo pose | animations.lua | See Poses and Partner system |
| Scene Creator pose | config_emotes.lua | See Emote library |
| Language | ConfigSelector.Locales | A table of 89 keys, named for the language |
| Location | Config.SpawnPosition, ConfigScenes.List, or the Scene Creator | See 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():
---@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
baseShotis re-read every frame bySelectorScene.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.
pivotXandpivotYare 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.
peopleletsshoot()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:
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.
| Command | Does |
|---|---|
/selectordebug [on|off] | Logging on, off, or toggled |
/selectordump | Prints the selector's whole state — config, scene, settings, characters, partner, Scene Creator, natives |
/selectoroverlay | On-screen line with ground and pose information for the partner |
/selectornatives | Which 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 |
/scenedebug | Validates the scene currently loaded in the Scene Creator |
/scenedump | Prints the Scene Creator's state |
/scenekeys | Watches for 10 seconds and prints which of the editor's keys arrive, in three columns — control, disabled, raw |
/scenegizmo | Prints what the screen projection native returns |
scenecreatorclose | Force-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.
Related
- Configuration — every key in
config_selector.lua - Database — the three tables and what is in them
- Scene Creator reference — every field of a saved scene
- Emote library — why a raw clip floats and a scenario cannot
- Troubleshooting — symptom, cause, fix
- VORP framework — the framework this builds on
Support: https://discord.gg/w4Ew4k3Exn