Quick navigation

Go to

World & props

Server-owned world props

Server-owned world props and lights: creation, streaming and cleanup.

27 min read6,037 wordsMarkdown
On this page21 sections

Create server-owned props, lights and effects that stream to nearby players. The server controls identity, transform, routing bucket and lifetime. Client resources can query projections or create local-only decorations.

One registry holds three kinds. A prop, a light and a looping effect are all "a thing at a transform that streams and can be removed", so they share Open77.props and are told apart by kind. Looping effects keep their own author-facing entry point in Visual and audio effects and land in this same registry regardless.

Model and collision limits

  • Use a curated alias or raw .mesh depot path. Each curated mesh uses a generated host; prop.catalog shows the models available to the running client.
  • Create, move, remove and carry state is replicated. Late joiners receive a snapshot; out-of-range props are not projected.
  • Lights update color and enabled state without respawning. Persistent effects resume after stream-in and stop when removed.
  • Collision depends on the mesh. Of 184 generated hosts, 116 contain collision; the remaining hosts are visual only. Check prop.catalog or docs/generated/prop-hosts.json per alias. Objects such as barrier.hesco, container.shipping, street.hydrant and industrial.forklift may not block movement.
  • Detached physical hosts reject collision = false: their visible component also provides collision. Attached props use visual hosts without physics and can intersect scenery.
  • Raw .ent templates are disabled by default with template_backend_disabled because some decorative entity roots can crash the client.
  • An alias without a resolvable mesh falls back to a marker cylinder. Inspect the client catalogue after changing model data.

Manifest permissions

permissions { "world.props" }

Server mutation requires world.props. The same string on a client manifest grants the read-only projection view and the local decorative API; a client resource that omits it gets an empty Open77.props.list() and permission_denied:world.props from Open77.props.create on the client.

Architecture

  • Prop IDs are decimal strings, so their full 64-bit identity survives Lua number conversion. Store, compare and return them unchanged; never pass one through tonumber.
  • A prop is streamed only to players in the same routing bucket and inside its interest radius. Hysteresis keeps a player standing on the boundary from cycling between stream-in and stream-out.
  • A prop belongs to the resource that created it. A resource may read every prop in the registry, but may only mutate or remove the ones it created; another resource's prop answers owned_by_another_resource.
  • Stopping or reloading a resource removes everything it created, with the removal reason resource_stopped. Nothing a resource mints may outlive it, or the world accumulates props no script can ever remove again.
  • Replication rides the same JSON net-event path as ground loot, not a binary opcode. Props are created, retransformed and removed β€” they do not move every frame, and that is exactly what makes the cheap path correct for them.
  • Every prop is a real streamed engine entity with a real VRAM cost. The registry holds at most 8,192 props, of which one resource may own 2,048; both ceilings answer with quota_exceeded.

Create a prop

The short form is a model and a place to put it:

local id, reason = Open77.props.create({
    model    = "furniture.chair.metal",
    position = { x = -1440.0, y = 130.0, z = 18.0 },
    yaw      = 90.0,
})

assert(id, reason)

The full definition:

local id, reason = Open77.props.create({
    model      = "crate.small",
    position   = { x = -1448.2, y = 96.1, z = 17.5 },
    yaw        = 180.0,
    scale      = { x = 1.0, y = 1.0, z = 1.0 },
    appearance = "",
    bucket     = 0,
    physics    = "static",
    collision  = true,
    kind       = "prop",
    streamingRadius     = 120.0,
    streamingHysteresis = 30.0,
    ttlMs      = 0,
})
Field Type Default Meaning
model string required A curated alias, or a raw depot .mesh path. At most 256 bytes. An alias draws the model it names through its authored host entity; a raw .ent path is refused with template_backend_disabled (Β§Status). See Models.
position { x, y, z } required World position in metres. Non-finite values, or any axis past Β±1,000,000, give invalid_position.
yaw number 0 Heading in degrees. Pitch and roll are not exposed.
scale { x, y, z } {1,1,1} Each axis 0.01–100. Non-unit scale is honoured by the mesh back-end only; see Models.
appearance string "" Template appearance name, at most 128 bytes. Empty means the template's default.
bucket integer 0 Routing bucket. A player only ever sees props in their own bucket.
physics string "static" static, kinematic, dynamic or none. No effect: the spawned object has no entPhysicalMeshComponent. See model limits.
collision boolean true Detached hosts retain their authored collision; disabling a solid host's collision is unsupported (Β§Status). Attached hosts are always visual-only and non-blocking.
visible boolean true false keeps the registry entry and hides the object.
kind string "prop" prop, light or effect. See Lights and Effects in this registry.
light table β€” Accepted only when kind == "light"; supplying it for any other kind is rejected. See Lights.
streamingRadius number 120 Distance in metres at which the prop streams in. Range 10–2000.
streamingHysteresis number 30 Extra distance before it streams out. Range 0 to streamingRadius.
ttlMs integer 0 Lifetime in milliseconds. 0 means "until removed"; the ceiling is seven days.
persistent boolean false Not implemented as an API field. The authoritative registry has no persistence field, and a stopped resource loses every prop it created: write your own scene table and recreate them on start. What does exist is the bundled resource's opt-in β€” see Persistence.

