Quick navigation

Go to

World & props

Props and effects

Placing server-owned objects, lights and replicated effects from Lua: the create definition, the curated model catalogue, the bucket and ground-height traps that make a prop invisible, lights and what respawns them, carrying, quotas and removal reasons, with the admin commands and two worked scenes.

22 min read4,803 wordsMarkdown
On this page18 sections

Create synchronized props, lights and persistent effects from server Lua. The server owns each object; nearby clients render its current state.

Nothing on this page can be done from a client resource. That is deliberate, and it is the same rule as ground loot: a client that could mint objects everyone else sees is a cheat vector, so the client half of this API only reads what the server placed and may make purely decorative things nobody else can see.

This page is for the person writing the gamemode. For the client-local Open77.vfx and Open77.sfx calls β€” effects one player sees, on their own machine β€” see Visual and audio effects.

What is in the box

You want Use
A physical object at a place in the world Open77.props.create with kind = "prop"
A light source Open77.props.create with kind = "light"
A bang, a spark, a puff of smoke, once Open77.effects.play
A fire that keeps burning until you stop it Open77.effects.create
A muzzle flash or a sound on a player, an NPC or a car Open77.effects.playOn / .sound
To try any of it without writing a line of Lua the slash commands below

Props and looping effects live in two registries with the same shape β€” same ids, same ownership, same buckets, same streaming, same cleanup. They are separate only so that filling one does not consume the room the other needs. Learn the rules once and they apply to both.

Permissions

resource "my_gamemode"
version "1.0.0"

server_script "server/main.lua"

permissions { "world.props", "world.effects" }

world.props grants Open77.props; world.effects grants Open77.effects. Declare only what you use β€” a call into a namespace you did not ask for answers permission_denied:world.props (or …:world.effects) rather than raising.

The same two strings on a client manifest mean something different and much smaller: the read-only view of what this machine is currently drawing, plus the local decorative API. They do not let a client resource place anything anybody else sees.

Your first prop

local id, reason = Open77.props.create({
    model    = "barrier.concrete",
    position = { x = -1448.2, y = 96.1, z = 17.5 },
    yaw      = 90.0,
})

assert(id, reason)

create answers with an id on success, or nil and a reason on failure. The id is a decimal string β€” a 64-bit engine number that happens to be spelled as text. Store it, compare it, pass it back unchanged, and never put it through tonumber: past 2^53 a Lua number stops being able to hold it and two different props start looking like the same one.

Everything else has a default:

Field Type Default Meaning
model string required A curated alias, or a raw .mesh depot path. See Models.
position { x, y, z } required Metres. z is the ground height; see below.
yaw number 0 Heading in degrees. Pitch and roll are not exposed.
scale number or { x, y, z } 1 A single number scales all three axes.
kind string "prop" prop, light or effect. Passing a light table implies light.
bucket integer 0 Routing bucket. See Buckets.
physics string "static" static, kinematic, dynamic, none.
collision boolean true
visible boolean true false keeps the registry entry and hides the object.
appearance string "" Template appearance name.
light table β€” Only with kind = "light". See Lights.
streamingRadius number 120 Metres at which it streams in. 10–2000.
streamingHysteresis number 30 Extra metres before it streams out. 0 to streamingRadius.
ttlMs integer 0 Lifetime in milliseconds. 0 means "until removed"; the ceiling is seven days.

Every bound is enforced by the server, not by you. A value outside one is a refusal with a reason, never a prop quietly clamped into somewhere you did not ask for.

Ground height: the mistake everyone makes first

A prop's origin is at the bottom of the model, and nothing snaps it to the floor. Whatever z you pass is where the base of the object goes. Pass the ground height at that (x, y) and the crate sits on the pavement; pass a number you guessed and it hovers, or it is buried to the lid and looks like it failed to spawn.

There is no ground query in this API and no snapToGround flag. Get your heights the way everyone does: stand where you want the object, run /pos, and paste the numbers. The /pos command copies the player's transform to the clipboard for exactly this.

That is also why prop.here exists. It uses the caller's own feet as the origin, so it cannot get the height wrong, and it is the fastest way to survey a scene: walk the route, drop a prop at each spot, then read the coordinates back out of prop.list and paste them into your Lua.

If a prop reports created and you cannot find it, check the height before anything else β€” then check the bucket.

Buckets: the prop nobody can see

