Network elevators
Server-authoritative native lifts: adopting a LiftDevice, bucket and chunk streaming, movement and pause control, late-join catch-up, and the reference resource commands.
CyberM keeps Cyberpunk 2077's native moving-platform motion, sounds, collision and floor markers, but makes the dedicated server authoritative over every registered elevator.
Status: protocol 1.7, server authority, bucket/chunk streaming, late-join catch-up, Lua APIs and the
cyberm_elevatorsreference package are implemented. Two-client runtime acceptance is still required for quest-specific elevators and landing-door variants.
Architecture
button / Lua request
|
v
server validation + deadline + revision + bucket
|
v reliable ElevatorState
client WorldQuery -> streamed LiftDevice -> native MoveTo / Pause / Unpause / TeleportTo
The server advances an elevator even when no client streams it. A client entering its interest area receives the current authoritative phase and remaining time. Old revisions are ignored.
Elevators are static world entities and are adopted, not spawned. Identity includes the routing
bucket, so one native LiftDevice can be idle at floor 0 in bucket 0 and moving to floor 4 in
bucket 42 without leaking state between instances.
Spatial interest uses 128 m chunks, streams in at 225 m and streams out at 275 m. The hysteresis prevents repeated create/remove traffic near a chunk boundary.
Manifest permissions
permissions {
"world.elevators", -- server adoption and mutation
"elevators.read", -- client streamed snapshots
"elevators.request" -- client button/call intentions
}
world.elevators is server-only. Client resources cannot call native movement, choose markers,
set deadlines or report an arrival as canonical.
Server API
Adopt a native elevator
Use the inspector in game to obtain the LiftDevice entity hash and its position:
local id, reason = CyberM.elevators.adopt({
engineEntity = "0x0123456789ABCDEF",
position = { x = -1200.0, y = 450.0, z = 20.0 },
bucket = 0,
initialFloor = 0,
floorCount = 3,
flags = CyberM.elevators.flags.powered
| CyberM.elevators.flags.interactionAllowed,
})
assert(id, reason)
REDengine hashes are opaque unsigned 64-bit values. Keep them as hexadecimal strings; never pass
them through tonumber. The dedicated server does not load REDengine, so type validation happens
when a streamed client resolves the hash. A non-lift hash remains abstract and is never projected.
The adopting resource owns the elevator until remove, resource stop or server shutdown. The same
engineEntity may be adopted once per bucket. Re-adopting the same structural definition from the
same resource is idempotent: it returns the existing ID and preserves its phase and deadline, which
makes transactional resource reloads safe. A different owner, position or floor count is rejected.
Read snapshots
local lift = CyberM.elevators.get(id)
for _, other in ipairs(CyberM.elevators.all(0)) do
print(other.id, other.engineEntity, other.phase, other.activeFloor)
end
Server snapshots contain:
id,resource,engineEntity,bucket,chunkX,chunkY,floorCount;x,y,z,phase,activeFloor,originFloor,targetFloor;travelMs,pausedRemainingMs,flags,revision.
Phases are idle, moving and paused.
Move, pause and recover
CyberM.elevators.goTo(id, 2, { travelMs = 12000 })
CyberM.elevators.call(id, 1, { travelMs = 8000 })
CyberM.elevators.pause(id)
CyberM.elevators.resume(id)
CyberM.elevators.teleport(id, 0) -- administration/recovery
goTo and call schedule the same authoritative native trip. travelMs is bounded from 100 ms
to one hour. teleport cancels the schedule and aligns all projections to one exact floor marker.
Flags
local lift = CyberM.elevators.get(id)
lift.flags = lift.flags | CyberM.elevators.flags.locked
CyberM.elevators.setFlags(id, lift.flags)
| Flag | Meaning |
|---|---|
powered |
Player requests are accepted when set. |
locked |
Player requests are denied when set. |
interactionAllowed |
Enables bounded button/call requests. |
doorsClosed |
Closes cabin doors; set automatically during normal movement. |
Authoritative arrival clears doorsClosed. More detailed roleplay access policy belongs in the
owning server resource and its ACL-restricted commands.
Server events
AddEventHandler("onElevatorStateChanged", function(
id, revision, bucket, phase, activeFloor, targetFloor
) end)
AddEventHandler("onElevatorRemoved", function(id, revision, reason) end)
Client API
The client surface is read-only except for bounded requests:
local lift = CyberM.elevators.get(id)
local streamedStates = CyberM.elevators.all()
local submitted, reason = CyberM.elevators.request(id, 1, "call")
local submitted, reason = CyberM.elevators.request(id, 2, "goto")
Client requests accept only call and goto; pause and resume are server-Lua-only mutations.
The submission result only means that the packet was queued; the later ElevatorState is the
authority decision. The server validates session, life state,
bucket, distance (18 m), flags, floor range and current phase.
Client snapshots add position, remainingMs, streamed and applied. While a lift is moving,
remainingMs is extrapolated from the most recent authoritative heartbeat, so it decreases between
packets; it is still corrected by every server resynchronization. The reference resource
publishes convenience events:
AddEventHandler("cyberm:elevator:streamedIn", function(id, snapshot) end)
AddEventHandler("cyberm:elevator:updated", function(id, snapshot) end)
AddEventHandler("cyberm:elevator:streamedOut", function(id) end)
Reference resource and commands
resources/cyberm_elevators is auto-started and is both a working package and an example for other
developers. Server resources use the native CyberM.elevators namespace directly; client-side
convenience exports are also provided. Its restricted commands use the normal CyberM ACL:
elevator.list [bucket]
elevator.adopt <entityHex> <x> <y> <z> <floorCount> [bucket] [initialFloor]
elevator.adopt.player <entityHex> <playerId> <floorCount> [initialFloor]
elevator.goto <id> <floor> [travelMs]
elevator.teleport <id> <floor>
elevator.pause <id>
elevator.resume <id>
elevator.power <id> <on|off>
elevator.lock <id> <on|off>
elevator.remove <id>
The chat receives descriptions and parameter completion after chat:ready. The client developer
console can inspect current projections with:
resource.emit cyberm:elevators:probe
resource.emit cyberm:elevators:nearby 100
nearby lists streamed native LiftDevice hashes and exact positions, including unmanaged lifts,
which makes the output directly usable with elevator.adopt.
Native behavior and late join
A trip uses the vanilla MovingPlatformMovementDynamic in time mode and the floor's native
NodeRef. This retains physical cabin motion, engine sounds and transport of actors standing in
the cabin. It is not a sequence of transform teleports.
For a late join, CyberM starts the full canonical curve, then uses the native Pause/Unpause time
cursor on the next script frame to seek to elapsed progress. A paused server state remains paused.
At authoritative arrival, every client receives idle and performs a one-shot exact marker
alignment to remove residual drift.
If REDengine unloads and later recreates the LiftDevice while the CyberM state remains in range,
the new native instance is detected, its topology is re-read, and the current state is projected
again even when the server revision did not change. Time spent waiting for native topology is
deducted before the movement cursor is positioned.
Managed LiftControllerPS.OnGoToFloor and OnCallElevator actions are intercepted before their
vanilla local mutation and converted to ElevatorRequest. Unmanaged single-player lifts keep their
normal behavior outside the CyberM interest set. CyberM maps both the streamed LiftDevice entity
and its persistent LiftControllerPS identity, because some vanilla variants use different IDs for
movement and button actions.
At departure, CyberM closes the configured cabin doors and sends LiftDepartedEvent to every floor
terminal so all landing doors lock. At authoritative arrival, the native floor event opens only the
active landing and only the front/left/right cabin sides declared by that floor's
ElevatorFloorSetup. Local save authorization cannot make two clients disagree on a managed
landing door; access has already been decided by the server request path.
Security and limits
- A client cannot provide an engine entity hash, marker, duration, curve, door state or revision.
- Invalid packet enums, floors, positions, times and flags are rejected by both codecs.
- Elevator requests are soft-limited to 8 packets per authenticated session per second.
- A well-formed request that races a stream-out, resource reload or bucket transition is denied without disconnecting the player; only malformed protocol payloads are connection violations.
- Server mutation is resource-owned and protected by
world.elevatorsplus command ACLs. - At most 2048 elevators can be adopted per server.
- Floor indexes are vanilla per-lift indexes in
0..floorCount-1; they are not universal floor IDs. - Quest lifts may have extra workspots, layers or scripted doors and need explicit runtime testing.
Implementation evidence, Ghidra handlers, known risks and the acceptance matrix are recorded in
docs/research/elevators-and-moving-platform-replication.md.
For agents and LLMs: this page as Markdown · llms.txt · llms-full.txt
