Second Chance DevlogThe Gazette

The singer who is on at eight, and the mic she never put down

unreal-enginenpcroutinesaudiounreal-engine-5.8

The pub in this game is meant to run to a timetable rather than exist as a room with people loitering in it. There is a barmaid who serves, a regular who drinks, a quiz at eight and bingo after it — and a singer, Samantha, whose set is the thing the evening is built around. One of the marketing shots is captioned "Samantha's on at eight". In the build, her hour actually runs from seven to eight; eight o'clock is when she bows off and the quiz starts. That mismatch is on my list, and it is the least interesting thing that went wrong.

This entry covers three threads: the routine itself, the timing, and the microphone. All three were built in early August; the timing was reworked on the 14th, which is the date on this entry.

What the routine is

Samantha exists twice, in the sense that she has two controllers driving one character. One owns her while she is sitting at the bar — drinking, chatting to the barmaid, putting her glass back on the mat. The other owns her set: eleven states running from idle at the bar through picking up the mic, standing, walking to the stage, singing, walking out to a table to serenade someone, bowing, walking back, sitting down and going idle again.

Only one controller may own her at a time. The handoff is a tick-enable flip: the first state of the set switches the bar controller off, and the last state switches it back on. That is the whole ownership protocol, and it works, but it is worth saying out loud because the failure mode when two controllers both think they own a character is not a crash — it is a character being written to twice per frame by two different sets of maths.

The geometry the set walks — seats, tables, serenade positions — was measured out of the level by a script rather than typed in, and the eight track tempos were measured offline from the audio and baked in as numbers. Both of those are seeded into the controller automatically, which matters later.

Trap one: "she slides off her chair and walks out into the street"

That was the user's report, and it is exactly what happened: from the first tick she left her stool at walking speed, went through the pub wall, and headed for the middle of the map, all while still playing her seated drinking animation.

The mechanism is dull and general. The per-tick mover was called unconditionally, in every state. The destination variable was only ever assigned when she entered state 2 or above. So in state 0 the destination was still its default — the zero vector — and the zero vector is not a null. It is a real place on the map, so the mover did precisely what it was told and interpolated her towards it.

The fix was a branch on state ≥ 2 in front of the mover's body: states 0 and 1 never touch her transform, and every moving state still calls the mover unconditionally, which keeps an older rule intact about not gating movers on arrival tests.

How that fix was applied matters more than the fix. I did not rewrite the function. The graph-reading tool in this project renders keyword arguments as positionals, so writing a readback back into the engine silently rebinds arguments to the wrong pins. Instead the branch was spliced in with node surgery — add the nodes, connect them, break exactly one link, reroute — which left every existing pin untouched. The readback afterwards showed the original body byte-identical, now nested inside the new condition. When your only serialisation of a graph is lossy, edit the graph, do not round-trip it.

Trap two: the timing, and a units error

The original rule for when the set ends was "seven o'clock plus the length of the playlist divided by 3600". The playlist is 927 seconds of audio; 927/3600 is 0.257, so the set ended at 19:15 and change. That is real seconds divided into game hours — a units error dressed up as arithmetic. Whether 15 game-minutes is long or short in real time depends entirely on the clock rate, and at the time nobody had actually measured the clock rate.

The user's instruction was blunt: run it between seven and eight, and don't let it finish really quickly. So:

  • the end of the set became a flat 20:00, which lines the bow up with the quiz that starts

at eight anyway;

  • the game clock is slowed for the duration of the set so that one game hour takes 927 real

seconds — the playlist plays through exactly once and the last note lands on the hour.

Three things in that are worth copying. The clock rate is written absolutely, not as a multiplier of whatever normal happens to be, so it lands on the right duration regardless. The restore afterwards is guarded on the cached normal rate being greater than zero — without that guard, a failed cache would write a rate of zero and freeze the game clock permanently, which is a bug you would spend a day chasing. And the resume logic, which works out how far into the playlist you should be if you walk in halfway through, had to be rescaled: it previously multiplied by 3600, which with the hour remapped onto a 927-second playlist would seek 1800 seconds into a 927-second set.

There is a fourth, subtler one. The next band's elapsed-time calculation has to be written as a wrapped difference, because the naive subtraction goes negative in the window between the old end time and the new one, and a modulo of a negative number stays negative — which makes the resume walk pick track zero with a negative play offset.

