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.

5 min read1,007 wordsMarkdown

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_elevators reference 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.elevators plus 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