static is the default for physics on purpose: a prop that shoves the player around is a deliberate choice, never something you get by forgetting a field.

Every bound above is enforced by the server registry, not by the caller. A value outside one is a failure with a reason, never a silently clamped prop somewhere you did not ask for.

Models: aliases and raw depot paths

model accepts a curated alias or a raw cooked depot path. This is deliberately the same split as the VFX catalogue, and it carries the same warning.

A curated alias is the supported surface. It has been reviewed, it resolves to a path that has been seen to work on this build, and it keeps resolving when the underlying path is corrected. Open77.props.catalog() returns the real list, each entry pairing an alias with the model it resolves to, and is the only list worth trusting at runtime β€” it is what the client actually hosts, where the table below is what the client was asked to host.

The catalogue is 185 aliases in 29 families, named family.thing.variant. Of those, 184 use generated spawn-safe hosts and race.checkpoint keeps its audited authored device template. Rather than reproduce all 185 here, the families and their sizes:

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, 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, kiosk frame
street.* 11 lamp posts, hydrant, parking meter, traffic light, poles, 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.* 11 forklift, pallet truck, trolleys, generator, gas pump, racks, machinery, vents
pipe.* 4 pipe runs by gauge, plus a waste pipe
electronics.* 12 monitors, vending machines, arcade, 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, 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, glass door, 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
race.* 2 the visual-only sq024 road chevron host and the authored two-sided checkpoint gate

Model scale

Catalogue dimensions come from each mesh's authored boundingBox. Some assets are much smaller than their names suggest.

sign.* direction signage is wall-mounted and small. sign.arrow.left is 8 x 0 x 10 cm β€” a flat plate with no thickness, because the whole directions_* family in the depot is interior wall signage meant to sit flush on a surface. Spawned free-standing at your feet it is essentially invisible, and it reads as "the prop did not appear". The asset is correct; place it against a wall, or use sign.rect.blank / sign.street when you want something that stands up on its own.

A prop that draws something tiny is indistinguishable from one that draws nothing. electronics.vending_machine pointed for a long time at vending_machine_device_a.mesh, which is the interactive device panel rather than the machine, and measured 31 cm. In game that is not a scale bug, it is an absence: nobody looks at 31 cm of plastic and thinks "that vending machine is too small". It was found by measuring the catalogue, not by looking at it, and no amount of spawning things and eyeballing them would have named the cause. If a prop seems missing, check its mesh extent before assuming the spawn failed.

For the exact alias strings: admin.props.catalog, or the Props tab of the admin panel, where the buttons are grouped by family. prop.catalog from the bundled open77_props resource answers client-side only β€” on a dedicated server it says so rather than printing an empty list. The source of truth is kModelAliases in client/src/api/Props.cpp, mirrored by name into resources/system/open77_admin/shared/config.lua.

Every path in that table was checked verbatim against the cooked archive listing. None of them is individually validated in game β€” every row still reads discoverable_not_individually_validated, so archive presence proves the file exists and nothing more.

A raw path β€” base\...\name.ent or base\...\name.mesh β€” is advanced and build-dependent. The file being present in the 2.31 archives does not guarantee that the object is safe, visible, correctly collided, or meaningful outside the quest or interior it was authored for. Archive presence proves discoverability, nothing else. An alias that is not in the catalogue fails with unknown_alias; a path the engine will not resolve fails with invalid_model.

