Manager code for the HexPlatforms game step "Torch Carry". It controls the cursed flame lifecycle: spawning the loose flame after the bonfire is put out, tracking host-owned carrier state, mirroring that state to clients, rules for taking/dropping/losing the flame, building the flame GameObject (visuals, lights, sound), moving it when carried, and developer console commands to inspect or force behaviors.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// BASALT — STEP 4, TORCH CARRY (named by the user, 2026-09-26). It begins when a Shrieker dies in the bonfire once Color
/// Rings is done (`HexPlatforms.Bonfire.cs`, <see cref="BonfireOut"/>): the fire goes out, the offering with it, and the
/// cursed flame is left floating over tile 1 — a sphere of purple fire, a little off the stone. Asked for as *"kill a
/// shrieker inside the bonfire, this will put it out, and remove the icon in the floor, instead leaving a floating purple
/// flame sphere slightly above the ground"*, then *"this will be the Torch Carry step — the flame can be picked up, call
/// it the cursed flame — when the player is looking at it a hud message appears to press E to pick up the cursed
/// flame"*.
///
/// ⛔ LOOKED AT, THEN E. While a player within reach looks at the loose flame, "Press E - Pick up the cursed flame" shows
/// under the crosshair (`UsePrompt.ForCursedFlame`), and E takes it (`NZPlayer.TickUse`). Both ask one question,
/// <see cref="CursedFlameAimed"/> — the prompt system's rule that the screen and the key must agree. The carrier then holds
/// it like a torch: small, in the lower right of their own view, and at their shoulder to everyone else.
///
/// ⛔ WHAT IT IS FOR: while it is carried, the shield lock's code shows on the walls near it (`HexPlatforms.Code.cs`); and
/// once the lock is open its carrier sets it on the altar inside the shield's column, where it burns for good — step 5,
/// the Altar (`HexPlatforms.Altar.cs`).
///
/// ⛔ A ZOMBIE'S HIT ON ITS CARRIER SNUFFS IT OUT UNTIL THE NEXT ROUND. Asked for as *"if a zombie hits the player carrying
/// the flame, the flame vanishes and only appears back on the next round, in the same place it spawned initially"*: it
/// goes from their hands and from everywhere, with the power-down, and the next round's start brings it back floating
/// over tile 1 (<see cref="ReturnFlame"/>). A hit is a zombie's swing that lands (`ZombieAI.DoAttackDamage`, where its
/// impact sounds) — not fire, not an explosion. ⚠️ FOR A CARRIER ON ANOTHER MACHINE THE HOST COUNTS THE SWING AS IT
/// LANDS: that machine sizes the hit itself, so a hit its own immunity window then swallows has still taken the flame.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - the PRESS is the presser's machine's. It asks the host (`NZNet.CursedFlameTake`), which knows who asked from the call
/// itself, never from anything the call says;
/// - WHO CARRIES IT, whether it is LOST and whether it is ON THE ALTAR are HOST state, MIRRORED to everyone on every
/// change (`NZNet.CursedFlameState`) and to a joiner: the carrier's connection id, which every machine can turn back into
/// their body, and two flags;
/// - a HIT is the host's to see: the zombies are the host's;
/// - the FLAME is LOCAL. Every machine builds its own, and moves it with the carrier it was told of.
///
/// ⚠️ A CARRIER WHO LEAVES THE GAME, OR IS OUT UNTIL THE NEXT ROUND, DROPS IT BACK OVER TILE 1 at once, so it is never lost
/// with nobody able to take it again. A carrier downed by anything but a zombie's hit keeps it. Where it is carried to,
/// beyond the code, is still to decide.
/// </summary>
public sealed partial class HexPlatforms
{
// ══ the start: the fire put out ═════════════════════════════════════════════════════════
/// <summary>
/// Torch Carry begins: the fire is out — a Shrieker died in it with every ring's dot home. HOST. The flames and the
/// offering go, and the cursed flame is left floating over tile 1, nobody holding it, on every machine
/// (<see cref="BuildCursedFlame"/>). The step-done clicking plays a moment after, as for the others. `who` only says in
/// the log how it came about.
/// </summary>
void PutOut( string who = "" )
{
ResetFlame();
_bonfire = BonfireOut;
SendBonfire();
if ( !Testing ) DoneCueLater();
Fanfare( 4 );
Log.Info( $"[nz-hex] ✦ THE BONFIRE IS OUT{By( who )} — a Shrieker died in it with every ring's dot home. The offering"
+ " is gone, and the cursed flame floats over tile 1: 🕯 TORCH CARRY" );
}
// ══ who carries it ══════════════════════════════════════════════════════════════════════
//
// ⚠️ NULL IS NOBODY, AS "" IS. A field new to a manager a hotload carried over starts null — its initialiser does not
// run (INSTRUCTIONS.md §1) — so every read asks `string.IsNullOrEmpty`.
/// <summary>HOST — who carries the cursed flame (<see cref="CarrierIdOf"/>), or nobody while it floats over tile 1.</summary>
string _flameCarrier;
/// <summary>MIRROR — the same, as this machine was told (`NZNet.CursedFlameState`). The flame is built and moved from it.</summary>
string _flameCarrierShown;
/// <summary>HOST — is the cursed flame snuffed out: its carrier hit by a zombie, and it gone until the next round?</summary>
bool _flameLost;
/// <summary>MIRROR — the same, as this machine was told (`NZNet.CursedFlameState`).</summary>
bool _flameLostShown;
/// <summary>HOST — who carries it, and whether it is lost, for `NZNet.PushState` to replay to a joiner.</summary>
public static string FlameCarrierState => Instance.IsValid() ? Instance._flameCarrier ?? "" : "";
public static bool FlameLostState => Instance.IsValid() && Instance._flameLost;
/// <summary>Does anyone carry it, as this machine was told?</summary>
bool FlameCarried => !string.IsNullOrEmpty( _flameCarrierShown );
/// <summary>
/// Is the cursed flame floating loose over tile 1 on this machine — the fire out, nobody holding it, not snuffed out, not
/// on the altar?
/// </summary>
bool FlameLoose => OnBasalt && _doneShown && _bonfireShown >= BonfireOut && !FlameCarried && !_flameLostShown && !_flamePlacedShown
&& !_twinOpenShown;
/// <summary>
/// Can the cursed flame be taken up on this machine: loose over tile 1, or burning light blue on the altar once its
/// defense has held — *"the flame becomes light blue and the player can pick it up again"*?
/// </summary>
bool FlameTakeable => FlameLoose
|| (OnBasalt && _doneShown && _bonfireShown >= BonfireOut && !FlameCarried && !_flameLostShown && _flamePlacedShown && FlameBlueShown
&& !_twinOpenShown);
void SendFlame() => NZNet.CursedFlameState( _flameCarrier ?? "", _flameLost, _flamePlaced );
/// <summary>
/// Who carries it, whether it is lost, and whether it is on the altar. EVERY machine — `NZNet.CursedFlameState`: the flame
/// built again, loose, held, gone or burning on the altar.
/// </summary>
public void ApplyFlameState( string carrier, bool lost, bool placed )
{
_flameCarrierShown = carrier ?? "";
_flameLostShown = lost;
_flamePlacedShown = placed;
_carrierBody = null;
DressAltar();
BuildRewardTile();
}
/// <summary>
/// The cursed flame back over tile 1, purple, nobody holding it, not lost, off the altar — and the altar's defense over,
/// its wave gone. HOST — a new game, a relit fire, a command.
/// </summary>
void ResetFlame()
{
EndDefense( Defense.None );
if ( string.IsNullOrEmpty( _flameCarrier ) && !_flameLost && !_flamePlaced ) return;
_flameCarrier = "";
_flameLost = false;
_flamePlaced = false;
SendFlame();
}
/// <summary>
/// The flame back where it rests, its carrier gone: over tile 1 — or, once the altar has held, on the altar, light blue,
/// where it turned. HOST — `WatchFlameCarrier`.
/// </summary>
void RestFlame()
{
_flameCarrier = "";
_flameLost = false;
_flamePlaced = _defense == Defense.Won;
SendFlame();
}
/// <summary>
/// A zombie's swing landed on a player. HOST — `ZombieAI.DoAttackDamage`, where its impact sounds. If they carry the
/// cursed flame, it is snuffed out (<see cref="CarrierHit"/>).
/// </summary>
public static void OnPlayerHit( GameObject victim )
{
if ( NZGame.IsClient || !OnBasalt || !victim.IsValid() ) return;
// ⛔ A CLIENT'S BODY IS JUDGED BY ITS OWN MACHINE (`NZNet.CursedFlameHitMe`), not here (the co-op audit, 2026-09-27).
// Here, a swing on somebody else's body counts as landed before their own health has had its say — the immunity
// window, Widow's Wine's web, armor that soaks it all — so a client carrier lost the flame to swings the host's own
// carrier would have shrugged off.
if ( Networking.IsActive && !PlayerPresence.Mine( victim ) ) return;
var m = Instance;
if ( !m.IsValid() || string.IsNullOrEmpty( m._flameCarrier ) ) return;
m.CarrierHit( victim.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) );
}
/// <summary>
/// The rule for a hit. HOST — apart from the hook, so the selftest can walk it. On the carrier, the flame is snuffed out:
/// gone from their hands and from everywhere, with the power-down where they stand, until the next round
/// (<see cref="ReturnFlame"/>). On anyone else, nothing. Returns whether it was lost.
/// </summary>
/// <summary>Does this machine's player carry the cursed flame, as the host last said? `NZNet.HurtPlayer`.</summary>
public static bool ICarryTheFlame
=> Instance.IsValid() && !string.IsNullOrEmpty( Instance._flameCarrierShown )
&& Instance._flameCarrierShown == (Connection.Local?.Id.ToString() ?? "");
/// <summary>
/// A zombie's swing landed on this caller, by their own machine's reckoning (`NZNet.CursedFlameHitMe`). HOST: if they
/// carry the cursed flame, it goes out — the host's own carrier's rule, `OnPlayerHit`, at the same moment of the hit.
/// </summary>
public static void HostFlameHit( string who )
{
if ( NZGame.IsClient || !OnBasalt ) return;
var m = Instance;
if ( !m.IsValid() || string.IsNullOrEmpty( m._flameCarrier ) ) return;
m.CarrierHit( CarrierBodyOf( who ) );
}
bool CarrierHit( NZPlayer body )
{
if ( !body.IsValid() || string.IsNullOrEmpty( _flameCarrier ) || CarrierIdOf( body ) != _flameCarrier ) return false;
_flameCarrier = "";
_flameLost = true;
SendFlame();
Cue( FailCue, body.WorldPosition + Vector3.Up * OtherHold.z );
Log.Info( $"[nz-hex] 🕯 THE CURSED FLAME IS SNUFFED OUT — a zombie hit {NameFor( body )}, who carried it. It is gone until"
+ ( _defense == Defense.Won ? " the next round, when it burns light blue on the altar again" : " the next round, when it floats over tile 1 again" ) );
return true;
}
/// <summary>
/// A round began: a flame a zombie's hit snuffed out — or a failed defense took — comes back where it first floated,
/// over tile 1; a light blue one, lost after the altar held, comes back on the altar, where it turned. HOST —
/// `RoundBegan`, every round.
/// </summary>
void ReturnFlame()
{
if ( !_flameLost ) return;
_flameLost = false;
_flamePlaced = _defense == Defense.Won;
SendFlame();
Log.Info( _flamePlaced ? "[nz-hex] 🕯 a new round — the light blue flame burns on the altar again"
: "[nz-hex] 🕯 a new round — the cursed flame floats over tile 1 again" );
}
/// <summary>
/// A player's id as a carrier: their connection's, as the host wrote it on their body — the one handle every machine
/// agrees on (`NZPlayers.BodyOf`) — or, on a body nobody wrote on (single player), the body's own object id.
/// </summary>
static string CarrierIdOf( NZPlayer p )
{
if ( !p.IsValid() ) return "";
var id = NZPlayers.OwnerOf( p.GameObject );
return string.IsNullOrEmpty( id ) ? p.GameObject.Id.ToString() : id;
}
/// <summary>The body a carrier id names, on this machine, or null.</summary>
static NZPlayer CarrierBodyOf( string id )
{
if ( string.IsNullOrEmpty( id ) ) return null;
var body = NZPlayers.BodyOf( id );
if ( body.IsValid() ) return body;
foreach ( var go in PlayerSpawner.AllBodies() )
if ( go.IsValid() && go.Id.ToString() == id ) return go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
return null;
}
/// <summary>LOCAL — the carrier's body, looked up once per carrier rather than every frame.</summary>
NZPlayer _carrierBody;
NZPlayer CarrierBody()
{
if ( !FlameCarried ) return null;
if ( !_carrierBody.IsValid() ) _carrierBody = CarrierBodyOf( _flameCarrierShown );
return _carrierBody;
}
/// <summary>A name for the log: the player's profile name, or "someone".</summary>
static string NameFor( NZPlayer p )
{
var name = NZPlayers.NameOf( p );
return string.IsNullOrWhiteSpace( name ) ? "someone" : name;
}
/// <summary>Can this player not take the flame — gone, down, or out until the next round? From what every machine knows of them.</summary>
static bool CannotCarry( NZPlayer p ) => !p.IsValid() || p.IsDown || p.DownedNet || p.IsOutOfRound || p.OutOfRoundNet;
/// <summary>Torch Carry, in words. HOST.</summary>
string TorchStateText() => _bonfire != BonfireOut ? "Torch Carry: not begun — the fire burns until a Shrieker dies in it"
: _twinOpen ? "the light blue flame went into the twin shield, and the shield is down"
: _flamePlaced && _defense == Defense.Won ? "the Altar held: the flame burns light blue on it, to be taken up again"
: _flamePlaced ? $"Torch Carry: done — the cursed flame burns on the altar (the Altar, step 5{( _defense == Defense.Running ? ": its defense running" : "" )})"
: _flameLost ? "Torch Carry: the cursed flame is snuffed out — a zombie hit its carrier; the next round brings it back over tile 1"
: string.IsNullOrEmpty( _flameCarrier ) ? "Torch Carry: the cursed flame floats over tile 1, nobody holding it"
: $"Torch Carry: {NameFor( CarrierBodyOf( _flameCarrier ) )} carries the cursed flame";
// ══ picking it up ═══════════════════════════════════════════════════════════════════════
static float? _flameReach;
/// <summary>How near a player's feet must be to the loose flame's middle to take it, in units: 130 — anywhere on tile 1's top.</summary>
public static float FlameReach { get => Math.Clamp( _flameReach ?? 130f, 40f, 600f ); set => _flameReach = value; }
/// <summary>
/// How much further the host allows than the presser's own machine: the host sees a remote body where it was a moment
/// ago, and a take that looked in reach to the presser must not be refused for that.
/// </summary>
const float ReachSlack = 48f;
/// <summary>How far off the flame's own sphere a look may pass and still be at it, in units.</summary>
const float AimSlack = 8f;
/// <summary>
/// Is this player looking at the loose cursed flame, near enough to take it? LOCAL — the prompt's test and the use key's,
/// one question for both (`UsePrompt.ForCursedFlame`, `NZPlayer.TickUse`). The look is this machine's camera, as the
/// power switch's is; the flame has no collider for a trace to stop on, so it is a sphere the view's ray must pass
/// through.
/// </summary>
public static bool CursedFlameAimed( NZPlayer player )
{
var m = Instance;
if ( !m.IsValid() || !m.FlameTakeable || CannotCarry( player ) ) return false;
var cam = m.Scene.Camera;
if ( !cam.IsValid() ) return false;
var centre = m._cursedGo.IsValid() ? m._cursedGo.WorldPosition : CursedFlameHome;
if ( centre.Distance( player.WorldPosition ) > FlameReach ) return false;
return RayMeetsSphere( cam.WorldPosition, cam.WorldRotation.Forward, centre, CursedFlameRadius + AimSlack );
}
/// <summary>Does a ray from here, this way (a unit direction), pass within this radius of that point, ahead of it?</summary>
static bool RayMeetsSphere( Vector3 from, Vector3 dir, Vector3 centre, float radius )
{
var along = Vector3.Dot( centre - from, dir );
return along > 0f && (from + dir * along).Distance( centre ) <= radius;
}
/// <summary>E on the cursed flame. LOCAL — the presser's machine asks the host, which decides (<see cref="HostTakeFlame"/>).</summary>
public static void TakeCursedFlame( NZPlayer player )
{
if ( player.IsValid() ) NZNet.CursedFlameTake();
}
/// <summary>
/// Someone pressed E on the cursed flame. HOST — `NZNet.CursedFlameTake`, with `who` the caller's connection id as the
/// call itself carries it, or "" for the host's own press.
/// </summary>
public static void HostTakeFlame( string who )
{
if ( NZGame.IsClient ) return;
var m = Instance;
if ( !m.IsValid() ) return;
var body = CarrierBodyOf( who );
if ( !body.IsValid() && (string.IsNullOrEmpty( who ) || who == Connection.Local?.Id.ToString()) ) body = NZPlayer.Local;
var refused = m.TakeFlame( body );
if ( refused != "" ) Log.Info( $"[nz-hex] the cursed flame stays where it is: {refused}" );
}
/// <summary>
/// The rules for taking it. HOST — apart from the RPC, so the selftest can walk it. The fire must be out, the flame not
/// snuffed out, not on the altar unless the altar has held, nobody holding it, and the taker up and within reach of it
/// (`anywhere` skips the reach, for the test and `nz_hex_torch`). Returns why not, or "".
/// </summary>
string TakeFlame( NZPlayer body, bool anywhere = false )
{
if ( _bonfire != BonfireOut ) return "the fire is not out";
if ( _twinOpen ) return "it went into the twin shield";
if ( _flamePlaced && _defense != Defense.Won ) return "it burns on the altar, and the altar is not held yet";
if ( _flameLost ) return "it is snuffed out until the next round";
if ( !string.IsNullOrEmpty( _flameCarrier ) ) return "someone carries it already";
if ( CannotCarry( body ) ) return "the one who reached for it is down, out or gone";
var from = _flamePlaced ? AltarFlameHome : CursedFlameHome;
if ( !anywhere && from.Distance( body.WorldPosition ) > FlameReach + ReachSlack ) return "they are too far from it";
// ⚠️ OFF THE ALTAR, IF IT WAS THERE: the light blue flame, taken up again
var offAltar = _flamePlaced;
_flamePlaced = false;
_flameCarrier = CarrierIdOf( body );
_carrierWatch = 0f;
SendFlame();
if ( !Testing ) NZSound.PlayShared( NZSound.PickupSalvage, from );
Log.Info( offAltar ? $"[nz-hex] 🕯 {NameFor( body )} took the light blue flame up from the altar"
: $"[nz-hex] 🕯 TORCH CARRY — {NameFor( body )} picked up the cursed flame" );
return "";
}
/// <summary>HOST — when the carrier was last looked for.</summary>
TimeSince _carrierWatch;
/// <summary>
/// HOST — from `OnUpdate`, twice a second: a carrier who has left the game, or is out until the next round, drops the
/// cursed flame back over tile 1.
/// </summary>
void WatchFlameCarrier()
{
if ( string.IsNullOrEmpty( _flameCarrier ) || _carrierWatch < 0.5f ) return;
_carrierWatch = 0f;
var body = CarrierBodyOf( _flameCarrier );
if ( body.IsValid() && !body.IsOutOfRound && !body.OutOfRoundNet ) return;
Log.Info( "[nz-hex] 🕯 the cursed flame's carrier "
+ ( body.IsValid() ? $"{NameFor( body )} is out until the next round" : "has left the game" )
+ ( _defense == Defense.Won ? " — it burns light blue on the altar again" : " — it floats over tile 1 again" ) );
RestFlame();
}
// ══ the cursed flame ════════════════════════════════════════════════════════════════════
//
// ⚠️ THE NAPALM'S FLAME AGAIN, IN PURPLE, ROUND A SPHERE. Clones of `napalm_flame.prefab` stand on a sphere's surface and
// burn upwards, so together they read as a ball of fire. Each plays `purple_flame.sprite` — the napalm frames recoloured
// by `Tools/basalt_purple_flame.py`, since those are orange in themselves and a tint only multiplies them — set while
// the clone is disabled, then enabled, the order `LavaFog` and `ColourTracer` use. At its heart a soft purple flare (the
// powerups' glow), round it a purple light, a low crackle. Loose, it rises and falls a little over tile 1, to float;
// held, it is a smaller copy of itself, carried like a torch.
const string CursedFlameSprite = "sprites/nz/purple_flame.sprite";
/// <summary>
/// The flame's frames once the altar has held: the same napalm frames recoloured light blue (`Tools/basalt_purple_flame.py
/// --blue`), for the same reason — a tint only multiplies orange.
/// </summary>
const string BlueFlameSprite = "sprites/nz/blue_flame.sprite";
/// <summary>The purple its light and its flare share: the purple its frames burn in.</summary>
static Color CursedPurple => new( 0.62f, 0.24f, 1f );
static float? _cursedRadius, _cursedHeight, _cursedScale, _cursedBob, _cursedLight;
static int? _cursedFlames;
/// <summary>The loose flame's sphere radius, where its flames stand, in units: 22.</summary>
public static float CursedFlameRadius { get => Math.Clamp( _cursedRadius ?? 22f, 4f, 120f ); set => _cursedRadius = value; }
/// <summary>
/// How high its middle floats over tile 1's top, in units: 60, so its lowest flames start about 38u up and their
/// underside is some 20u off the stone — "slightly above the ground".
/// </summary>
public static float CursedFlameHeight { get => Math.Clamp( _cursedHeight ?? 60f, 0f, 400f ); set => _cursedHeight = value; }
/// <summary>How many flames make the loose sphere: 16.</summary>
public static int CursedFlames { get => Math.Clamp( _cursedFlames ?? 16, 1, 48 ); set => _cursedFlames = value; }
/// <summary>How big each of its flames is against one on a burning body: 1.2 (the bonfire's are 2.5).</summary>
public static float CursedFlameScale { get => Math.Clamp( _cursedScale ?? 1.2f, 0.1f, 10f ); set => _cursedScale = value; }
/// <summary>How far it rises and falls as it floats, in units, once every <see cref="CursedFlameBobPeriod"/> seconds.</summary>
public static float CursedFlameBob { get => Math.Clamp( _cursedBob ?? 4f, 0f, 60f ); set => _cursedBob = value; }
const float CursedFlameBobPeriod = 3.2f;
/// <summary>How bright its purple light is; it reaches <see cref="CursedFlameLightRadius"/>.</summary>
public static float CursedFlameLight { get => Math.Clamp( _cursedLight ?? 2.5f, 0f, 20f ); set => _cursedLight = value; }
const float CursedFlameLightRadius = 320f;
static float? _heldRadius, _heldScale, _holdForward, _holdRight, _holdDown;
/// <summary>
/// The flame held as a torch: a sphere of 6u radius, its flames half a burning body's — small enough to carry in view
/// without blinding the carrier — with fewer flames (<see cref="HeldFlames"/>) and a dimmer light (<see cref="HeldLight"/>).
/// </summary>
public static float HeldRadius { get => Math.Clamp( _heldRadius ?? 6f, 1f, 60f ); set => _heldRadius = value; }
/// <summary>How big each held flame is against one on a burning body: 0.5.</summary>
public static float HeldScale { get => Math.Clamp( _heldScale ?? 0.5f, 0.05f, 10f ); set => _heldScale = value; }
const int HeldFlames = 10;
const float HeldLight = 1.4f;
/// <summary>
/// Where the carrier sees it in their own view, in units from the camera: 32 ahead, 17 to the right, 15 down — the lower
/// right, where a torch is held. Everyone else sees it at the carrier's shoulder (<see cref="OtherHold"/>).
/// </summary>
static Vector3 OwnHold => new( Math.Clamp( _holdForward ?? 32f, 4f, 200f ), -Math.Clamp( _holdRight ?? 17f, -100f, 100f ),
-Math.Clamp( _holdDown ?? 15f, -100f, 100f ) );
/// <summary>Where everyone else sees it: 16 ahead of the carrier's feet, 16 to their right, 62 up — at the shoulder, turned with them.</summary>
static Vector3 OtherHold => new( 16f, -16f, 62f );
/// <summary>LOCAL — the cursed flame, and what it was built as: held, on the altar, or loose over tile 1.</summary>
GameObject _cursedGo;
(bool Held, bool OnAltar, bool Blue, float Radius, int Flames, float Scale, float Light) _cursedBuilt;
/// <summary>LOCAL — whether this manager has swept up any flame from before a hotload (<see cref="SweepCursedFlames"/>).</summary>
bool _cursedSwept;
/// <summary>Say once that the purple frames did not load, not at every rebuild.</summary>
static bool _cursedSpriteMissing;
/// <summary>Where the loose flame's middle floats from: over the middle of tile 1, <see cref="CursedFlameHeight"/> up.</summary>
static Vector3 CursedFlameHome => new( RewardTile.X, RewardTile.Y, RewardTile.Top + CursedFlameHeight );
/// <summary>Where the flame floats from when nobody holds it: over tile 1, or over the altar once it is set there.</summary>
Vector3 FlameHome => _cursedBuilt.OnAltar ? AltarFlameHome : CursedFlameHome;
/// <summary>
/// The cursed flame as this machine was told of it: floating over tile 1, held by its carrier, burning on the altar, or
/// none — not begun, or snuffed out. LOCAL.
/// </summary>
void BuildCursedFlame()
{
// ⚠️ AND NONE ONCE THE TWIN SHIELD HAS TAKEN IT: the flame is spent (`HexPlatforms.Twin.cs`)
var want = OnBasalt && _doneShown && _bonfireShown >= BonfireOut && !_flameLostShown && !_twinOpenShown;
if ( !want ) { ClearCursedFlame(); return; }
var blue = FlameBlueShown;
var look = FlameCarried
? (Held: true, OnAltar: false, Blue: blue, Radius: HeldRadius, Flames: HeldFlames, Scale: HeldScale, Light: HeldLight)
: _flamePlacedShown
? (Held: false, OnAltar: true, Blue: blue, Radius: AltarFlameRadius, Flames: AltarFlames, Scale: AltarFlameScale, Light: AltarFlameLight)
: (Held: false, OnAltar: false, Blue: blue, Radius: CursedFlameRadius, Flames: CursedFlames, Scale: CursedFlameScale, Light: CursedFlameLight);
if ( _cursedGo.IsValid() && _cursedBuilt == look ) return;
var home = look.OnAltar ? AltarFlameHome : CursedFlameHome;
ClearCursedFlame();
SweepCursedFlames();
var root = Scene.CreateObject();
root.Name = $"Hex tile {RewardTile.Id} — the cursed flame";
root.Flags |= GameObjectFlags.NotSaved;
root.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
root.Tags.Add( PanelTag );
root.WorldPosition = home;
// purple, or light blue once the altar has held
var spritePath = look.Blue ? BlueFlameSprite : CursedFlameSprite;
var hue = look.Blue ? FlameBlueHue : CursedPurple;
var sprite = ResourceLibrary.Get<Sprite>( spritePath );
if ( sprite is null && !_cursedSpriteMissing )
{
Log.Warning( $"[nz-hex] {spritePath} did not load — the cursed flame burns orange (Tools/basalt_purple_flame.py makes it)" );
_cursedSpriteMissing = true;
}
var file = _flameFailed ? null : ResourceLibrary.Get<PrefabFile>( FlamePrefab );
foreach ( var p in SpherePoints( look.Flames ) )
{
var at = home + p * look.Radius;
GameObject f;
try
{
f = file is null ? null : SceneUtility.GetPrefabScene( file ).Clone( new CloneConfig
{
Transform = new Transform( at ),
StartEnabled = false,
Name = "cursed flame",
} );
}
catch ( Exception ) { f = null; }
if ( !f.IsValid() )
{
if ( !_flameFailed ) Log.Warning( $"[nz-hex] {FlamePrefab} would not clone — the cursed flame has its glow, and no flames" );
_flameFailed = true;
break;
}
// ⛔ SetParent KEEPS THE WORLD TRANSFORM, so the place is set again after it
f.Flags |= GameObjectFlags.NotSaved;
f.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
f.SetParent( root );
f.WorldPosition = at;
f.WorldScale = look.Scale;
// ⛔ BOTH `Tint` AND `Gradient`: the tint multiplies the gradient, and the prefab's runs white to orange to brown
foreach ( var fx in f.Components.GetAll<ParticleEffect>( FindMode.EverythingInSelfAndDescendants ) )
{
fx.Tint = Color.White;
fx.Gradient = Color.White;
}
if ( sprite is not null )
foreach ( var r in f.Components.GetAll<ParticleSpriteRenderer>( FindMode.EverythingInSelfAndDescendants ) )
r.Sprite = sprite;
f.Enabled = true;
}
// its heart: the powerups' soft flare, in purple — on a child of its own, as the powerup's is, so nothing turns it
var heart = Scene.CreateObject();
heart.Name = "glow";
heart.Flags |= GameObjectFlags.NotSaved;
heart.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
heart.SetParent( root, false );
heart.LocalPosition = Vector3.Zero;
var glow = heart.Components.Create<SpriteRenderer>();
var flare = ResourceLibrary.Get<Sprite>( Powerup.GlowSprite );
if ( flare is not null ) glow.Sprite = flare;
glow.Size = new Vector2( look.Radius * 3.4f, look.Radius * 3.4f );
glow.Color = hue;
glow.Additive = true;
glow.Lighting = false;
glow.Shadows = false;
glow.DepthFeather = 8f;
// round it, a purple light — on tile 1, or on the way it is carried
var lamp = Scene.CreateObject();
lamp.Name = "light";
lamp.Flags |= GameObjectFlags.NotSaved;
lamp.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
lamp.SetParent( root, false );
lamp.LocalPosition = Vector3.Zero;
var light = lamp.Components.Create<PointLight>();
light.LightColor = hue * look.Light;
light.Radius = CursedFlameLightRadius;
light.Shadows = false;
// a low crackle: the bonfire's own fire loop, quieter and nearer — quieter still in the carrier's hands
var sound = ResourceLibrary.Get<SoundEvent>( FireLoop );
if ( sound is not null )
{
var sp = root.Components.Create<SoundPointComponent>();
sp.SoundEvent = sound;
sp.PlayOnStart = true;
sp.SoundOverride = true;
sp.Volume = look.Held ? 0.3f : 0.45f;
sp.DistanceAttenuationOverride = true;
sp.DistanceAttenuation = true;
sp.Distance = look.Held ? 700f : 900f;
}
_cursedGo = root;
_cursedBuilt = look;
MoveCursedFlame();
}
void ClearCursedFlame()
{
if ( _cursedGo.IsValid() ) _cursedGo.Destroy();
_cursedGo = null;
}
/// <summary>
/// Any cursed flame in the scene this manager does not hold, gone: one a hotload left behind when it renamed what held
/// it (the "purple flame"'s `_orbGo` became `_cursedGo` on 2026-09-26). Known by its name. LOCAL — from each rebuild,
/// and once from `OnUpdate`, the frame after a hotload.
/// </summary>
void SweepCursedFlames()
{
_cursedSwept = true;
if ( !Scene.IsValid() ) return;
foreach ( var old in Scene.GetAllObjects( false )
.Where( x => x != _cursedGo && x.Tags.Has( PanelTag )
&& (x.Name.EndsWith( " — the purple flame" ) || x.Name.EndsWith( " — the cursed flame" )) )
.ToList() )
old.Destroy();
}
/// <summary>
/// The cursed flame where it should be this frame: loose, over tile 1, rising and falling a little — and on the altar
/// over its top, rising and falling half as far; held, in the lower right of the carrier's own view, and at their
/// shoulder to everyone else. LOCAL — from `OnPreRender`, after the camera and the bodies have moved this frame, so it
/// does not trail them.
/// </summary>
void MoveCursedFlame()
{
if ( !_cursedGo.IsValid() ) return;
if ( !FlameCarried )
{
var bob = _cursedBuilt.OnAltar ? CursedFlameBob * 0.5f : CursedFlameBob;
_cursedGo.WorldPosition = FlameHome
+ Vector3.Up * (bob * MathF.Sin( Time.Now * MathF.Tau / CursedFlameBobPeriod ));
return;
}
var body = CarrierBody();
if ( !body.IsValid() ) return;
var cam = Scene.Camera;
_cursedGo.WorldPosition = PlayerPresence.Mine( body.GameObject ) && cam.IsValid()
? cam.WorldPosition + cam.WorldRotation * OwnHold
: body.WorldPosition + Rotation.FromYaw( body.WorldRotation.Yaw() ) * OtherHold;
}
/// <summary>
/// Where the flames stand on the sphere, as directions from its middle: a Fibonacci spiral from top to bottom, so however
/// many there are they cover it evenly.
/// </summary>
static IEnumerable<Vector3> SpherePoints( int count )
{
var turn = MathF.PI * (3f - MathF.Sqrt( 5f )); // the golden angle
for ( var i = 0; i < count; i++ )
{
var z = 1f - 2f * (i + 0.5f) / count;
var r = MathF.Sqrt( MathF.Max( 0f, 1f - z * z ) );
yield return new Vector3( MathF.Cos( turn * i ) * r, MathF.Sin( turn * i ) * r, z );
}
}
// ══ commands ════════════════════════════════════════════════════════════════════════════
/// <summary>
/// `nz_hex_torch [take|drop|lose]` — HOST: Torch Carry as it stands. `take` gives you the cursed flame wherever you
/// stand, by the other rules (the fire out, the flame not snuffed out, nobody holding it); `drop` puts it back over tile
/// 1, snuffed out or not; `lose` snuffs it out as a zombie's hit on its carrier would, until the next round.
/// `nz_hex_bonfire out` puts the fire out first, without a Shrieker.
/// </summary>
[ConCmd( "nz_hex_torch" )]
public static void TorchCmd( string what = "" )
{
if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] host only" ); return; }
var m = Ensure();
if ( !m.IsValid() ) { Log.Warning( "[nz-hex] no game running — start one first" ); return; }
switch ( what.Trim().ToLowerInvariant() )
{
case "":
break;
case "take":
{
var refused = m.TakeFlame( NZPlayer.Local, anywhere: true );
if ( refused != "" ) Log.Warning( $"[nz-hex] the cursed flame stays where it is: {refused}" );
break;
}
case "drop":
m.ResetFlame();
break;
case "lose":
if ( !m.CarrierHit( CarrierBodyOf( m._flameCarrier ) ) )
Log.Warning( "[nz-hex] nobody here carries the cursed flame to lose it — nz_hex_torch take first" );
break;
default:
Log.Warning( "[nz-hex] nz_hex_torch take, drop or lose — or nothing, to see where it stands" );
return;
}
Log.Info( $"[nz-hex] {m.TorchStateText()}" );
}
/// <summary>
/// `nz_hex_shrieker [platform]` — HOST: a Shrieker on tile 1's top, to kill there and put the fire out — or, with
/// `platform`, on the 1911 platform round the twin shield's column, where the 1911 lay, to count for the Shrieker
/// platform (`HexPlatforms.Shriekers.cs`). On basalt they come by themselves from round 14; this is for testing. Its
/// death counts where it falls, so kill it before it walks off.
/// </summary>
[ConCmd( "nz_hex_shrieker" )]
public static void ShriekerCmd( string where = "" )
{
if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] host only" ); return; }
if ( !OnBasalt ) { Log.Warning( "[nz-hex] tile 1 is basalt's — this is for ttt_basalt_d" ); return; }
var place = where.Trim().ToLowerInvariant();
if ( place is not ("" or "tile1" or "platform" or "1911") )
{
Log.Warning( "[nz-hex] nz_hex_shrieker platform, for the 1911 platform — or nothing, for tile 1" );
return;
}
var onPlatform = place is "platform" or "1911";
var scene = Game.ActiveScene;
var variant = SpecialEnemies.VariantFor( SpecialEnemies.Shrieker );
if ( !scene.IsValid() || variant is null ) { Log.Warning( "[nz-hex] no scene, or the Shrieker's variant did not load" ); return; }
// on the platform, off the column's west face — the column's middle is solid while the twin shield stands
var at = onPlatform ? ShriekerPlatformTop + new Vector3( -81f, 0f, 0f ) : new Vector3( RewardTile.X, RewardTile.Y, RewardTile.Top );
var z = ZombieCommands.SpawnAt( scene, at, variant );
var m = Instance;
var spot = onPlatform ? "the 1911 platform" : "tile 1";
Log.Info( z is null ? $"[nz-hex] the Shrieker would not spawn on {spot}"
: $"[nz-hex] a Shrieker on {spot} — kill it there"
+ ( m.IsValid() ? $". {( onPlatform ? m.ShriekersStateText() : m.BonfireStateText() )}" : "" ) );
}
/// <summary>
/// `nz_hex_flame [radius] [height] [flames] [scale] [light]` — the cursed flame as it floats over tile 1: the radius of
/// the sphere its flames stand on, how high its middle floats, how many flames and how big, and how bright its light —
/// redrawn at once; bare, it prints them. On this machine and until a restart, as `nz_hex_bonfire_flames`: settle the
/// numbers by eye, then make them the defaults.
/// </summary>
[ConCmd( "nz_hex_flame" )]
public static void FlameCmd( float radius = 0f, float height = -1f, int flames = 0, float scale = 0f, float light = -1f )
{
if ( radius > 0f ) CursedFlameRadius = radius;
if ( height >= 0f ) CursedFlameHeight = height;
if ( flames > 0 ) CursedFlames = flames;
if ( scale > 0f ) CursedFlameScale = scale;
if ( light >= 0f ) CursedFlameLight = light;
var m = Instance;
if ( m.IsValid() ) m.BuildRewardTile();
Log.Info( $"[nz-hex] the cursed flame, loose: {CursedFlames} flames round a sphere of {CursedFlameRadius:0.#}u radius,"
+ $" its middle {CursedFlameHeight:0.#}u over tile 1's top, each flame {CursedFlameScale:0.##}× a burning body's,"
+ $" its light {CursedFlameLight:0.##}"
+ ( m.IsValid() && m._cursedGo.IsValid() ? "" : " — not burning now (nz_hex_bonfire out)" ) );
}
/// <summary>
/// `nz_hex_flame_hold [forward] [right] [down] [radius] [scale]` — the cursed flame as its carrier holds it: where it sits
/// in their own view (units from the camera), and how big a sphere, and how big its flames — redrawn at once; bare, it
/// prints them. On this machine and until a restart, like `nz_hex_flame`. `nz_hex_torch take` puts it in your hands.
/// </summary>
[ConCmd( "nz_hex_flame_hold" )]
public static void FlameHoldCmd( float forward = 0f, float right = -999f, float down = -999f, float radius = 0f, float scale = 0f )
{
if ( forward > 0f ) _holdForward = forward;
if ( right > -999f ) _holdRight = right;
if ( down > -999f ) _holdDown = down;
if ( radius > 0f ) HeldRadius = radius;
if ( scale > 0f ) HeldScale = scale;
var m = Instance;
if ( m.IsValid() ) m.BuildRewardTile();
var hold = OwnHold;
Log.Info( $"[nz-hex] the cursed flame, held: {hold.x:0.#} ahead of the carrier's eye, {-hold.y:0.#} to the right,"
+ $" {-hold.z:0.#} down; a sphere of {HeldRadius:0.#}u radius, each flame {HeldScale:0.##}× a burning body's" );
}
}