World & props
Server-owned world props
Server-owned world props and lights: creation, streaming and cleanup.
On this page21 sections
- Model and collision limits
- Manifest permissions
- Architecture
- Create a prop
- Models: aliases and raw depot paths
- Model scale
- Lights
- Effects in this registry
- Streaming
- Mutation
- Attachment
- Open77.props.attach β follow a target
- Lifecycle: what happens when the target goes away
- Cost
- Open77.heldItems.hold β put an item in a hand
- What neither of these is
- Inspection and cleanup
- Finding the prop next to you
- Client-side props that never replicate
- Server API reference
- Server events
- Failure reasons
- Client API
- Catalogue
- Reference resource and terminal commands
- Persistence: the bundled resource's opt-in
- Current limitations
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
.meshdepot path. Each curated mesh uses a generated host;prop.catalogshows 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.catalogordocs/generated/prop-hosts.jsonper alias. Objects such asbarrier.hesco,container.shipping,street.hydrantandindustrial.forkliftmay 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
.enttemplates are disabled by default withtemplate_backend_disabledbecause 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:
- The player's routing bucket equals the prop's
bucket. No radius makes a prop cross buckets. - The player is within
streamingRadiusmetres β the prop streams in. - The player passes
streamingRadius + streamingHysteresismetres β 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.
trackYawhas 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.propsguarantee 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, notbase/β¦/crate.ent. If what you want to show has no item record,props.attachis the tool, not this. - The client half must be running.
open77_helditemsowns theopen77:helditem:requesthandler; without it a request times out after ten seconds rather than failing fast, exactly as a weapon request does withoutopen77_weapons. - It fights
Open77.weaponsoverWeaponRight. 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_readyrather 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:readyif 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.moveandlight.toggleupdate the saved row; a carried prop is saved once, where the carry ends. - Deletion is durable:
prop.removedeletes the prop's row,prop.clearempties 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.jsonundercollision. 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 additionalrace.route_arrowhost is intentionally visual-only. - A narrow prop can be walked around rather than through.
container.barrelblocks 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.dropuse 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.
persistentis not implemented as a field onOpen77.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
flickerfield; 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