The path also picks the back-end, which is why scale behaves differently between the two:

Model string Back-end Consequence
…\name.ent, or an alias resolving to one Runtime TweakDB record pointing at the cooked template You get the object the artist authored β€” materials, appearances, collision. Scale is the template's.
…\name.mesh, or an alias resolving to one An Open77-owned host entity with the mesh written onto its entMeshComponent Needs no record, so it escapes the per-session record ceiling, and it accepts a non-unit scale. Collision and material fidelity are then Open77's to supply.

Template records are provisioned lazily, memoised for the session, and do not survive a game restart β€” a known property of runtime TweakDB records. They are re-provisioned on the next session automatically. A failure to mint one returns record_provisioning_failed; a record that mints but produces no entity returns entity_spawn_failed.

Lights

A light is a prop with kind = "light". That is the whole point of the shared registry: a light streams, buckets, expires and cleans up through exactly the same path as a crate, and there is no second lifetime to get wrong.

local lamp, reason = Open77.props.create({
    kind     = "light",
    model    = "light.spot",
    position = { x = -1460.2, y = 99.9, z = 24.8 },
    yaw      = 0.0,
    bucket   = 0,
    streamingRadius = 60.0,
    light = {
        spot       = true,
        color      = { x = 1.0, y = 0.35, z = 0.1 },
        intensity  = 40.0,
        radius     = 12.0,
        innerAngle = 20.0,
        outerAngle = 45.0,
        enabled    = true,
    },
})
Light field Type Meaning
spot boolean true for a spot light, false/absent for a point light. Spot is what makes the two angles meaningful. Changing it respawns the prop, because point and spot are different light types and the render proxy is built from that.
color { x, y, z } Linear colour, each channel 0–1. The keys really are x/y/z, not r/g/b: the field goes through the shared vector reader, and an {r,g,b} table is rejected as invalid_color.
intensity number Emission strength, 0–10000.
radius number Falloff radius in metres, 0.1–500.
innerAngle, outerAngle number Cone angles in degrees, 0–180. Ignored for a point light.
enabled boolean Whether the lamp is lit. Toggling it does not respawn the prop β€” the live component is rewritten in place β€” so this is the field to use for a switch.

The light table rides to the client as opaque JSON, capped at 2 KiB. The server bounds the payload and proves it parses; it does not pretend to know the engine's field ranges, because the client owns the component-write contract. Every field above maps to a real field on REDengine's entLightComponent in 2.31 β€” none of it is simulated on Open77's side.

A light's parameters are written to the live component, and unlike a mesh those writes reach the renderer: the colour a resource asks for is the colour the frame shows. What cannot be changed after the fact is the light component itself β€” a light host is authored with its component already wired, because one appended to a compiled package is attached, reported as enabled, and emits nothing.

Supplying a light table with any kind other than "light" is rejected rather than ignored.

A light's render proxy reads its fields when it is built. Fields that cannot be patched on a live proxy make Open77.props.update respawn the entity rather than silently doing nothing β€” the prop keeps its ID, but it will visibly blink. Turning a light off without removing its registry entry is Open77.props.update(id, { visible = false }).

Effects in this registry

kind = "effect" is the registry's third discriminator: a host entity that a looping world VFX is anchored to. Authors should not reach for it directly. Use Open77.effects.create, which takes an effect name rather than a model path.

A looping effect is kept in its own server registry, shaped exactly like this one β€” same identity, ownership, revisioning and streaming β€” with its own ceilings, so filling it does not consume the room a prop needs. It lands on the client as a kind = "effect" entry, which is why it streams and cleans up through one code path here. See Visual and audio effects.

The distinction that matters, and the one worth re-reading before writing any effect code, is between client-local effects (Open77.vfx / Open77.sfx, decorative, seen by one player) and replicated effects (Open77.effects, server-authoritative, seen by everyone in range). That page covers both and keeps them apart.

Streaming

Three conditions decide whether a given player has a given prop:

  1. The player's routing bucket equals the prop's bucket. No radius makes a prop cross buckets.
  2. The player is within streamingRadius metres β€” the prop streams in.
  3. The player passes streamingRadius + streamingHysteresis metres β€” the prop streams out.