bucket is the routing bucket the prop belongs to, and a player only ever sees props in the bucket they are standing in. No radius crosses a bucket.

Open77.props.create defaults bucket to 0, not to the bucket of whoever asked. If your gamemode runs a round inside bucket 7 and a prop is created without naming a bucket, that prop is created successfully, answers with an id, appears in Open77.props.all(), and is drawn for absolutely nobody. There is no error, because nothing is wrong: an empty bucket is a legal place to put an object.

So pass the bucket, every time, from the same place your gamemode gets it:

local origin = Open77.players.position(playerId)   -- { x, y, z, bucket }
Open77.props.create({ model = "crate.small", position = origin, bucket = origin.bucket })

Open77.players.position returns a table with an x, a y, a z and a bucket β€” which is why it can be passed straight in as position, and why origin.bucket is always the right answer for "the bucket this player is in". It does not carry a heading, so a yaw has to come from somewhere else.

The bundled slash commands paper over this by defaulting to the caller's own bucket, which is right for a command typed by a person standing in the world and is not what the API does. Do not learn the default from the commands.

To move an existing prop between buckets, use setBucket: everyone who loses it gets an explicit stream-out and everyone in the new bucket and in range gains it. It is a change of audience, not a removal.

Models

model takes a curated alias or a raw cooked .mesh depot path.

An alias is the supported surface. Each one resolves to a host entity built at asset-build time carrying that object's mesh, which is what makes it render reliably. There are 183, in 28 families, named family.thing.variant:

Family n What is in it
furniture.* 18 chairs, stools, benches, tables, counters, a sofa, a bed, shelving, cabinets
office.* 6 desks, office chairs, a file cabinet, a lobby reception desk
kitchen.* 5 counter, fridges, stove, coffee machine
bathroom.* 3 toilet, shower, mirror
crate.* 7 loot crates, delivery and cargo crates, cardboard, ammo box
container.* 12 barrel, locker, safe, toolbox, keg, gas tanks, jugs, bucket, freight, shipping container
barrier.* 10 blockades, hesco, jersey and pedestrian barriers, a swing gate, tire blocker
fence.* 4 railings and a wire-fence reinforcement
sign.* 6 generic plates, a direction arrow, a street sign, a homeless sign, a kiosk frame
street.* 11 lamp posts, hydrant, parking meter, traffic light, poles, junction boxes, awning, AC unit
bin.* 5 dumpsters and trash cans
garbage.* 5 bags, cardboard, street and industrial trash, a mattress
debris.* 5 rubble, corrugated sheet, construction and sand piles, rebar
pallet.* 3 cargo pallets
industrial.* 12 forklift, pallet truck, trolleys, generator, gas pump, racks, machinery, crane, vents
pipe.* 4 pipe runs by gauge, plus a waste pipe
electronics.* 12 monitors, vending machines, arcade cabinet, jukebox, server, register, fuse box, camera
light.* 9 lanterns, ceiling and fluorescent fixtures, spotlight, desk and hanging lamps, candle, disco ball
vegetation.* 5 planters, flower pots, a palm attachment
market.* 5 market stands, a kiosk, shop shelving
food.* 6 street food, cans, packaged drink and snacks, a bottle, a beer tap
medical.* 7 cart, IV stand, monitor arm, devices, container, morgue table, body bag
military.* 5 cases, weapon rack, checkpoint, security gate
door.* 4 swing gate, elevator doors, a glass door, a shuttle door
recreation.* 5 billiard and roulette tables, gym equipment, tent, sleeping bag
music.* 3 electric guitar, amplifier, piano
decor.* 4 sculptures, a painting, a vase
tool.* 3 shovel, welder, fire axe

The exact alias strings are not reproduced here, because 183 rows is a wall and the list moves. Two places always have the current one: admin.props.catalog in the terminal, or the Props tab of the admin panel, where the alias buttons are grouped by family and clicking one fills the model box without spawning anything. (prop.catalog, from the bundled open77_props resource, answers client-side only β€” see below.)

Not every alias is guaranteed to draw. An alias whose target the asset build cannot resolve to a renderable mesh gets no host entity, and the prop falls back to a plain marker cylinder β€” silently, with no error. Four aliases used to fail this way (furniture.cabinet.industrial, barrier.gate.swinging, light.lantern.japanese, industrial.forklift) and now resolve. The failure mode has not gone away, so place a prop and look at it before you build a scene around it.