The barmaid announces the singer fifteen game-minutes before the set and thanks her near the end. Those cues are clock-driven rather than gated on the set finishing, and the reason recorded at the time was that the playlist ran on real seconds while the clock ran much faster, so there was no honest "the set just ended" moment to hang an announcement on. The retiming removed most of that objection. Nobody has been back to reconsider it.

Trap three: "the mic floats behind her when she walks"

This one is my favourite, because the first fix was correct, compiled clean, and did nothing at all.

The grab was never broken — measured in play at about 6 cm from her right hand joint, attached and stable all the way to the stage. What was missing was the release. The set controller had three attach nodes and zero detach nodes, and the home position of the mic was recorded at startup and then never read by anything. So the mic was welded to her hand for the rest of the session. After the set she sits back at the bar, the drinking animations take over, and the mic swings about with them. It does not lag behind her; it is being carried when it should be on the counter.

The release function is four statements: detach, move home, restore rotation, clear the held flag. The ordering is the general rule — detach before any attempt to send a held prop home, because setting the world location of an attached actor only rewrites its relative offset, and the prop then follows the hand at that new distance forever.

Then the interesting part. I spliced that call into the sit-down state, next to the node that switches the mic's LED off. It compiled. The pin dump showed the execution wiring exactly as intended. At runtime the mic stayed in her hand.

The cause: that function contained two copies of the same node at the same graph position — one live, one belonging to an orphaned duplicate chain left behind by an earlier rewrite. Rewriting a function body in this toolchain rewires the entry pin to a fresh chain and leaves the old one lying in the graph, disconnected and invisible. I had spliced into the dead one.

What distinguishes them is a detail I nearly dismissed as a rendering quirk: the graph reader renders only the live chain. After the first splice, the new call did not appear in the readback anywhere. I assumed the reader was being unhelpful. It was telling the truth, and the pin dump — which looked authoritative — was the misleading one.

So the two tools answer different questions, and you need both:

  • the pin dump proves the wiring is correct;
  • the readback proves the wiring is reachable.

A node that appears in the pin dump but not in the readback is on a dead chain. There is also a cheap independent check that isolated this in one step: call the function directly on the live actor in play. It detached the mic and set it down perfectly, which proved the function was right and pointed straight at the call site.

One related trap while wiring the mic's on/off glow: the "off" state must be a real material instance asset. The first attempt created the off-state material at runtime, which is transient — the slot reverts on the next level load and the ring silently lights itself back up.

What is still not proven

  • The set bows off at eight and the pub quiz ducks the music from eight to nine, both

checked by setting the clock to a value and reading the consequences. None of those checks measured how fast the clock actually advances. A band test and a rate test are different tests, and the shipped clock rate turned out to be wrong by a factor of sixty — found the following day, in a separate pass.

  • Her tempos are measured and baked into the set data, but her singing loop's length was

inherited from a borrowed idle animation and is musically arbitrary. I have no record of the animation play rate ever being driven from those baked numbers, so do not assume she sings in time.

  • The mic prop as placed sits about 3.5 cm inside the bar counter. The home position is

captured from wherever it starts, so it returns faithfully to being slightly inside the furniture.

What to take from it

  • A default of zero is a location, not a null. Any variable holding a world position

needs either an explicit "unset" flag or a guard on the state that assigns it.

  • Ownership of a character must be exclusive and explicit. Two controllers on one actor

is a fine pattern; two controllers that both think they are driving is not, and it fails quietly rather than loudly.

  • Attach and detach are a pair. If you count the attaches in a graph and the number of

detaches is zero, that is the bug, whatever the symptom looks like.

  • "It compiled and the wiring is correct" is not the same as "it runs." Ask separately

whether the code is reachable — and treat a tool that shows you less than you expected as evidence, not as a glitch.

  • Mixing real seconds and game hours in one expression is a units error. Write the

conversion down, and when you remap a duration, check every other place that assumed the old one.

  • When the serialisation of a structure is lossy, never edit by round-tripping it.

Surgery on the live structure preserves what you did not mean to touch.

← All devlog entries

Watch it get built. All of this goes up on YouTube as it happens — broken animations, buildings hovering a foot off the ground, the lot.

Subscribe on YouTube