Multiplayer Elevator System
An elevator that thinks like a real one: press a floor and the cabin serves every request it passes on the way there.
Feature context
- Project context
- Obversus: Umbra
- Status
- Shipping Ready (in backend)
- Engine
- Unreal Engine 5.8
Responsibilities
- Designed and implemented the whole system in C++: the shaft, the floors, the cabin state machine, and the request scheduling.
- Built the networking: server authority over the cabin, replicated state, and smoothed following on clients.
- Built the editor tooling designers use to lay out a shaft and its floors.
Overview
An elevator that behaves like the ones in a real building. You press a floor, the button lights, the cabin comes, the doors open on their own timing, and it takes you there. Press three floors at once and it does not run back and forth: it sweeps in one direction and serves everything it passes, then turns around.
That sounds obvious until you build it. The naive version, drive to the last floor someone pressed, feels broken the moment two players use it at once, which in a multiplayer game is most of the time.
Everything here is tunable without code: floor count and heights, cabin speed, door timing, and travel feel are all designer editable, so one system serves every shaft in the game.
How designer sets up the Elevator
Three steps, all in the editor, no code:
- Place the shaft. A shaft is a straight vertical axis, not a spline: its base is the actor’s location, its up is the actor’s up vector, and it runs for a set length. Rotate the actor and the whole shaft tilts with it.
- Drop the floors. Press a button on the shaft and it drops a set number of floors up the axis at a set spacing, already wired and numbered. Each floor projects itself onto the axis to find its own height, so a designer never types a coordinate.
- Tune it. Speed, how hard it accelerates, how early it brakes, how long the doors take to open, how long they stay open. All in one data asset.
Floors register themselves against the shaft when they begin play, so the order they are placed in never matters. The shaft keeps them sorted and answers one question for the cabin: given a floor number, what height do I stop at?
// The single "where do I stop" answer the car asks for: the floor's registered height
// plus that floor's own align offset. False if no floor carries that index.
bool AElevatorShaft::GetFloorStopDistance(int32 FloorIndex, float& OutStopDistance) const;
Floor numbers are labels, not the sort order, so a designer can number a basement -1 and skip numbers without the cabin caring.
Tuning lives in a data asset
Same shape as the monorail’s config, and for the same reason: none of the numbers belong in code.
- Movement: cruise speed, the distance to reach it, the distance before a stop where braking starts, and a speed floor so the taper arrives instead of crawling.
- Shape: acceleration and braking curves, with exponents for quick tuning and a toggle that swaps them for authored curve assets.
- Smoothing: how fast a client eases toward the server’s height, and the gap at which it snaps instead.
- Doors: open time, how long they dwell, close time.
- Sound: the departure and braking one shots, the door cues, and the two loops.
Because the config is shared, retuning every elevator on a level is one asset edit.
How the speed is shaped
Speed is a function of position, not of time. The cabin ramps up over a set distance from where the leg started, tapers down over a set distance before the mark, and takes the lower of the two. Because it reads position, the motion is the same at any frame rate, and a retarget in mid leg changes only the taper:
float AElevatorCar::StepHead(float Head, float DeltaTime, float& OutSpeed) const
{
// Signed distance still to travel toward the mark.
const float Remaining = LegDirection * (TargetDistance - Head);
if (Remaining <= 1.f)
{
OutSpeed = 0.f;
return TargetDistance;
}
// Trapezoid from position: ramp up from the leg start, taper into the mark, take the lower.
const float Travelled = LegDirection * (Head - LegStartHead);
const float AccelAlpha = FMath::Clamp(Travelled / ProfileAccelDistance, 0.f, 1.f);
const float DecelAlpha = FMath::Clamp(Remaining / ProfileStartSlowingDistance, 0.f, 1.f);
const float Fraction = FMath::Min(SpeedFraction(AccelAlpha, /*bAccel*/ true), SpeedFraction(DecelAlpha, /*bAccel*/ false));
// The speed floor keeps the taper arriving instead of crawling.
OutSpeed = FMath::Max(ProfileMaxSpeed * Fraction, ProfileMinApproachSpeed);
Head += LegDirection * OutSpeed * DeltaTime;
return LegDirection * (Head - TargetDistance) > 0.f ? TargetDistance : Head;
}
Each end of the trapezoid is shaped by an exponent, or by an authored curve when the toggle is on. A curve that is not set falls back to the exponents with a logged warning, because an empty curve samples to zero and the cabin would stop in the shaft.
The core
The cabin holds a set of requested floors and a sweep direction it is currently committed to. Every time it finishes with a floor it asks the same question: is anything still requested ahead of me in the direction I am already going? If yes, take the nearest one. Only when nothing remains ahead does it flip.
That single rule is what makes it feel like a real elevator instead of a shuttle:
bool AElevatorCar::PickNextTarget()
{
int32 FloorIndex = INDEX_NONE;
float StopDistance = 0.f;
int32 Direction = SweepDirection;
// Prefer the nearest floor still ahead in the held direction: this is the no backtrack rule.
if (FindRequestedInDirection(SweepDirection, FloorIndex, StopDistance))
{
Direction = SweepDirection;
}
else if (FindRequestedInDirection(-SweepDirection, FloorIndex, StopDistance))
{
// Nothing ahead this way: flip and serve the other side.
Direction = -SweepDirection;
}
// ...
SweepDirection = Direction;
RequestedFloors.Remove(FloorIndex);
TargetFloorIndex = FloorIndex;
TargetDistance = StopDistance;
LegStartHead = HeadDistance;
return true;
}
Pressing a button is a Lyra interaction that runs as an ability, and only the server actually changes the cabin. The request itself dedups, so hammering an already lit button does nothing.
The interesting case is pressing a floor while the cabin is already moving past it. If that floor is ahead, closer than the current target, and there is still room to brake for it, the cabin retargets and puts its old target back in the queue. If there is not enough room to stop safely, it keeps going and catches that floor on the way back:
if (CarState == EElevatorCarState::Moving)
{
const float Remaining = LegDirection * (StopDistance - HeadDistance);
const float CurrentRemaining = LegDirection * (TargetDistance - HeadDistance);
// Close enough to matter, far enough to still brake for. Never reverses the sweep.
if (Remaining > ProfileStartSlowingDistance && Remaining < CurrentRemaining)
{
RequestedFloors.Add(TargetFloorIndex); // the floor we were heading to waits its turn
RequestedFloors.Remove(FloorIndex);
TargetFloorIndex = FloorIndex;
TargetDistance = StopDistance; // keep LegStartHead so the accel ramp carries through
}
return;
}
Keeping the leg’s start height untouched there matters: the cabin is already up to speed, so restarting the acceleration ramp would make it lurch.
Door safety
The cabin is a five state machine: idle, moving, doors opening, doors open, doors closing. The safety rule is structural rather than a check bolted on top. Moving is only ever entered from idle, where the doors are already shut, or from the end of the closing phase. There is no path from open to moving:
void AElevatorCar::TickState(float DeltaSeconds)
{
switch (CarState)
{
case EElevatorCarState::Idle:
if (RequestedFloors.Num() > 0)
{
BeginServingRequests();
}
return;
case EElevatorCarState::Moving:
float Speed = 0.f;
HeadDistance = StepHead(HeadDistance, DeltaSeconds, Speed);
if (HeadDistance == TargetDistance)
{
DoorTimer = Config->DoorOpenTime;
SetCarState(EElevatorCarState::DoorsOpening);
}
return;
case EElevatorCarState::DoorsClosing:
DoorTimer -= DeltaSeconds;
if (DoorTimer > 0.f)
{
return;
}
// Doors are shut: only now may the cabin move. Pick the next stop in the sweep, or park.
SetCarState(PickNextTarget() ? EElevatorCarState::Moving : EElevatorCarState::Idle);
return;
// DoorsOpening and DoorsOpen only count their timer down into the next door state.
}
}
C++ owns the timing, the Blueprint only plays the animation. The state machine fires an open or close event with the duration it is going to wait, and the timeline sets its play rate to fill exactly that. That way there is one source of truth for how long a door takes, and no chance of the code thinking the doors are shut while the visual is still moving.
The door visual is reconciled from the state rather than told what to do, and the same function runs on the server and on every client when the state replicates in. Each transition fires once, and a client that missed an intermediate state, because replication jumped it straight to a later one, still lands on the right visual:
void AElevatorCar::ApplyDoorVisual()
{
// Feed the BP the same door time C++ gates on, so the timeline play rate matches the gate exactly.
const float OpenSeconds = FMath::Max(0.01f, Config ? Config->DoorOpenTime : 1.f);
const float CloseSeconds = FMath::Max(0.01f, Config ? Config->DoorCloseTime : 1.f);
const bool bWantOpen = (CarState == EElevatorCarState::DoorsOpening || CarState == EElevatorCarState::DoorsOpen);
if (bWantOpen && !bDoorsOpenVisual)
{
bDoorsOpenVisual = true;
OpenDoors(OpenSeconds);
PlayOneShot(Config ? Config->DoorOpenSound : nullptr, DoorAudioAnchor);
}
else if (!bWantOpen && bDoorsOpenVisual)
{
bDoorsOpenVisual = false;
CloseDoors(CloseSeconds);
PlayOneShot(Config ? Config->DoorCloseSound : nullptr, DoorAudioAnchor);
}
}
Pressing the current floor while the doors are closing reopens them, the same as a real elevator. Pressing it while they are already open extends the dwell instead of queuing a second visit.
Riders in the doorway
A player standing in the doorway when the doors close would block them. A sensor box on the doorway finds the pawns inside it and walks each one in along the cabin’s inside axis, until the rider is past the sensor’s inner face by a set clearance. Depth is measured along that axis, so rotating the sensor re-aims the push:
const FVector LocalPush = PushDirection.GetSafeNormal();
const FVector SensorOrigin = DoorwaySensor->GetComponentLocation();
const FVector InsideDirection = DoorwaySensor->GetComponentQuat().RotateVector(LocalPush);
// Depth the rider must reach: the sensor's inner face along the inside axis, plus the clearance.
const FVector Extent = DoorwaySensor->GetScaledBoxExtent();
const float TargetDepth = FMath::Abs(LocalPush.X) * Extent.X + FMath::Abs(LocalPush.Y) * Extent.Y
+ FMath::Abs(LocalPush.Z) * Extent.Z + Config->DoorPushClearance;
for (AActor* Overlap : Overlaps)
{
APawn* Rider = Cast<APawn>(Overlap);
// Only the server and the pawn's own machine move it, so the server move and client prediction agree.
if (!Rider || (!HasAuthority() && !Rider->IsLocallyControlled()))
{
continue;
}
const float Depth = FVector::DotProduct(Rider->GetActorLocation() - SensorOrigin, InsideDirection);
const float Remaining = TargetDepth - Depth;
if (Remaining > 0.f)
{
Rider->AddActorWorldOffset(InsideDirection * FMath::Min(Config->DoorPushSpeed * DeltaSeconds, Remaining), /*bSweep*/ true);
}
}
The step is capped by the remaining depth, so the push stops exactly at the clearance and never overshoots into the back wall.
Multiplayer
The server owns everything that decides: the request set, the sweep direction, the door timers, the height. Clients never run the core logic.
Here the elevator deliberately parts ways with the monorail. The monorail predicts on the client, because a train is usually watched from outside and any lag in a long fast body is obvious. An elevator is almost always experienced from inside it, standing on the floor, and a prediction that corrects underneath a rider is far worse than a few frames of lag. So the client only follows:
void AElevatorCar::UpdateDisplayHead(float DeltaSeconds)
{
if (HasAuthority())
{
// Server integrates every frame with the real delta, so it is already smooth.
DisplayHead = HeadDistance;
}
else
{
// Client: never predict, only follow. The rider is based on this local cabin,
// so easing toward the replicated head is what stops them jittering against the floor.
const float Gap = HeadDistance - DisplayHead;
if (FMath::Abs(Gap) > Config->DisplaySnapDistance)
{
DisplayHead = HeadDistance; // teleport, join, or real desync
}
else
{
DisplayHead = FMath::FInterpTo(DisplayHead, HeadDistance, DeltaSeconds, Config->DisplayInterpSpeed);
}
}
PositionCabin(DisplayHead);
}
Only the height and the cabin state replicate. The door visuals, the working loop, and the travel sound are all derived from that state on each machine, so a client that misses a transition still reconciles to the right look rather than being told about it separately.
Sound
Two loops layered rather than one. A working loop runs the whole time the elevator has anything to do, so a car being called from another floor is audible before it arrives. A travel loop plays only while it is actually moving, and its volume is driven by the speed of the displayed height, which means it ramps up on departure and fades to nothing as the cabin tapers into a floor without anyone authoring a fade.
Because the volume reads the local displayed height rather than the server’s speed, it works on clients too, which never compute a speed of their own.