No alias is individually validated in game. Every entry was checked against the cooked archive listing, which proves the file exists β€” never that the object is the right size, sits on its origin, or looks like anything useful where you put it. Place one and look at it before you commit to it.

A raw .mesh path β€” base\environment\...\thing_a.mesh β€” also works, and it is how you reach the several thousand objects nobody has curated. It is advanced and build-dependent: the file existing in the 2.31 archives proves that a path resolves, never that the object is the right size, sits on its origin, or means anything outside the interior it was authored for. Expect to try several.

A raw .ent path is refused, with template_backend_disabled. That is not squeamishness: spawning a cooked prop template through this route takes the whole game process down, so the path is closed rather than left as a way to crash every player on the server at once.

Open77.props.catalog() returns an empty table on the dedicated server. The alias list lives on the client, next to the host entities it resolves, and the server keeps no copy. The bundled prop.catalog command says so rather than printing empty β€” "empty" reads as "there are no models", which sends you looking for a catalogue that is not missing, just not on that side. The families above are the shape of it; the admin console's admin.props.catalog carries its own copy of the names and is the one to reach for from the server side.

Changing a prop that already exists

Open77.props.setTransform(id, { position = { x = -1442.2, y = 127.4, z = 18.0 }, yaw = 45.0 })
Open77.props.update(id, { visible = false })
Open77.props.setBucket(id, 12)
Open77.props.remove(id)
local howMany = Open77.props.clear()   -- everything this resource owns; returns the count

update is sparse β€” the fields you do not name are left alone β€” and it takes appearance, physics, collision, visible, bucket, light, streamingRadius, streamingHysteresis and ttlMs. Passing ttlMs = 0 clears an expiry without recreating anything.

Position, yaw and scale go through setTransform instead, because they belong together and because moving is the one change the engine will take on a live object. A move is cheap and invisible; most other changes are not. Position and yaw are written straight onto the existing entity, so a prop that travels looks like one object travelling. A change the renderer only reads when it builds the object β€” scale, or a light switching between point and spot β€” costs a rebuild: same id, but a visible blink. The API does that rather than accepting your patch and quietly rendering the old thing.

model and kind are not patchable at all. A different model is a different object; remove it and create the new one, and accept the new id.

You may read every prop in the registry, from any resource. You may only mutate or remove your own β€” somebody else's prop answers owned_by_another_resource. And you never have to clean up on shutdown: stopping or reloading a resource removes everything it created, with the reason resource_stopped. Nothing a resource mints can outlive it, because a prop no running script remembers is a prop nobody can ever remove.

Lights

A light is a prop with kind = "light" and a light table. It streams, buckets, expires and cleans up down exactly the same path as a crate.

local lamp, reason = Open77.props.create({
    kind     = "light",
    model    = "light",                                -- a label, not a lookup
    position = { x = -1460.2, y = 99.9, z = 24.8 },
    bucket   = 0,
    light = {
        color     = { x = 1.0, y = 0.82, z = 0.55 },   -- x/y/z, not r/g/b
        intensity = 60.0,
        radius    = 14.0,
        spot      = false,
        enabled   = true,
    },
})
Light field Range Default Meaning
color each channel 0–1 white Keys are x, y, z β€” red, green, blue in that order.
intensity 0–10000 10 Emission strength.
radius 0.1–500 8 Falloff radius in metres.
spot β€” false false is a point light, true a cone.
innerAngle, outerAngle 0–180 20, 45 Cone angles in degrees. Ignored by a point light.
enabled β€” true The switch.

The colour keys really are x, y, z. It reads wrong and it is not a typo: the colour goes through the same three-component vector reader as every position in the API, so it answers to the vector's field names. { r = 1.0, g = 0.82, b = 0.55 } is not rejected β€” it is read as three missing fields and you get a white lamp.

model is not used for a light. Every light is routed to the one host entity that carries a light component, because the per-object hosts carry geometry and no light. The string still has to be non-empty, so pass something readable and expect it to be ignored.

Switching a light off is cheap; changing its type is not. enabled, color, intensity, radius and the two angles are written onto the live light component: the lamp goes dark or changes colour where it stands, with no blink. Flipping spot is structural β€” a point light and a spot light are different things to the renderer β€” so it rebuilds the object.

