Night cycle
Up until this week the night wasn’t really a night. Aliens spawned, the bat flew around, things happened. Then they kept happening. Forever. Whatever rhythm a playtest had was the rhythm I imposed by quitting out when I got bored, which is not the kind of pacing I want shipping in a game I’m asking people to spend evenings on.
This one went in as a single push. Around twenty C++ files, most of them small. The shape it produces:
- Night starts.
- Timer runs.
- At 30 seconds left, a warning fires and the moon-arc widget snaps to its warning color.
- Night ends one of three ways: timer expires (half XP, walk of shame), bat reaches the cave (full XP, safe-return badge, the win condition), bat dies (failure screen, no XP, fairly grim).
- Stats screen, save file updates, next night ramps slightly.
The plumbing is ENightState and ENightEndReason enums, an FNightEndStats struct, and five BlueprintAssignable delegates on the GameMode (OnNightStarted, OnRoostBegan, OnLowTimeWarning, OnNightEnded, OnNightFailed) so designers can hang behavior off the lifecycle without making me touch C++ again. A deal I’d like to maintain.
The cave is its own actor now, ACaveHidingSpot. Walk in, input suppresses, a 1-second roost transition runs, EndNight(Roost) fires. During that 1 second the bat is damage-immune (handled in EchoAttributeSet::PreAttributeChange). The reason for the immunity window is that the single worst feel-bug I hit in playtest was making it home, watching the cave transition start, and then dying on the threshold from a projectile that was already in flight when I crossed the line. Felt like getting mugged on my own porch. Can’t happen now.
Two bugs from this week I want to write down so I stop making them.
The cave originally inherited from AHidingSpot, but the prompt detection loop hard-casts to APerchHidingSpot*. So the cave prompt just never appeared. I spent maybe 45 minutes adding print statements to the prompt widget itself before I checked the cast, which is the kind of mistake my past self would normally write a smug Slack message about to my present self. Reparented and it lit up.
The other one is worse. BeginNight() had if (World) return instead of if (!World) return. Inverted guard. The system literally could not start when the world existed. It had been sitting there silently failing for who knows how long. Probably the entire time I’d been “testing” without the timer running and just not noticing because nothing else was wired up yet. Five characters wrong, twenty minutes to find, and I’m not even going to claim I’ll never do it again because I will absolutely do it again.
The prompt system also got tidied up while I was in there. PromptLabel and OccupiedPromptLabel live on the AHidingSpot base; each subclass sets its own. AVentHidingSpot adds TraversePromptLabel. UPerchPromptWidget has a primary line and an optional secondary. EchoHUDWidget::NativeTick drives the prompt state every frame: approaching a spot shows the primary label, perched at a vent shows occupied + traverse, perched elsewhere shows just occupied, cave or nothing hides it. The player always knows what verb is on the table.
New widgets in this push:
UMoonArcWidget. The moon arcs across the top of the screen as the timer ticks, snaps to a warning color at 30s. I am inordinately proud of this widget. It’s the thing in the HUD I want people to notice.UXPDisplayWidget. Banked vs. in-progress XP, both ticking live.UNightEndScreenWidget. Post-night stats. The half-XP penalty for timing out is called out plainly, because the only thing worse than the penalty is having to guess where the missing XP went.UNightFailedScreenWidget. “Caught” screen. Doesn’t try to be cute about it.
Both end screens call SetUIInputMode() on construct, because a stats screen you can’t click is wallpaper. Save game picked up NightCount, TotalXP, LastNightSafeReturn. Five new gameplay tags: Night.Started, Night.Ended, Night.Failed, Night.LowTime, Night.RoostBegan.
The thing that surprised me is how much different the build feels now that the night actually ends. Before this, playtests were a vibe. Now they’re runs, and runs have stakes, and the first time one closed cleanly with a safe-return badge I sat there for a second before starting the next one. That’s new.