The gap between 2 and 3 is what stops a player pacing on the boundary from thrashing the streamer. Interest is recomputed against the last player snapshot the server received, and each change is sent to the players it concerns rather than broadcast to the whole bucket β€” an RP server will hold thousands of props, and a bucket-wide broadcast does not survive that.

A player who joins late, or who returns inside the radius, receives the prop in a snapshot; no resource needs to replay anything.

Mutation

Open77.props.update(id, { appearance = "worn", collision = false, visible = true })
Open77.props.setTransform(id, { position = { x = -1442.2, y = 127.4, z = 18.0 }, yaw = 45.0 })
Open77.props.setBucket(id, 12)
Open77.props.remove(id)

update patches appearance, physics, collision, visible, bucket, light, streamingRadius, streamingHysteresis and ttlMs. It is sparse: fields you do not name are left alone. Passing ttlMs = 0 clears an expiry that was already set, which is how you cancel a TTL without recreating the prop.

model and kind are immutable. Remove the prop and create a replacement to change either field; the replacement receives a new ID. Change scale with setTransform.

One of these mutations is cheap and the rest are not. Position and yaw are the one change the engine will take on a live entity, so moving a prop is a transform write with no respawn and no visible pop. Scale, and any light parameter the render proxy only reads when it is built, respawn the entity behind the same ID β€” the API does that rather than accepting the patch and quietly rendering nothing.

setBucket moves visibility scope, and it is not a removal: the next interest pass sees the mismatch and hands every viewer that lost the prop an explicit stream-out, while every player in the new bucket and inside the radius gains it.

setTransform is refused on an attached prop, with prop_attached. The follow tick would overwrite the write within 100 ms, and a mutation that silently does not take is worse than one that says why. Detach first.

Attachment

Choose between transform following and equipment-slot attachment:

You want Use What it really is
A crate carried by a player, a jerrycan riding on a car roof, a briefcase that follows an NPC Open77.props.attach The server recomputes the prop's transform from the target's, ten times a second, and replicates it like any other moving prop.
A bottle in the hand, held through the animation, that looks right in first and third person Open77.heldItems.hold A real item in a real equipment slot, given to the body through the engine's own transaction system.

The dividing line is physical. props.attach follows; heldItems.hold holds. A followed prop hovers near the body and does not swing with an animation, because nothing binds it to a bone. A held item does, because the engine owns it β€” but it must be an item record, not an arbitrary .ent, and it can only go in one of ten slots.

Open77.props.attach β€” follow a target

-- A crate carried a metre above a player, dropped if they die or disconnect.
Open77.props.attach(crateId, { kind = "player", id = source })

-- A jerrycan on the roof of a car, turning with it.
Open77.props.attach(canId, { kind = "vehicle", id = vehicleId }, {
  offset   = { x = 0.0, y = -0.4, z = 1.6 },
  yaw      = 90.0,
  trackYaw = true,
})

Open77.props.detach(crateId)
Option Default Meaning
offset { x = 0, y = 0, z = 1 } Metres in world axes, added to the target's position. Capped at 16 m.
yaw the prop's current yaw The prop's yaw while attached.
trackYaw false Add yaw to the target's heading instead of using it as an absolute.
detachOnDeath true A dead or downed target drops what it was carrying.
followBucket true The prop follows a routing-bucket change. Turn it off and a carried prop is left behind, invisible to its carrier.

kind is "player", "npc" or "vehicle". A prop cannot follow another prop: that is a chain the tick would have to order and check for cycles, and nothing needs it yet.

props.attach follows the target at an offset. It does not place the prop in a hand or bind it to the target's animation.

Two limitations are worth stating plainly:

  • The offset is in world axes, not the target's frame. Walk north with a crate offset one metre east of you and it stays one metre east however you turn. Rotating the offset needs the target's heading, and a player's position snapshot carries position and routing bucket only.
  • trackYaw has no effect on player targets. Their heading is not available to the attachment path. The prop keeps its specified yaw. Vehicle targets provide orientation and support yaw tracking.

Lifecycle: what happens when the target goes away

A detach is always possible and always happens. No attached prop is ever left following a ghost.