Send the whole light table on every update, not just the field you changed. A light patch is not merged with the light the prop already has: the fields you omit come out as their defaults on the client, so an update carrying only enabled = false also resets the colour to white, the intensity to 10 and the radius to 8. Keep your own copy of the table, change one field in it, and send all of it β€” the worked example below does exactly that.

Reading it back off a snapshot will not help you here, because light arrives from Open77.props.all() as a JSON string rather than a table. Indexing it silently yields nil for every field, which is the same shape as "unset". Hold your own copy.

Reading the registry

local prop  = Open77.props.get(id)     -- one snapshot, or nil
local all   = Open77.props.all()       -- every prop in the registry
local here  = Open77.props.all(7)      -- ... in bucket 7 only

for _, entry in ipairs(all) do
    print(entry.id, entry.kind, entry.model, entry.bucket,
          entry.position.x, entry.position.y, entry.position.z)
end

A snapshot carries id, resource, kind, model, appearance, physics, yaw, bucket, revision, collision, visible, streamingRadius, streamingHysteresis, light, a nested position and scale, and x/y/z flattened alongside the nested position β€” both spellings are present, so read whichever suits you. revision is a monotonic counter shared across the registry; it orders changes and is not a timestamp.

Open77.props.nearest(position [, radius]) β€” "what am I standing next to?" β€” is a client-side call, not a server one. It answers with a snapshot and a distance in metres, or nil when nothing is in range, and it sees the same set the client's own list() sees. The radius defaults to 10 m and must be positive: a zero or negative radius is invalid_radius rather than an unbounded search. On the server, filter all(bucket) yourself β€” the bundled prop.near command is nine lines of exactly that, and its default radius is 15 m.

Effects

Open77.effects is the replicated half. Everything it creates is seen by every player in the bucket and in range, which is what separates it from Open77.vfx / Open77.sfx β€” those are client-local, they exist only in the client runtime, and only the player who ran the call perceives them.

One-shots

local ok, reason = Open77.effects.play("explosion.frag", {
    position = { x = -1448.2, y = 96.1, z = 17.5 },
    bucket   = origin.bucket,
    range    = 150.0,
    sound    = "wwise_event_name",
})

A one-shot has no registry entry and no handle: it is broadcast to the players in range and plays itself out. range is 1–500 m and defaults to 150; it decides who is told, not how loud it is, so a player outside it never learns the effect happened. orientation takes a quaternion { x, y, z, w } and defaults to identity. sound optionally fires a spatialised Wwise event at the same point.

Delivery is allowed to be unreliable β€” a bang that arrives late is worse than one that never arrives β€” so never build state on the assumption that every client played it.

Looping effects

local fire, reason = Open77.effects.create({
    effect          = "fire.small",
    position        = { x = -1460.2, y = 99.9, z = 14.8 },
    bucket          = origin.bucket,
    streamingRadius = 90.0,
    ttlMs           = 0,
})

Open77.effects.update(fire, { position = { x = -1460.2, y = 99.9, z = 15.2 } })
Open77.effects.remove(fire)

The field is effect, not name, and the lifetime is ttlMs, not duration: the registry retires a loop, the engine does not. visible defaults to true, streamingRadius to 90 (10–2000), streamingHysteresis to 20, bucket to 0 β€” the same trap as props, and the same fix.

update is sparse and takes position, orientation, bucket, visibility and the two streaming distances. The effect name is not patchable: a different name is a different VFX resource, so remove and create.

Open77.effects.all(bucket?) and .get(id) read the registry the way the prop calls do. Open77.effects.catalog(), like the prop one, answers with an empty table on the server β€” the alias list lives on the client.

The curated set is 49 aliases in 14 families, on the same family.thing.variant naming as the props: blood.* (1), electric.* (5), explosion.* (6), fire.* (5), glass.* (1), impact.* (4), laser.* (1), neon.* (2), smoke.* (6), sparks.* (4), steam.* (2), vehicle.* (6), water.* (3), weather.* (3). fx.catalog in the terminal prints the exact strings, and the admin panel's Props tab lists them grouped by family. A raw cooked .effect depot path also works, with the same build-dependent caveat as a raw mesh.

An effect needs no host entity, which is why this list is cheap to grow and the prop list is not.

Two quirks worth knowing before you reach for them. explosion.frag names the barrel blast β€” the grenade's own blast is baked into the projectile attack rather than shipped as a depot effect, so explosion.grenade is the separate indicator effect. And vehicle.skid and vehicle.skid.smoke deliberately point at the same resource; the second is kept only so older scripts keep working.

