DEV Community

FiveM MLOs
FiveM MLOs

Posted on

Toggling MLO Entity Sets in FiveM and Keeping Them in Sync for Every Player

Many custom interiors ship with optional variations: a lab that can be upgraded, a club with an event stage, a garage with extra lifts, a house that can be empty or furnished. In MLO terms these are entity sets, named groups of props inside the interior that code can enable or disable at runtime. This post explains how they work, how to find their names, how to toggle them with a few natives and, most importantly, how to make sure every player sees the same version of the room.

What an entity set actually is

An MLO's interior definition lives in its .ytyp file. Alongside the rooms and portals, that definition can contain a list of entity sets. Each set has a name and a list of entities (props) that belong to it. When a set is active, those props are drawn as part of the interior; when it's inactive, they are not.

Two things follow from that:

  • You can only toggle sets the creator actually defined. You can't turn an arbitrary prop into a set from a script.
  • The set names are fixed strings chosen by the creator, such as upgrade_lvl1 or stage_props.

Finding the set names

The easiest way is the documentation. Good creators list their entity set names in a readme or on the product page, often with a screenshot of each variant.

If there is no list, you can look inside the ytyp. Tools that open GTA V resource files can export a ytyp to XML, where the interior's archetype contains an entitySets block. Each item in that block has a name field: those are the strings your script needs. Only do this with files you have the right to use, and don't redistribute anything you extract.

Toggling a set from the client

Entity set state is applied on the client. The basic flow is: find the interior ID, change the set, then refresh the interior so the change is drawn.

-- client.lua
local function setEntitySet(coords, setName, enabled)
    local interiorId = GetInteriorAtCoords(coords.x, coords.y, coords.z)
    if interiorId == 0 then
        return false -- no interior at these coords (or not loaded yet)
    end

    if enabled then
        ActivateInteriorEntitySet(interiorId, setName)
    else
        DeactivateInteriorEntitySet(interiorId, setName)
    end

    RefreshInterior(interiorId)
    return true
end
Enter fullscreen mode Exit fullscreen mode

GetInteriorAtCoords returns 0 when there is no interior at that position, so check for it. Use coordinates that are clearly inside the interior, such as the middle of the main room, rather than the doorway, where you may hit the exterior instead.

You can check the current state with IsInteriorEntitySetActive(interiorId, setName), which is handy for debugging.

Describe your interiors in a config

Hard-coding coordinates and set names in logic gets messy as soon as you have more than one interior. Put them in a shared config instead:

-- shared/config.lua
Config = {}

Config.Interiors = {
    lab = {
        coords = vector3(1088.6, -3187.4, -38.9),
        sets = { 'upgrade_basic', 'upgrade_full' },
    },
    club = {
        coords = vector3(-1592.3, -3012.0, -76.0),
        sets = { 'stage_props', 'vip_area' },
    },
}
Enter fullscreen mode Exit fullscreen mode

The coordinates above are placeholders; use a point inside your own interior. Keeping a list of valid set names per interior also lets you reject typos before calling the natives.

The sync problem

Here is the catch. Because entity sets are toggled on the client, running setEntitySet on one player's machine changes the room only for that player. Everyone else still sees the old version. For a lab upgrade or a club event, that breaks immersion immediately: one player sees a stage, their friend sees an empty floor.

The state needs one owner, and that owner should be the server. A clean way to do that without extra libraries is a global state bag. The server writes the desired state; every client reads it and applies it, including players who join later.

Server: owning the state

-- server.lua
local function setInteriorState(name, activeSet)
    local interior = Config.Interiors[name]
    if not interior then return false end

    local valid = false
    for _, s in ipairs(interior.sets) do
        if s == activeSet then valid = true break end
    end
    if activeSet ~= nil and not valid then return false end

    GlobalState['mlo:' .. name] = activeSet -- nil means "no optional set"
    return true
end

RegisterCommand('setinterior', function(source, args)
    local ok = setInteriorState(args[1], args[2])
    print(('[interiors] %s -> %s (%s)'):format(args[1], tostring(args[2]), ok and 'ok' or 'rejected'))
end, true) -- restricted: needs the matching ACE permission
Enter fullscreen mode Exit fullscreen mode

Passing true as the third argument to RegisterCommand makes it restricted, so only players or consoles with the ACE permission command.setinterior can run it. In a real script you would call setInteriorState from your job logic, for example when a lab upgrade is paid for, after checking the player's job server-side.

Client: applying the state

-- client.lua
local function applyInterior(name, activeSet)
    local interior = Config.Interiors[name]
    if not interior then return end

    local interiorId = GetInteriorAtCoords(interior.coords.x, interior.coords.y, interior.coords.z)
    if interiorId == 0 then return end

    for _, s in ipairs(interior.sets) do
        if s == activeSet then
            ActivateInteriorEntitySet(interiorId, s)
        else
            DeactivateInteriorEntitySet(interiorId, s)
        end
    end
    RefreshInterior(interiorId)
end

-- react to changes made while we're online
AddStateBagChangeHandler(nil, 'global', function(_, key, value)
    local name = key:match('^mlo:(.+)$')
    if name then applyInterior(name, value) end
end)

-- apply the current state on join / resource start
CreateThread(function()
    for name in pairs(Config.Interiors) do
        applyInterior(name, GlobalState['mlo:' .. name])
    end
end)
Enter fullscreen mode Exit fullscreen mode

The change handler is registered with a nil key filter and the bag name 'global', then filters keys by prefix itself. The startup thread covers players who join after the state was set.

Re-apply when the interior loads

An interior that is far away may not be loaded yet when the startup thread runs, in which case GetInteriorAtCoords returns 0 and nothing happens. A simple fix is to re-apply the state when the player gets near:

CreateThread(function()
    while true do
        local pos = GetEntityCoords(PlayerPedId())
        for name, interior in pairs(Config.Interiors) do
            if #(pos - interior.coords) < 80.0 then
                applyInterior(name, GlobalState['mlo:' .. name])
            end
        end
        Wait(5000)
    end
end)
Enter fullscreen mode Exit fullscreen mode

A five-second loop with a distance check is cheap. If you already have a zone or polyzone system, trigger applyInterior on entering the zone instead.

Practical tips

  • Test each set alone first. Toggle every set with the simple client function before wiring up sync, so you know the names are right.
  • Watch for overlapping sets. Some creators design sets as exclusive (only one at a time); others expect several active together. The example above treats them as exclusive; adjust it if yours stack.
  • Check performance. Large sets add props to the room. Look at the resource monitor and frame times with the heaviest set active.
  • Read the install notes. Some interiors need a particular resource start order or a separate map file for their sets to appear. The general steps are at fivemmlos.com/how-to-install-fivem-mlo/.

Wrapping up

Entity sets let one interior do the work of several, but only if every player sees the same version. Keep the state on the server, apply it on every client and re-apply when the interior loads. When you shop for fivem server mlos that you plan to script against, look for listings that name their entity sets clearly; it saves a lot of digging later.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.