The target… What the prop does Reason reported
disconnects, despawns, or stops publishing a position (loading screen, streamed out) stops following and stays exactly where the last tick put it target_lost
dies or is downed same, unless the attachment set detachOnDeath = false target_down
changes routing bucket follows it there, still attached β€”
moves somewhere the world bounds reject stops following, prop keeps the last valid transform target_lost

And on this side of the attachment:

  • The prop is removed β€” the attachment goes with it. Nothing to clean up.
  • The owning resource stops or restarts β€” every prop it created is removed outright, attached or not. That is the existing world.props guarantee and it is stronger than a detach.
  • Another resource tries to attach or detach it β€” owned_by_another_resource. A resource may read every prop in the world and may only make its own follow anything.

A prop is never deleted by the follow tick. A lost carrier drops a crate; it does not evaporate one a resource paid a quota slot for.

Cost

The follow tick runs at 10 Hz, whatever the server tick rate, and writes nothing at all when the target has not moved more than a centimetre. A carried prop standing still costs zero bandwidth; a carried prop on a sprinting player costs ten upserts a second to the players who can actually see it, because it streams by its followed position like any other prop.

Open77.heldItems.hold β€” put an item in a hand

Open77.heldItems.hold(source, "Items.Preset_Nue_Default")            -- right hand
Open77.heldItems.hold(source, "Items.GenericCraftingMaterial1", { slot = "Head" })
Open77.heldItems.release(source)
Open77.heldItems.requestSnapshot(source)

Held equipment is client-owned REDengine state, so this is a relay with the same contract as Open77.clothing and Open77.weapons: the call returns a request id immediately and the verified outcome arrives later on open77:helditem:completed.

local requestId = Open77.heldItems.hold(source, "Items.Preset_Nue_Default")

AddEventHandler("open77:helditem:completed", function(player, id, operation, accepted, reason, result)
  if id ~= requestId then return end
  print(("held %s: %s %s"):format(operation, tostring(accepted), reason))
end)

The slot vocabulary is the ten equipment slots, and only those:

Head, Face, InnerChest, OuterChest, Legs, Feet, Outfit, UnderwearTop, UnderwearBottom, WeaponRight.

WeaponRight is the right hand and the default. Open77.heldItems.slots() returns the list. These are the AttachmentSlots.* TweakDB records the client compiles in client/src/game/Slot.hpp; there is no back and no hip, and there never was.

The price of being real:

  • An item record, not an entity. Items.Something, not base/…/crate.ent. If what you want to show has no item record, props.attach is the tool, not this.
  • The client half must be running. open77_helditems owns the open77:helditem:request handler; without it a request times out after ten seconds rather than failing fast, exactly as a weapon request does without open77_weapons.
  • It fights Open77.weapons over WeaponRight. Both write the same slot. Pick one per gamemode, or holster before you hand somebody a briefcase.
  • The body must be incarnated. A request against a player still on the "continue" screen is refused with player_not_ready rather than written; writing equipment to a body that is not there is the crash class the testing rules warn about.
  • Nothing persists it. A world entry rebuilds the body from the server's equipment record, which knows nothing about held items. Re-assert after open77:equipment:ready if it must survive.

What neither of these is

There is no engine parenting of a prop to a bone β€” no entHardTransformBinding written on a prop's root component. That would need a new native, a field on the projection envelope and an answer for what happens when the carrier streams out of a watcher's range mid-carry. It has been scoped twice and declined twice; the reasoning is in docs/research/props-and-object-spawning.md.

Inspection and cleanup

local prop = Open77.props.get(id)          -- canonical snapshot, or nil
local every = Open77.props.all()           -- every prop in the registry
local here  = Open77.props.all(12)         -- ... filtered to one bucket

for _, entry in ipairs(every) do
    print(entry.id, entry.kind, entry.model, entry.bucket, entry.revision)
end

for _, entry in ipairs(Open77.props.catalog()) do
    print(entry.alias, entry.model)
end

Open77.props.clear() -- only this resource's props

A snapshot carries id, revision, resource, kind, model, appearance, position, yaw, scale, bucket, physics, collision, visible, streamingRadius and streamingHysteresis; a light also carries its light table, and a prop with a TTL carries its expiry. revision is monotonic and is what a projection compares against β€” it is not a timestamp, and the counter is shared across the registry, so it orders changes rather than counting a prop's own edits.