On an entity

Open77.effects.playOn(playerId, "muzzle_flash", { duration = 0.15, loop = false })
Open77.effects.sound(playerId, "event_name", { unique = true })

Both take a player id or an Open77 entity id β€” an NPC or a vehicle works as well as a player β€” and both are fire-and-forget: the target owns the lifetime, neither returns a handle, and neither counts against the looping ceiling. duration is in seconds, 0–60, where 0 leaves the lifetime to the effect itself. slot is an optional authored attachment point.

Identifier formats. World effects are addressed by depot path or curated alias. playOn takes a name the target's own template declares β€” muzzle_flash is not a file. sound takes a Wwise event name. A depot path handed to playOn will not resolve, and an authored name the template does not define usually produces no visual at all rather than an error.

Carrying a prop

The bundled prop.pickup / prop.drop commands now use synchronized attachments. Install matching client/server builds, the updated open77_props resource and its attachment-host assets.

The server replicates a binding: canonical player or vehicle ID, optional named slot, local position and rotation. Each client follows its locally rendered parent every frame, including the animated slot. This replaces the old ten-times-per-second world-position follow loop.

-- Server: the calling resource must own this prop and have world.props.
local ok, err = Open77.props.attach(propId, {
    parentType='player', parentId=playerId, bone='RightHand',
    offset={x=0,y=0,z=0}, rotation={x=0,y=0,z=0},
})
if not ok then print(err) end

Use Open77.props.bones on the client to discover the rendered parent's named slots. RightHand is an example, not a universal rig contract; calibrate the offset for your mesh. Missing parents/slots hide the prop until it can bind. Attached props use visual-only hosts and do not collide with walls. Lights, looping effects and arbitrary raw mesh paths are not attachable.

Death, disconnection, bucket changes and vehicle removal detach the prop; resource stop removes its owned props. Detach before calling setTransform. The server's drop anchor is not an authoritative animated hand position.

An attachment does not reserve a player or transfer inventory ownership. Use player interactions for consent, paired animations and cancellation, then commit item ownership in your server resource after validated completion. The guide includes a revision-safe handoff example.

To put a pose and a duration around the carried object, so that a pick-up looks like a pick-up, see Animated actions: it pairs this binding with the catalogue's carry profiles and the UI-kit progress bar, with the offset numbers for crate.small and a command to tune them live.

If you want something genuinely held as gear, that is a different API: Open77.heldItems.hold(playerId, record, options?) puts a real item in one of the ten equipment slots through the engine's own transaction system, so it behaves as equipment in first and third person. The price is that it takes an item record rather than an entity path, needs open77_helditems running on the client, competes with Open77.weapons over WeaponRight, and nothing persists it across a world entry. props.attach binds a prop; heldItems.hold equips an item. The older attach(id, { kind = , id = }, opts) spelling still works and is translated onto the binding above, so a resource written against it keeps running; kind = "npc" is refused, because there is no rendered NPC parent yet.

Quotas, lifetime and the three ways a prop dies

Registry Per resource Global
Server props 2,048 8,192
Server looping effects 512 2,048
Client-local props 256 1,024

Both ceilings answer quota_exceeded. Every prop is a real streamed engine entity with a real VRAM cost, so these are budgets rather than formalities.

A prop leaves the registry for exactly three reasons, and every removal carries which one:

Reason When
removed You called remove or clear.
expired Its ttlMs came due.
resource_stopped Its owning resource stopped or reloaded.

The registry itself has no persistence. Nothing a resource creates through Open77.props survives a server restart, and nothing survives its owning resource stopping. Keep your scene in a table and place it on start β€” the roadblock below is that pattern.

The one exception is not a registry feature: the bundled open77_props resource has an opt-in persist tunable, off by default, which saves the props its own prop.* and light.* console commands created and recreates them at boot. It deliberately does not persist a gamemode's programmatic props β€” those would double-spawn against the gamemode's own rebuild on start. See World props.

Worked example: a roadblock the resource owns

Three barriers across a street, raised and lowered by a command, cleaned up without a stop handler.

-- server/main.lua
-- permissions { "world.props" }

-- Surveyed once, in the world, with /pos. Each `z` is the ground height at
-- that point: nothing snaps a prop to the floor.
local LAYOUT = {
    { x = -1451.1, y = 102.4, z = 15.6, yaw = 0.0 },
    { x = -1448.6, y = 102.4, z = 15.6, yaw = 0.0 },
    { x = -1446.1, y = 102.4, z = 15.6, yaw = 0.0 },
}

