A Teleporter component that implements a standing teleporter pad. It tracks availability, warmup/transit phases, latches riders (players) on the host, notifies clients via NZNet messages, freezes local player input and invulnerability during transit, and places riders at the destination with a spread and cooldown handling.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// A standing teleporter pad — the thing the player walks onto and presses E at.
///
/// ⛔ TAKES EVERYONE ON THE PAD, not just whoever pressed the key. That is the point of the pad
/// having a size at all: the group steps on together and travels together. A teleporter that moved
/// only the presser would split the team at the exact moment they chose to regroup.
///
/// ⚠️ ONE-WAY. Standing at B does nothing — a return trip is a second teleporter pointing back.
/// See TeleporterSpot for why.
///
/// ⛔ THE HOST RUNS IT; EVERY MACHINE PLAYS IT, AND MOVES ONLY ITS OWN PLAYER. See "the network" below.
/// </summary>
public sealed class Teleporter : Component
{
public static readonly List<Teleporter> All = new();
protected override void OnEnabled() => All.Add( this );
protected override void OnDisabled() => All.Remove( this );
/// <summary>The authored settings this pad was built from.</summary>
[Property] public TeleporterSpot Spot { get; set; }
/// <summary>Its index in the config, so messages can name it.</summary>
[Property] public int Index { get; set; }
/// <summary>
/// How far from the pad's centre the key still reaches.
///
/// ⚠️ Derived from the pad rather than a constant, so a wide pad is usable from its edge. The
/// margin is there because the player's ORIGIN is at their feet and they can stand with half
/// a boot over the lip.
/// </summary>
public float UseRange => MathF.Max( 48f, (Spot?.PadSize ?? 72f) * 0.75f );
/// <summary>What it costs per trip. 0 = free.</summary>
public int Price => Spot?.Price ?? 0;
/// <summary>
/// When this pad is usable again.
///
/// ⛔ RUNTIME STATE, AND IT LIVES HERE. The spot is authored data that survives a round; a
/// countdown written into it would be saved into the map and would come back mid-recharge on
/// the next load. Same split WunderfizzSpot documents.
/// </summary>
float _readyAt;
/// <summary>Seconds left before it can be used again, or 0 when it is ready.</summary>
public float CoolingFor => MathF.Max( 0f, _readyAt - Time.Now );
/// <summary>
/// Why this pad will not work, or "" when it will.
///
/// ⚠️ The gates that depend on the WORLD only — power and flag. Affording it is a refusal with
/// a price attached and belongs in <see cref="Use"/>.
/// </summary>
public string Unavailable( NZPlayer player )
{
if ( Spot is null ) return "";
if ( Spot.RequiresPower && !Power.IsOn )
return "Teleporter — needs power";
if ( !DoorLinks.IsOpen( Spot.Link ) )
return "Teleporter — locked";
// ⚠️ CHECKED LAST of the world gates, so an unpowered pad says "needs power" rather than
// counting down at a player who cannot use it either way.
var left = CoolingFor;
if ( left > 0f )
return $"Teleporter — recharging ({left:0}s)";
// ⛔ MID-SEQUENCE IS A REFUSAL, NOT A SECOND TRIP. Without this a second press during the
// warmup would reset _phaseEnds and charge again — the pad would never fire while anyone
// kept pressing, and every press would take their points.
if ( _phase == Phase.Warmup )
return $"Teleporter — leaving in {PhaseLeft:0.#}s";
if ( _phase == Phase.Transit )
return "Teleporter — in transit";
return "";
}
/// <summary>
/// Is this player standing on the pad?
///
/// ⚠️ A SQUARE TEST IN THE PAD'S OWN SPACE, and DELIBERATELY GENEROUS. Der Riese's pad is
/// ROUND, so this square is the one that encloses it: everyone visibly standing on the pad
/// passes, plus a little margin past its corners. Erring outward is the right direction here —
/// the player pressing E wanted to bring the team, and someone left behind by a rule they
/// cannot see reads as the teleporter being broken.
///
/// ⚠️ A radius test would be exact for the round model and WRONG for the placeholder box,
/// which really is square. One test that is slightly loose for both beats two that disagree.
///
/// ⚠️ The Z window is generous upward (a player's own height) and tight downward, so someone
/// on a walkway above the pad does not get dragged along.
/// </summary>
public bool IsOn( NZPlayer player )
{
if ( !player.IsValid() || Spot is null ) return false;
var local = Rotation.FromYaw( Spot.Yaw ).Inverse * (player.WorldPosition - Spot.A);
var half = MathF.Max( 8f, Spot.PadSize ) * 0.5f;
if ( MathF.Abs( local.x ) > half || MathF.Abs( local.y ) > half ) return false;
return local.z > -Spot.PadHeight - 8f && local.z < 80f;
}
/// <summary>Everyone currently standing on this pad.</summary>
public List<NZPlayer> Riders()
=> Scene.GetAllComponents<NZPlayer>().Where( IsOn ).ToList();
/// <summary>
/// Send everyone on the pad to B. Returns what to tell the player who pressed the key.
///
/// ⚠️ EVERY REFUSAL IS A SENTENCE, not a silent false — the same rule the perk machines and
/// the Wunderfizz are written to.
/// </summary>
public string Use( NZPlayer player )
{
if ( !player.IsValid() || Spot is null ) return "";
var blocked = Unavailable( player );
if ( !string.IsNullOrEmpty( blocked ) ) return blocked;
// ⚠️ A CLIENT'S PRESS IS STILL ON ITS WAY TO THE HOST — the pad has not been told it is warming up yet — and a second
// press inside that trip would pay twice for one ride
if ( NZGame.IsClient && _askedAt > 0f && Time.Now - _askedAt < AskGrace )
return "Teleporter — waiting for the host";
// ⛔ CHARGED ONCE, TO THE PRESSER, AND CHARGED NOW. Charging every rider would make a
// group trip cost four times as much for no stated reason, and charging nobody makes the
// price setting a lie. Upstream also takes the points at the press rather than at
// departure, so the warmup is committed the moment it starts.
var price = Price;
if ( price > 0 )
{
if ( player.Points < price )
return $"The teleporter costs {price} — you need {price - player.Points} more";
if ( !player.TrySpend( price ) )
return "Purchase failed";
}
// ⛔ THE HOST RUNS THE PAD. A client has paid — points are each player's own (`NZNet.PointsAre`), as a door's are — and
// asks; the host starts it for everyone, or gives the points back if it no longer can (`HostAsk`).
if ( NZGame.IsClient )
{
_askedAt = Time.Now;
NZNet.TeleporterAsk( Index, price );
}
else StartWarmup( player );
return $"Teleporter charging{(price > 0 ? $" — {price} spent" : "")}"
+ $" · leaves in {Spot.WarmupTime:0.#}s";
}
// ── the sequence ────────────────────────
//
// ⛔ A PHASE MACHINE, NOT A CHAIN OF TIMERS. Upstream uses `timer.Create` and then spends
// the rest of the file re-checking `IsValid(self)` inside every callback, because a pad can be
// removed while its timers are pending — and TeleporterManager.Rebuild destroys every pad on
// any config change. A phase read from OnUpdate simply stops existing with the component.
enum Phase { Idle, Warmup, Transit }
Phase _phase = Phase.Idle;
float _phaseEnds;
bool _warmupFired;
NZPlayer _presser;
/// <summary>When this machine last asked the host to run the pad — a client, until the host's warmup arrives.</summary>
float _askedAt;
/// <summary>How many ride this trip, on every machine. This machine's own riders are <see cref="_riding"/>.</summary>
int _aboard;
/// <summary>How long a client's press waits for the host's answer before another press may pay again.</summary>
const float AskGrace = 1.5f;
/// <summary>
/// How far past its end a client lets a phase run when the host's next word does not come — the host's pad rebuilt
/// mid-trip, or the message lost — before finishing it on its own, so nobody is left frozen in transit.
/// </summary>
const float LateGrace = 3f;
/// <summary>Who is mid-transit, and what their flags were before we changed them.</summary>
readonly List<Rider> _riding = new();
/// <summary>
/// One rider's prior state.
///
/// ⛔ THE PREVIOUS VALUES ARE STORED, NOT ASSUMED. Restoring `UseInputControls = true` and
/// `Invulnerable = false` blindly would hand movement back to a player something else had
/// frozen, and would strip the dev menu's godmode off anyone who rode a pad with it on.
///
/// ⚠️ AND THEIR PLACE IN THE LINE, which fans them out at B — the host's order, so every machine puts its own rider in the
/// right place and nobody lands inside anybody.
/// </summary>
readonly record struct Rider( NZPlayer Player, bool HadInput, bool WasInvulnerable, int Slot );
/// <summary>
/// EVERY BODY ON A TELEPORT, AS THE HOST SEES IT, AND UNTIL WHEN. A hit on one lands nowhere (`Health.Apply`), whoever's
/// machine drives it — *"fix it so clients also dont get hit mid teleport"* (the co-op audit, 2026-09-27). Each machine
/// makes only its own riders invulnerable (`Board`), so a zombie that reached a CLIENT's rider on the pad hit it on the
/// host: the swing sounded, and the cursed flame it carried went out. Kept on the host: a pad's riders from departure to a
/// moment after arrival (`TransitGrace`, for the owner's move to land), the blue altar's through its passage.
/// </summary>
static readonly Dictionary<GameObject, float> _transitUntil = new();
/// <summary>How long after the trip a rider stays untouchable, for their own machine's move to reach the host.</summary>
public const float TransitGrace = 0.75f;
/// <summary>Is this body on a teleport now, as the host sees it? False everywhere else.</summary>
public static bool InTransit( GameObject body )
=> body.IsValid() && _transitUntil.TryGetValue( body, out var until ) && Time.Now < until;
/// <summary>This body rides, for this long. HOST.</summary>
public static void GuardTransit( GameObject body, float seconds )
{
if ( !body.IsValid() ) return;
foreach ( var stale in _transitUntil.Where( kv => !kv.Key.IsValid() || Time.Now >= kv.Value ).Select( kv => kv.Key ).ToList() )
_transitUntil.Remove( stale );
_transitUntil[body] = Time.Now + seconds;
}
/// <summary>Is the pad mid-sequence? Blocks a second press and changes the prompt.</summary>
public bool Busy => _phase != Phase.Idle;
/// <summary>Seconds left in the current phase, or 0 when idle.</summary>
public float PhaseLeft => _phase == Phase.Idle ? 0f : MathF.Max( 0f, _phaseEnds - Time.Now );
/// <summary>How many are aboard right now, on any machine. 0 unless mid-transit.</summary>
public int Aboard => _aboard;
protected override void OnUpdate()
{
if ( Spot is null || _phase == Phase.Idle ) return;
if ( _phase == Phase.Warmup )
{
// ⚠️ AT T-1, WHICH IS UPSTREAM'S OWN OFFSET (`timer.Simple(TeleporterTime - 1, ...)`).
// The warmup cue and the departure portal are a one-second tell that it is about to
// fire, not an acknowledgement of the press — those are different things, and the
// second one is what gives a teammate time to jump on.
if ( !_warmupFired && PhaseLeft <= 1f )
{
_warmupFired = true;
NZSound.Play( NZSound.TeleporterWarmup, Spot.A );
TeleportPortal.Departure( Spot.A );
}
// ⛔ ONLY THE HOST LATCHES THE RIDERS — a client is told who rides (`ApplyDepart`). One that never hears lets the
// warmup go, rather than hold the pad at "leaving in 0s" for good.
if ( NZGame.IsHost )
{
if ( PhaseLeft <= 0f ) BeginTransit();
}
else if ( Time.Now - _phaseEnds > LateGrace )
{
Log.Warning( $"[nz-tp] #{Index}: the host never said who rides — the warmup lapses" );
_phase = Phase.Idle;
}
return;
}
// ⛔ AND ONLY THE HOST ENDS THE TRIP — a client is told (`ApplyArrive`). One that never hears arrives on its own, rather
// than leave its player frozen in transit.
if ( NZGame.IsHost )
{
if ( PhaseLeft <= 0f ) FinishTransit();
}
else if ( Time.Now - _phaseEnds > LateGrace )
{
Log.Warning( $"[nz-tp] #{Index}: the host never said the trip was over — arriving anyway" );
Arrive();
}
}
/// <summary>
/// Latch the riders and take them out of the world for the transit.
///
/// ⛔ RIDERS ARE LATCHED HERE, NOT AT THE PRESS, matching upstream — it calls
/// `ents.FindInSphere` inside `Teleport()`, which runs after the warmup. So the warmup is a
/// window to pile on, and someone who steps off before it ends does not travel.
/// </summary>
void BeginTransit()
{
var riders = Riders();
// ⚠️ The presser rides even if the stand-on test just missed them — they paid and
// pressed at this pad, so charging them and leaving them behind is the one outcome nobody
// would accept. They do NOT ride if they walked off the pad entirely.
if ( _presser.IsValid() && !riders.Contains( _presser ) && IsOn( _presser ) )
riders.Add( _presser );
var transit = MathF.Max( 0.2f, Spot.TransitTime );
var ids = new List<string>();
_riding.Clear();
foreach ( var r in riders )
{
if ( !r.IsValid() ) continue;
// every rider, this machine's or not: untouchable here until a moment after the arrival (`InTransit`)
GuardTransit( r.GameObject, transit + TransitGrace );
// ⛔ ONLY A BODY THIS MACHINE DRIVES IS FROZEN HERE. Anyone else's is simulated on their own machine, which undoes a
// freeze or a move written here within a frame — they are named in the departure, and their machine boards them.
if ( PlayerPresence.Mine( r.GameObject ) ) Board( r, ids.Count, transit );
ids.Add( NZPlayers.OwnerOf( r.GameObject ) );
}
_aboard = ids.Count;
_phase = Phase.Transit;
_phaseEnds = Time.Now + transit;
// ⚠️ IN THE ORDER THEY WERE FOUND, which is the order they fan out at B
NZNet.TeleporterDepart( Index, string.Join( "|", ids ), transit );
}
/// <summary>
/// One of this machine's own players boards: input off, mortality off, and — the one at this screen — the transit overlay.
/// Every machine, for the bodies it drives: the host's <see cref="BeginTransit"/>, a client's <see cref="ApplyDepart"/>.
/// </summary>
void Board( NZPlayer r, int slot, float transit )
{
var cc = r.Components.Get<PlayerController>();
var hp = r.Components.Get<Health>();
var hadInput = cc.IsValid() && cc.UseInputControls;
var wasInv = hp.IsValid() && hp.Invulnerable;
_riding.Add( new Rider( r, hadInput, wasInv, slot ) );
// ⛔ INPUT ONLY, NOT THE CAMERA. `UseCameraControls` stays on so a rider can still
// look around at the overlay — freezing the view as well reads as the game hanging.
if ( cc.IsValid() ) cc.UseInputControls = false;
// Upstream's `TeleportNextAllowedDamage = CurTime() + 4`, which is exactly its transit
// length. A frozen player who cannot see is not a fair target.
if ( hp.IsValid() ) hp.Invulnerable = true;
// ⚠️ THE OVERLAY IS LOCAL-ONLY, so it starts only when the local player is aboard.
// Playing it for a bystander would black out someone standing beside the pad.
if ( r == PlayerCharacters.Local() )
{
TeleportOverlayState.Begin( transit );
NZSound.Play( NZSound.TeleporterCharge, Spot.A );
}
}
/// <summary>The trip is over: this machine's own riders down at B, and every other machine told to do the same. HOST.</summary>
void FinishTransit()
{
var n = _aboard;
Arrive();
NZNet.TeleporterArrive( Index );
Log.Info( $"[nz] teleported {n} player(s) to {Spot.B}" );
}
/// <summary>
/// The trip over, on this machine: its own riders put down at B and given back to themselves, the pad heard at both ends,
/// its cooldown begun. Every machine — the host's <see cref="FinishTransit"/>, a client's <see cref="ApplyArrive"/>.
/// </summary>
void Arrive()
{
foreach ( var rider in _riding )
{
if ( !rider.Player.IsValid() ) continue;
Send( rider.Player, rider.Slot );
Restore( rider );
}
// ⚠️ TWO CUES, AT BOTH ENDS. One sound at the destination leaves the pad silent to
// anyone who did not travel, and the pad going off is exactly what the rest of the team
// needs to hear. Departure fires where the pad is, arrival where the riders land.
NZSound.Play( NZSound.TeleporterOut, Spot.A );
NZSound.Play( NZSound.TeleporterIn, Spot.B );
TeleportPortal.Arrival( Spot.B );
// ⚠️ STARTED AFTER THE TRIP COMPLETED, never at the press. A sequence that was
// interrupted — the pad removed, the config reloaded — must not leave a cooldown behind.
if ( Spot.Cooldown > 0f )
_readyAt = Time.Now + Spot.Cooldown;
_riding.Clear();
_aboard = 0;
_presser = null;
_askedAt = 0f;
_phase = Phase.Idle;
}
// ── the network ─────────────────────────
//
// ⛔ THE PAD RAN ON THE PRESSER'S MACHINE ALONE, AND NOTHING SAID SO. No message existed: in co-op nobody else saw it charge
// or heard it, and a teammate standing on it was moved only on the presser's screen — their own machine, which drives their
// body, left them where they stood. User: *"the teleporter should have networking"*.
//
// ⚠️ NOW THE HOST RUNS IT AND EVERY MACHINE PLAYS IT. The host decides — the press, who rides, when they land — and each
// machine freezes, blacks out and moves only its OWN player (`NZNet.PlaceAt`'s rule: a body can only be moved where it is
// driven). The warmup cue, both portals and the cooldown run on every machine from the host's word, and the pads are
// named by their index in the config, which every machine shares (`NZNet.ConfigLoaded`).
/// <summary>This machine's pad of that config index, or null.</summary>
public static Teleporter ByIndex( int index )
=> All.FirstOrDefault( t => t.IsValid() && t.Index == index );
/// <summary>The warmup begins, here and — told by the host — on every other machine (`NZNet.TeleporterWarmup`). HOST.</summary>
void StartWarmup( NZPlayer presser )
{
var seconds = MathF.Max( 0.2f, Spot.WarmupTime );
_phase = Phase.Warmup;
_phaseEnds = Time.Now + seconds;
_warmupFired = false;
_presser = presser;
NZNet.TeleporterWarmup( Index, seconds );
}
/// <summary>
/// A client pressed this pad and paid. HOST — `NZNet.TeleporterAsk`: the warmup for everyone, or — the pad busy, cooling,
/// locked or unpowered by the time the press arrived — the points back to the presser.
/// </summary>
public void HostAsk( string presser, int paid )
{
var body = NZPlayers.BodyOf( presser );
var blocked = body.IsValid() ? Unavailable( body ) : "Teleporter — the host has no body for that player";
if ( string.IsNullOrEmpty( blocked ) )
{
StartWarmup( body );
return;
}
// ⚠️ BY THE PRESSER'S ID, NOT THROUGH A BODY: one the host cannot find a body for is owed the points all the same
if ( paid > 0 ) NZNet.AwardPoints( presser, paid );
Log.Info( $"[nz-tp] #{Index}: a press refused — {blocked}{(paid > 0 ? $" · {paid} given back" : "")}" );
}
/// <summary>The host began the warmup. EVERY OTHER MACHINE — `NZNet.TeleporterWarmup`: it spins up here too, its cue at T-1.</summary>
public void ApplyWarmup( float seconds )
{
_phase = Phase.Warmup;
_phaseEnds = Time.Now + seconds;
_warmupFired = false;
_askedAt = 0f;
}
/// <summary>
/// The host latched who rides, in the order they fan out at B. EVERY OTHER MACHINE — `NZNet.TeleporterDepart`: the pad in
/// transit, and this machine's own player boarded if they are named.
/// </summary>
public void ApplyDepart( string riders, float transit )
{
_riding.Clear();
_phase = Phase.Transit;
_phaseEnds = Time.Now + transit;
var ids = string.IsNullOrEmpty( riders ) ? Array.Empty<string>() : riders.Split( '|' );
_aboard = ids.Length;
var me = PlayerCharacters.Local();
var myId = Connection.Local?.Id.ToString();
var slot = string.IsNullOrEmpty( myId ) ? -1 : Array.IndexOf( ids, myId );
if ( me.IsValid() && slot >= 0 ) Board( me, slot, transit );
}
/// <summary>The host says the trip is over. EVERY OTHER MACHINE — `NZNet.TeleporterArrive`.</summary>
public void ApplyArrive()
{
// ⚠️ ONCE: a machine that already arrived on its own (`LateGrace`) has nothing left to do
if ( _phase == Phase.Transit ) Arrive();
}
/// <summary>Hand one rider back their movement and their mortality.</summary>
static void Restore( Rider rider )
{
if ( !rider.Player.IsValid() ) return;
var cc = rider.Player.Components.Get<PlayerController>();
if ( cc.IsValid() ) cc.UseInputControls = rider.HadInput;
var hp = rider.Player.Components.Get<Health>();
if ( hp.IsValid() ) hp.Invulnerable = rider.WasInvulnerable;
}
/// <summary>
/// ⛔ THE FROZEN-FOREVER GUARD, AND IT IS THE WORST BUG THIS FEATURE COULD HAVE.
/// TeleporterManager.Rebuild DESTROYS every pad on any config change — placing another
/// teleporter, loading a config, starting a game. A pad destroyed mid-transit would take its
/// riders' restore with it and leave them unable to move for the rest of the round, with
/// nothing on screen to say why.
/// </summary>
protected override void OnDestroy()
{
foreach ( var rider in _riding )
Restore( rider );
if ( _riding.Count > 0 )
{
TeleportOverlayState.ClearPending();
Log.Warning( $"[nz] teleporter destroyed mid-transit — released {_riding.Count} rider(s)" );
}
_riding.Clear();
}
/// <summary>
/// Put one player down at B.
///
/// ⚠️ Moving the GameObject IS the teleport — PlayerController reads its position from the
/// transform, there is no Teleport method, and Velocity is read-only because it is derived.
/// The same note PlayerSpawner.Place carries.
///
/// ⚠️ THE VIEW IS AIMED TOO. The controller keeps its own eye angles, so rotating the object
/// alone puts you in the right place looking the wrong way.
/// </summary>
void Send( NZPlayer rider, int i )
{
if ( !rider.IsValid() ) return;
var facing = Rotation.FromYaw( Spot.Yaw );
// ⚠️ SPREAD, so a group does not arrive inside one another. Fanned left and right of B in
// the order they were found.
//
// ⛔ THE INDEX IS PASSED IN, NOT LOOKED UP. Asking Riders() again inside this method reads
// the pad AFTER the earlier riders have already left it, so every player after the first
// would score index 0 and they would all land on the same spot — the exact bug the spread
// exists to prevent. It is the host's order, carried in the departure (`Rider.Slot`).
var offset = i <= 0
? Vector3.Zero
: facing.Right * ((i % 2 == 1 ? 1 : -1) * ((i + 1) / 2) * 24f);
// ⚠️ THROUGH THE ONE DOOR EVERY PLACEMENT USES, on the machine that drives the body — it aims the view and zeroes the
// body's speed too, so a rider who boarded mid-fall does not land at fall speed (`PlayerSpawner.PlaceLocal`).
PlayerSpawner.PlaceLocal( rider.GameObject, Spot.B + offset, facing );
}
/// <summary>The pad within use range of a point, or null.</summary>
public static Teleporter Near( Vector3 pos )
{
Teleporter best = null;
float bestDist = float.MaxValue;
foreach ( var t in All )
{
if ( !t.IsValid() || t.Spot is null ) continue;
var d = pos.Distance( t.Spot.A );
if ( d > t.UseRange || d >= bestDist ) continue;
bestDist = d;
best = t;
}
return best;
}
}