Records may arrive with a nested position table or with flattened x/y/z depending on where you read them; the bundled resource accepts both rather than trusting one shape, and code that walks snapshots should do the same.

clear() removes everything this resource owns, returns how many went, and touches nothing else. It is not a way to tidy up after another resource, and there is deliberately no call that is.

A prop leaves the registry for exactly three reasons, and the removal carries which one: removed for an explicit call, expired when its TTL comes due, and resource_stopped when its owner stops or reloads.

Finding the prop next to you

Open77.props.list() gives you every prop this resource owns and leaves the distance maths to you. When the question is "what am I standing next to?", nearest answers it directly:

local prop, distance = Open77.props.nearest({ x = px, y = py, z = pz })
if prop then
    print(("nearest is %s, %.2f m away"):format(prop.model, distance))
end

local prop = Open77.props.nearest(position, 25)   -- widen the search to 25 m

It returns the prop snapshot and its distance in metres, or nil when nothing is in range. nil is an ordinary answer rather than a failure β€” "nothing nearby" is a normal state of the world, so there is no error string to check for it.

The radius defaults to 10 m and must be a positive number. A radius of 0 or a negative one is rejected with invalid_radius rather than treated as an unbounded search; searching everything is what list() is for, and silently widening a caller's range check would make that check a lie.

nearest sees exactly what list() sees β€” the props this resource owns, plus any it has projected. It is a convenience over the same data, not a way to discover props belonging to somebody else, and the two functions returning different sets would be a trap rather than a feature.

Ties go to whichever prop the registry lists first, so repeated calls with identical inputs give identical answers.

Client-side props that never replicate

There is no separate local table. A prop created from a client resource with Open77.props.create is already local-only: it lives on that machine, is owned by the calling resource, counts against the per-owner (256) and global (1024) quotas, and is never sent to anybody. Replication is a different verb entirely β€” project(serverId, options), which only the bundled projection resource calls when the server hands it a record.

So the client-side create is the local-only prop. An earlier draft of this API planned an Open77.props.local_.create / .remove pair as its counterpart; it was not built, because there is no non-local client create for it to be the counterpart of, and having two names for one behaviour would imply that plain create replicates.

Server API reference

Every method requires world.props.

Function Signature Result
Open77.props.create (definition) Prop ID as a decimal string, or nil, reason.
Open77.props.update (id, patch) boolean, reason?
Open77.props.setTransform (id, { position?, yaw?, scale? }) boolean, reason?
Open77.props.setBucket (id, bucket) boolean, reason?
Open77.props.attach (id, { kind, id }, { offset?, yaw?, trackYaw?, detachOnDeath?, followBucket? }) boolean, reason? β€” the prop follows the target. See Attachment.
Open77.props.detach (id) boolean, reason? β€” stops the follow and leaves the prop where it stands. Succeeds on a prop that was not attached.
Open77.props.remove (id) boolean, reason?
Open77.props.get (id) Canonical snapshot, or nil.
Open77.props.all (bucket?) Array of prop snapshots, optionally filtered to one bucket. Reading is not restricted to your own props.
Open77.props.catalog () The curated alias list: entries carrying an alias and the model it resolves to.
Open77.props.clear () Remove every prop this resource owns.

Open77.props.get returns attachment as a table β€” kind, id, offset, yaw, trackYaw, detachOnDeath, followBucket β€” or leaves the field absent when the prop follows nothing, so a plain if record.attachment then is the whole test.

Low-level aliases are CreateProp, UpdateProp, SetPropTransform, SetPropBucket, AttachProp, DetachProp, RemoveProp, GetProp, and GetProps. Prefer the namespaced wrappers: they accept structured option tables and validate them.

Server events

AddEventHandler("onPropCreated", function(id, resource, model) end)
AddEventHandler("onPropRemoved", function(id, reason, resource) end)

Both require world.props β€” the same string reading one costs, because a lifecycle event that announced a prop to a resource that cannot list one would route around that capability. reason is removed, expired, resource_stopped, or the free text (at most 64 characters) the caller passed to the remove call.

The same two transitions also reach the generic onEntityCreated(kind, id, resource) and onEntityRemoved(kind, id, reason) with kind = "prop", for a resource that additionally declares world.entities.observe. The mirror is raised by the same statement, so the two feeds cannot disagree; see entity lifecycle events for the authority rule and for why there is no Updated counterpart.