local placed = {}

local function lower()
    local count = #placed
    for _, id in ipairs(placed) do
        Open77.props.remove(id)
    end
    placed = {}
    return count
end

local function raise(bucket)
    if #placed > 0 then return false, "already up" end

    for _, slot in ipairs(LAYOUT) do
        local id, reason = Open77.props.create({
            model    = "barrier.concrete",
            position = { x = slot.x, y = slot.y, z = slot.z },
            yaw      = slot.yaw,
            bucket   = bucket,
        })

        -- Half a roadblock is worse than none: undo and report.
        if id == nil then
            lower()
            return false, reason
        end

        placed[#placed + 1] = id
    end

    return true
end

RegisterCommand("roadblock", function(source, args)
    if args[1] == "down" then
        print(("roadblock: removed %d barriers"):format(lower()))
        return
    end

    -- The caller's bucket, not 0 -- see Buckets. A console caller has no
    -- body and therefore no bucket, so it falls back to the default one.
    local origin = nil
    if source ~= nil and source > 0 then
        origin = Open77.players.position(source)
    end

    local ok, reason = raise(origin and origin.bucket or 0)
    print(ok and "roadblock: up" or ("roadblock: " .. tostring(reason)))
end, true)

Two things this example does not need. It does not clean up on shutdown: stopping or reloading the resource removes all three barriers with the reason resource_stopped, because nothing a resource creates may outlive it. And it does not remember them across a restart, because there is no persistence to remember them with β€” LAYOUT is the memory, and raise is how it comes back.

If you want the barriers gone the instant the resource stops rather than as part of teardown, the handler is ordinary:

AddEventHandler("onResourceStop", function(name)
    if name ~= GetCurrentResourceName() then return end
    lower()
end)

Worked example: a lamp a player can switch

One command places a lamp overhead; the same command switches every lamp this resource owns. The interesting part is lamps β€” we keep our own copy of each light table, because a patch that omits a field resets it, and because reading light back off a snapshot gives a JSON string rather than a table.

-- server/main.lua
-- permissions { "world.props" }

local lamps = {}   -- [propId] = the light table we last sent

local function place(origin)
    local light = {
        color     = { x = 1.0, y = 0.82, z = 0.55 },   -- warm white; x/y/z = r/g/b
        intensity = 60.0,
        radius    = 14.0,
        spot      = false,
        enabled   = true,
    }

    local id, reason = Open77.props.create({
        kind     = "light",
        model    = "light",                            -- ignored for a light
        position = { x = origin.x, y = origin.y, z = origin.z + 2.5 },
        bucket   = origin.bucket,
        light    = light,
    })

    if id == nil then return nil, reason end
    lamps[id] = light
    return id
end

local function switch(id, on)
    local light = lamps[id]
    if light == nil then return false, "not one of ours" end

    light.enabled = on

    -- The whole table, every time. A patch carrying only `enabled` would
    -- take the client's defaults for colour, intensity and radius, and the
    -- lamp would come back white.
    return Open77.props.update(id, { light = light })
end

RegisterCommand("lamp", function(source, args)
    -- The dedicated console has no body, so it has nowhere to put a lamp.
    local origin = nil
    if source ~= nil and source > 0 then
        origin = Open77.players.position(source)
    end
    if origin == nil then
        print("lamp: this command needs an in-game caller")
        return
    end

    if args[1] == "on" or args[1] == "off" then
        local on = args[1] == "on"
        for id in pairs(lamps) do
            switch(id, on)
        end
        print(("lamp: switched %s"):format(args[1]))
        return
    end

    local id, reason = place(origin)
    print(id and ("lamp " .. id .. " placed") or ("lamp failed: " .. tostring(reason)))
end, true)

Switching does not respawn anything: enabled is written onto the live light component, so the lamp goes dark where it stands. Had the example flipped spot instead, the object would have been rebuilt and the player would have seen it blink.

Trying it from the terminal

The bundled open77_props and open77_effects resources ship admin commands. Type them into the developer terminal that Β² opens in Cyberpunk. They are the fastest way to survey a scene and to check that an alias renders before you write it into Lua.

prop.here barrier.concrete
prop.near 25
prop.move 1 -1448.6 102.4 15.6 90
prop.pickup 1
prop.drop
light.here 60 14 1 0.82 0.55
fx.here fire.small
Command Form Access
prop.create <model> <x> <y> <z> [yaw] [bucket] restricted
prop.here <model> [yaw] β€” at your feet, in your bucket restricted
prop.move <id> <x> <y> <z> [yaw] restricted
prop.remove <id> restricted
prop.clear β€” everything this resource owns restricted
prop.pickup <id> β€” within 4 m restricted
prop.drop β€” restricted
prop.list [bucket] public
prop.near [radius] β€” default 15 m, your bucket, sorted by distance public
prop.catalog β€” client-side only; from a server it points you at admin.props.catalog. See Models public
light.here [intensity] [radius] [r] [g] [b] restricted
light.create <x> <y> <z> [intensity] [radius] [r] [g] [b] restricted
light.toggle <id> <on|off> restricted
fx.play <effect> <x> <y> <z> [bucket] β€” one-shot restricted
fx.here <effect> β€” one-shot at your feet restricted
fx.loop <effect> <x> <y> <z> [bucket] restricted
fx.stop <id> restricted
fx.list [bucket] public
fx.catalog β€” client-side only; use admin.props.catalog from a server public

A restricted command is gated by the access control list as command.<name>; a public one anybody can run.

One thing to know before you trust what you see: the commands that need a position β€” prop.here, prop.near, light.here, fx.here β€” refuse from the dedicated console, which has no body to stand in.

Two rough edges this guide described on the day it was written have since been fixed, and are called out here because older notes still mention them. Every command now defaults to your bucket, fx.play and fx.loop included β€” an effect fired from inside a round no longer lands where nobody is standing. And light.toggle now reads the lamp's current settings before flipping the switch, so a lamp you tuned comes back the colour and brightness you left it. The underlying cause was worth knowing: a server snapshot hands light back as a JSON string rather than a table, and indexing a string in Lua quietly yields nil instead of raising, so the "send the whole table" precaution was sending defaults.

Listings are capped at 100 lines. These commands own only what they create β€” a gameplay resource calling the same API keeps its own props, and prop.clear will not touch them.

Client-side props, and why there is no local table

A client resource holding world.props can create props too. They are already local-only: they live on that one machine, they belong to the calling resource, they count against the client quotas of 256 per resource and 1,024 in total, and they are never sent to anybody. That is what makes them useful β€” a placement ghost, a build cursor, a preview the player drags around before the server is asked to commit it.

So if you go looking for Open77.props.local_.create, there is none, and its absence is the design. A local table would only make sense as the counterpart of a client create that replicates, and there is no such thing: a client cannot mint an object other players see, on this API or any other. Having two names for one behaviour would imply that the plain one replicates.

Replication is a different verb entirely β€” project(serverId, options) β€” and only the bundled projection resource calls it, when the server hands it a record.

Failure reasons

Failures are values. Every mutating call answers nil, reason or false, reason rather than raising, so an invalid request and a temporarily unavailable subsystem are told apart without wrapping every line in pcall.

Reason Meaning
permission_denied:world.props The manifest does not declare world.props. Likewise …:world.effects.
quota_exceeded The per-resource or global ceiling is full.
not_found No object with that id: it expired, or it was already removed.
owned_by_another_resource It exists, and it is not yours to change.
invalid_position Non-finite coordinates, or an axis past Β±1,000,000.
invalid_model The model string is empty or malformed.
unknown_alias Not in the catalogue, and not a depot path.
invalid_argument A field is the wrong type, or out of its documented range.
template_backend_disabled A raw .ent path. See Models.
record_provisioning_failed The engine record for the object could not be minted.
entity_spawn_failed The record resolved and the engine still produced no entity.
world_unavailable The projecting client has no world loaded, or is mid-transition.

See also

  • Visual and audio effects β€” the client-local half: Open77.vfx and Open77.sfx, seen by one player.
  • Drawing in the world β€” markers, anchors, nameplates: what to reach for when you want an overlay rather than an object.
  • Contextual interactions β€” a "hold E" prompt, which props do not carry on their own.
  • Loot β€” the same authority model applied to pickups, and the one this API was shaped after.
  • Writing a gamemode β€” where the bucket you keep passing comes from.

// For agents & LLMsthis page as Markdownllms.txtllms-full.txt

// Guides Β· Lua API

Search the docs

1,905 guides and functions indexed

Try