Props network management
How DyingStar networking works explains the whole system in plain language, with no code — start there if words like replicate, Horizon or GORC are new to you. This page is the hands-on guide for people who write props.
In plain terms: a "prop" is any object in the world — a box, a rock, a building, a vehicle. This page shows how to make one that every player sees and that stays in sync for everyone.
Generic props replicate their state through the same mechanism as the player: the game server
sends property updates to Horizon, which keeps an authoritative copy and forwards them to the
clients in range. This page also covers the player's replication to other clients — the
player is just an object of type player (with its own player_def.json); only its
input/action channel is specific (see Player network management).
Which properties are actually replicated (and how far / how often) is not decided in your GDScript: it is declared in a per-type definition file. See Replication definition files below — read it first, because a property that is not declared there will silently never reach the clients.
What is a generic prop?
A generic prop is any scene spawned in the game, except for the player scene (normal_player.tscn).
It can be a planet, a box, a building, a car...
Writing a generic prop
The recommended way is to make your prop extend GenericProp
(scenes/globals/generic_prop.gd). It already provides everything a networked, carriable
prop needs:
- the replication signals (
hs_server_prop_update/hs_server_prop_delete), - the
uuid/type_name/ position state, - server -> client replication driven by
PropNet.server_tick(self), which sends an update only when the prop's local position/rotation/parent actually changes (not every frame); a held or loaded prop does not re-announce itself — Horizon rebuilds its world position from its parent (see Carriables), - the
"carriable"group and the carry contract (interact()/set_carried()— see Carriables), - reparenting and delete-on-exit.
So a carriable prop is just:
class_name Box50cm
extends GenericProp
func _ready() -> void:
type_name = "box"
super()
Build the scene (a body + collision shape + mesh), attach the script, and declare a
<type>_def.json (see Replication definition files). Done —
no boilerplate to copy.
Set the RigidBody3D Mass on your prop's scene (Inspector). It is the prop's weight
everywhere in the game — its physics behaviour, and what a vehicle bed counts as cargo load
(see Cargo — loading the bed). A prop left at the default 1 kg feels
weightless and barely registers as cargo, so give it a realistic value. (The mining rock sets
this per-piece from its volume instead of a fixed value.)
On your prop's body, set the collision Layer to prop and the Mask to MASK_SOLID
(world + player + vehicle + prop) in the Inspector. A prop left on the default layer/mask either
falls through the floor, gets ignored by tools, or drags down the server's frame rate. If your prop
also has a purely static part (a fixed collision that never moves), put it on world with
Mask 0. See Collision layers & masks for the full table and the why.
Complex props
A prop with special behaviour may stay a standalone script (extending whatever body it needs)
and implement the contract itself, but it should still call PropNet.server_tick(self) from
its _physics_process so replication and carry-follow stay consistent. Example: the mining
rock (rock_mining.gd) is custom because it fractures, and only a fully-fractured ore
piece is carriable (it overrides interact() for that) — a whole rock is not carriable.
Positions are relative to a parent
A prop never stores its absolute world position. It stores a local position plus a
parent_id — the uuid of the object it is attached to (a planet, a city, a vehicle, the player
carrying it…). Horizon rebuilds the real world position by walking the chain
(world = parent's world + local). See the introduction
for the why; this section is what it means when you write a prop.
Three rules follow from it:
-
A child of a moving thing does not re-announce its position. When a truck drives, the box in its bed keeps the same local position, so
PropNet.server_ticksends nothing — and that is correct. Horizon moves the box because its parent moved. Re-sending the box every frame "to keep it fresh" is wrong: it floods the network for no reason (it was a real cause of the game freezing under load). -
Re-parenting is a server decision. Only the game server changes a prop's (or a player's) parent and replicates the new
parent_id; clients apply it, they never decide it. On the client, after applying a new parent to a node, callreset_physics_interpolation()on it, or it will visually slide in from its previous world position. -
Deleting a parent? Re-attach its children first. Before freeing a prop that has replicated children (a warehouse full of crates, a vehicle with cargo, a rock about to break apart), move those children up to the deleted prop's own parent. Otherwise they are left pointing at an object that no longer exists, their world position becomes wrong, and they turn into collision-less ghosts on the other clients.
PropNet.server_tick compares the prop's position/rotation/parent to the last values it sent
and emits only on a real change — and, for multi-field objects like a vehicle, only the
fields that changed. When you write your own replication, do the same: never send a full
snapshot every frame. A lost message is solved by reliable change-detection, not by re-sending
"just in case".
Carriables (carry / drop)
Any prop in the "carriable" group can be picked up. The flow is server-authoritative: the
client only aims, the server decides.
- Aiming — the client casts its interact ray (areas only) and sends the
uuidit looks at. Hands free, it shows[E] Carrywhen the target'sinteract()allows it; while holding something it shows[E] Drop. - Pick up (server) — the prop is parented to the player and marked carried (
set_carried(true)), but it stays a liveRigidBody3D: the server steers it toward a hold spot in front of the player by velocity (gravity off, rotation locked). So a carried prop now physically collides with the world — blocked by the ground, walls and vehicles, and it pushes lighter props (needed to load a container) — instead of clipping through. Itsparent_idbecomes the player so Horizon keeps its GORC global fresh, and its physics-driven transform replicates as it moves. The pickup also needs a clear line of sight: the server casts a solids ray and the prop must be the first thing hit (a wall, or the side of the bed it sits in, blocks it) — the client can't check this (it has no collisions), so it is the server's call. - Drop (server) — the prop's normal physics is restored, it is reparented back to the world, and
set_carried(false). Dropped while standing in a vehicle bed, it is loaded onto that vehicle instead (see Vehicles → Cargo bed).
interact(interactor) -> bool is the gate (default: not already carried; the mining rock also
requires a fully-fractured piece). set_carried(bool) only flips the flag. A carried prop keeps
its collision and stays solid to the world and to other players; only the carrier is excepted
(add_collision_exception_with(), removed on drop) so it is not blocked by what it holds.
Because a carried prop is driven by velocity toward the hold spot, a few behaviours fall out of the physics for free: the camera pitch raises/lowers it vertically (look up/down to place it on a higher shelf), a downward raycast keeps the hold spot above the ground (looking down to grab can't drive it underground), and it auto-drops if dragged out of grab reach (e.g. left stuck behind a wall while you back away). Collision is suppressed only during the brief travel into the hand, so nothing knocks it on the way in.
A carriable rides its carrier purely through parent_id (the player's, or a vehicle's bed). The
server replicates parent_id only when it actually changes, and the client reparents only
then. Delivering that single message reliably is Horizon's job — the prop does not re-send it
"just in case".
Update properties
Server sends update to client
The server can send updated properties to the client, for example, a LED state.
For that, you need to emit a signal on hs_server_prop_update with this code:
emit_signal(
"hs_server_prop_update",
uuid,
{
"led": true,
},
type_name,
has_parent
)
In the argument where we have led, we can put many properties. All other arguments are the same.
Client receives update
The client receives the properties updated by the server in the function client_channel_data_update.
The data argument is a dictionary where the key is the property and the value is the property value.
You can update or do what you want with the value.
Be careful, the int value sent by the server is a float when it arrives, so you need to convert it to int before use.
Delete prop
Deletion is server-authoritative: a prop is removed by freeing its node on the Godot
server — never directly by a client. When the node leaves the tree, its _exit_tree emits
hs_server_prop_delete and the rest is automatic:
- the Godot server sends a
props/delete_objectmessage to Horizon, - Horizon removes the object from GORC, so every nearby client gets a zone-exit and despawns the scene (the prop disappears for everyone),
- Horizon forwards the deletion to the persistence service, which removes the row from the database — so the prop does not respawn after a restart.
Triggering a deletion (server side)
Free the prop node on the server — e.g. the mining depot consuming a deposited rock:
func _collect_rock(rock: Node) -> void:
rock.queue_free() # _exit_tree -> hs_server_prop_delete -> GORC + database
Requesting a deletion from a client
A client never deletes a prop itself: it sends an action (the same channel as any other action, see Player network management); the server validates it and frees the node.
# client
client_send_action_to_server({"action": "delete_prop", "type": type_name, "uuid": uuid})
# server, in server_action_received(data)
"delete_prop":
var prop := _find_deletable_prop(str(data.get("uuid", "")))
if prop != null:
prop.queue_free() # held locally -> _exit_tree replicates the delete
else:
# Not held as a node by THIS server (e.g. loaded from the database elsewhere): send the
# delete message to Horizon directly so it still leaves GORC and the database.
_on_prop_delete(str(data.get("uuid", "")), str(data.get("type", "")))
What actually deletes the object is the props/delete_object message sent to Horizon. Freeing
the node via _exit_tree is just the usual trigger; a server can also send that message directly
for a prop it does not currently hold as a node.
For the deletion to reach the database, the ds_bridge persistence service must be subscribed to
plugin:genericprops:delete_object in plugins.toml. The Docker config (.docker/plugins.toml)
already is; a bare-metal plugins.toml may not be.
Replication definition files
Emitting hs_server_prop_update (props) or server_send_properties_to_client (player) is
not enough for a property to reach the clients. Horizon only replicates the properties
that are declared for that object type, in a definition file.
These files live in the Horizon plugins, one per object type:
horizonserver/ds_genericprops/props/<type>_def.json
where <type> is the prop's type_name (e.g. box, miningrock, player).
Example (box_def.json):
{
"channels": [
{
"zone": 0,
"distance": 150.0,
"frequency": 30.0,
"properties": ["position", "rotation", "opened", "parent_id"]
},
{
"zone": 6,
"distance": 153.0,
"frequency": 3.0,
"properties": ["scenename"]
}
]
}
Each entry in channels is a replication zone:
distance— clients within this radius (meters) of the object receive this zone's properties.frequency— how many times per second this zone is replicated (use a high rate for fast-changing data likeposition, a low rate for rarely-changing data).properties— the whitelist: only these property names are replicated.
A property you send that is not listed in any zone is dropped without any error. The
client simply keeps its default value (empty string, 0, ...). Symptom: the object behaves
correctly on the server but is wrong on the other clients. Whenever you add a new replicated
property, add it to the type's _def.json (and rebuild Horizon).
A client instantiates an object as soon as it receives its scenename. If scenename is in
a farther zone than position/parent_id, a client standing between the two distances
receives the scene without its placement and spawns the object at the world origin
(0,0,0). Keep scenename, position and parent_id within the same distance.
Object deletion is sent on channel 6. Every prop type's def must include a zone 6
entry (it carries scenename). Without it, deleting the object fails with
Channel 6 not defined for object <uuid>: it is removed server-side and from GORC, but the
delete never reaches the clients, so it stays on screen (a "ghost"). When you add a new
prop type, copy the zone 6 block from box_def.json / miningrock_def.json.
Spawning a prop from the game server
If the game server creates a prop at runtime (not through the normal spawn flow), registering it in Horizon is not enough: Horizon's replication is players-only and does not echo the object back to the game server, so the prop would have no server-side body (it floats / can't be interacted with until a reconnect reloads it from persistence).
Create it both in Horizon (for the other clients) and locally on the game server. The helper does both:
NetworkOrchestrator.spawn_prop_authoritative(data) # data must hold "uuid" and "type"
Moving or reparenting a prop on the Horizon side (GORC)
Most prop work is done from the game server (Godot). But if you ever change an object's position inside a Horizon plugin (a move, a drop, a reparent, or recomputing a child's world position), there is one rule that is essential — getting it wrong silently breaks replication.
In a Horizon handler, update an object's position through events.update_object_position(...),
never gorc_instances.update_object_position(...) directly.
gorc_instances.update_object_position moves the object but discards the GORC zone entry/exit
events. So a player who comes within range of the object's new position is never subscribed to
it and stops receiving its updates — the symptom is an object that "teleported" on the server but
is stale on a client (e.g. a dropped item glued to the carrier's hand). events.update_object_position
recomputes the zones and delivers the entry/exit messages, so nearby clients (re)subscribe.
For a child object (e.g. cargo riding in a vehicle bed), compute its world position as
parent_global + child_LOCAL — read the child's local position from its zone data, not its
already-global position(), otherwise you double-count the parent's position.
This is the root cause of the old "can't drop an item retrieved from a bed" bug (horizonserver
Fix reparent, ds_genericprops/src/handlers/update.rs). It is not fixable from the game
server: re-sending the data more often does nothing if the client was never zone-subscribed — this
kind of "update never reaches a client after the prop moved" is always a GORC subscription
issue, so it belongs in Horizon, not in the Godot prop.