Failure reasons

Reason Meaning
permission_denied:world.props The manifest does not declare world.props.
quota_exceeded The per-resource or global prop ceiling is full. Remove something first.
not_found No prop with that ID, or it expired or was already removed.
owned_by_another_resource The prop exists but belongs to a different resource.
invalid_position Non-finite coordinates, or outside the world bounds the protocol accepts.
invalid_model The model string is malformed, or the depot path does not resolve.
unknown_alias The alias is not in Open77.props.catalog().
record_provisioning_failed A runtime TweakDB record for the template could not be minted.
entity_spawn_failed The record or host entity resolved but the engine produced no entity.
world_unavailable No world is loaded on the projecting client, or it is mid-transition.
prop_attached setTransform on a prop that is following a target. Detach first.
invalid_attachment_kind Not player, npc or vehicle. A prop cannot follow another prop.
invalid_attachment_target No target id was named, or it was zero.
invalid_attachment_offset A non-finite offset, or one further than 16 m from the target.
invalid_item_record Open77.heldItems.hold was given something that is not an item record.
invalid_slot Not one of the ten equipment slots.
player_not_ready The body is not incarnated yet, so there is no equipment system to write to.
request_timeout The client half of Open77.heldItems did not answer within ten seconds. Is open77_helditems running?

Failures are values, not errors: every mutating call returns nil, reason or false, reason rather than raising, so a caller can tell an invalid request from a temporarily unavailable subsystem without wrapping every line in pcall.

Client API

A client resource projects and reads. It has no way to mint a replicated prop, by design.

Local props are decorative: a placement ghost, a build cursor, a preview the player is dragging around. They are never replicated, never visible to anyone else, and they are charged against the calling resource's own quota so a busy gamemode cannot starve the projection of real props.

On the client, Open77.props.create is that local path β€” there is no separate local_ table. The name is the same as the server's because the two Open77 tables are different objects on different machines, exactly as they are for every other Open77 API; what differs is what the call means on each side. On the server it mints an authoritative, replicated prop. On the client it makes a decorative one that never leaves the machine.

local ghost, reason = Open77.props.create({
    model     = "crate.small",
    position  = { x = -1442.2, y = 127.4, z = 18.0 },
    yaw       = 0.0,
    collision = false,
})

assert(ghost, reason)
Open77.props.remove(ghost)
-- Your own local props, with what the renderer actually did with each.
for _, prop in ipairs(Open77.props.list()) do
    print(prop.id, prop.kind, prop.model, prop.backend, prop.rendered, prop.failed)
end

list() is scoped to the calling resource's own props. It deliberately does not include server-owned props: those belong to the session, not to any resource, so asking a resource-scoped question about them would return an empty table and read as "there are no props" rather than "wrong question". To see projected server props, use the open77_props resource's all export, which returns the records as the server sent them.

Two events are raised as the projection changes. The bundled open77_props resource raises them, and a client TriggerEvent fans out to every running client resource, so any resource can listen without depending on an export:

AddEventHandler("open77:props:added", function(id, record)
    -- record is the server prop, now projected locally.
    print("projected", id, record.model, record.kind)
end)

AddEventHandler("open77:props:removed", function(id, reason)
    -- reason names why: a server removal, an expiry, a stream-out, or
    -- "projection_failed" when the native layer refused to build it.
    print("dropped", id, reason)
end)

added fires once per prop, when its record first arrives and is projected β€” not again on every update. Both fire for server-owned props only. A local prop is yours already: you have its ID from create.

Catalogue

The generated inventory lives at docs/generated/props-2.31.csv, alongside the VFX, clothing, NPC and weapon catalogues. Its columns are category, path, kind (ent or mesh), source, game_version and runtime_status.

runtime_status starts at discoverable_not_individually_validated for every row, exactly as the VFX catalogue does, and is promoted only by a sampler that actually spawns entries and records which of them render. Archive presence is not proof of playability.

The catalogue carries identifiers and provenance only. Open77 does not redistribute game assets.

Reference resource and terminal commands

The bundled open77_props resource is the reference implementation. Its client half mirrors the registry into the native projection layer and acknowledges each projection back to the server; nothing there runs on a tick, and a prop is projected once when its record arrives rather than polled. Its server half registers the admin commands below with RegisterCommand, exactly as open77_loot does.

prop.create furniture.chair.metal -1440.0 130.0 18.0 90 0
prop.here crate.small
prop.list 0
prop.near 25
prop.remove 1
prop.clear
prop.catalog

Type these into the developer terminal opened with Β² in Cyberpunk.

Command Form Access
prop.create <model> <x> <y> <z> [yaw] [bucket] restricted β€” command.prop.create
prop.here <model> [yaw] β€” at the caller's own position and bucket restricted β€” command.prop.here
prop.list [bucket] public
prop.near [radius] β€” sorted by distance, caller's bucket only public
prop.remove <id> restricted β€” command.prop.remove
prop.clear β€” removes every prop this resource owns restricted β€” command.prop.clear
prop.catalog β€” the curated alias list public

prop.here and prop.near need an in-game caller with a fresh position snapshot: the dedicated console has no body, so they refuse rather than guessing an origin. 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.

Persistence: the bundled resource's opt-in

By default every prop dies with the server process β€” the registry is in-memory, and that is the documented contract above. The bundled open77_props resource adds an opt-in on top: a persist tunable (Warden β†’ Tuning β†’ open77_props, or tunable.set open77_props persist true from the console), off by default, which saves every prop created by its own prop.* / light.* commands to the server's database and recreates them at boot.

The contract, measured against a real server-process restart:

  • Off (the default) costs nothing: no database conversation is ever opened, one log line at boot says so, and behaviour is exactly the in-memory contract above.
  • On, every prop created by the resource's commands is written through β€” model, kind, position, yaw, bucket, and a light's full parameter table. A restart recreates them with the same transforms; prop.move and light.toggle update the saved row; a carried prop is saved once, where the carry ends.
  • Deletion is durable: prop.remove deletes the prop's row, prop.clear empties the saved scene. A removed prop does not resurrect at the next boot. Flipping the tunable off deletes nothing already saved β€” switching back on resumes where you left off.
  • A saved row that no longer restores (say its alias left the catalogue) is kept and named in the boot log, once per boot, rather than silently discarded.

Only props created through the bundled resource's commands are persisted here. Gamemodes must save and restore their own programmatic props to avoid duplicate spawns.

Persistence needs the server's database bridge (database.enabled plus OP77_DATABASE_CONNECTION); with the tunable on and no database, the resource says so once and stays inert. The table is open77_props_persisted, created on first use.

Current limitations

  • 68 of the 184 generated hosts have no collision, listed per alias in docs/generated/prop-hosts.json under collision. Their meshes ship no collision shapes, so no component choice makes them solid; they need collision authored or a substituted mesh. The other 116 stop a player β€” verified in game by walking into them, against controls drawn from the original hollow 67 that were walked straight through. The additional race.route_arrow host is intentionally visual-only.
  • A narrow prop can be walked around rather than through. container.barrel blocks head-on but a long approach drifts past it. That is geometry, not a missing collider.
  • physics = "dynamic" names a simulated prop, which is a continuously moving networked entity and therefore needs an authority-holder model and a binary opcode that the current JSON replication path does not have. Do not build on it until that lands.
  • Synchronized attachments expose player/vehicle parents, skeletal slots and local position/rotation. Attached props use visual-only hosts; they are not simulated rigid bodies. prop.pickup / prop.drop use the binding, without a server teleport timer.
  • Interaction prompts on props are not exposed. Anchor a prompt yourself through contextual interactions if you need one now.
  • persistent is not implemented as a field on Open77.props.create. A gameplay resource's props die with it; keep your own scene table and recreate them on start. Props placed through the bundled resource's commands can opt in to surviving restarts β€” see Persistence.
  • There is no flicker or animation on a light. An earlier draft of this page documented a flicker field; it never existed in the shipped reader and has been removed rather than left as a promise.
  • A light prop carries the geometry of the entity its host was cloned from, so it is not invisible. A lightless donor with a spawnable root would fix that.
  • Standalone props are placed with a yaw. Attachments additionally expose local pitch and roll.

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

// Guides Β· Lua API

Search the docs

1,905 guides and functions indexed

Try