Two Frames
One per cent
Tuesday was a college day, and James started it with nearly all of his week's allowance with the agent already spent. The reset was due on Wednesday. So the plan was small on purpose: a handful of fixes from the last playtest, pushed along from his phone between sessions, and a proper look at the snooker once he got home.
The agent - Claude Code Opus 5 for most of the day, and Opus 5.5 by the end of it, because Anthropic released it that day - does the driving on days like this. He talks to it from the mobile app and it works the machine at home. It was not alone there. A second agent session spent the day building the fishing on the same editor, and the house rule when two are attached is that neither goes into play mode. So everything that moved in the daytime was built, checked inside the editor and saved, and the playing waited for him.
Three of those fixes were for Barry. Barry is the hustler at the Baulk Line, the snooker hall on the estate: head shaved close, a full beard, black t-shirt, and a cue he keeps upright beside him like a walking stick. You can walk in off the street, watch him play a whole frame against one of the regulars, and then put a fiver down and play him yourself. GTA 6 will have more of almost everything than this game will. It will not have a snooker hall on a Midlands estate where the local shark sits by the door waiting for somebody with a fiver.

At lunch he got to the gym and ran for half an hour, then walked back for the afternoon and ate his snap in the breaks.
Barry's voice
The first job was that Barry was too quiet. On Monday James had finally heard him say his lines in the game, after some chasing, and the next complaint was the obvious one: you had to be standing on top of him to hear it.
The quick fix was one number. Barry's voice uses an attenuation asset - the rule for how a sound fades with distance - and its full-volume radius was 160 cm. A snooker table is 3.6 metres long. Anyone at the far cushion was already outside the loud bit. The agent checked who else used the asset before changing it, and the answer was 58 things, four of which were not Barry: the roulette table, a roulette punter, Denise and Jack. Widening it in place would have turned up four other characters across the whole map, and nothing would have errored.
So it made a copy with one number changed - 160 to 450 cm, roughly half the hall - and pointed only Barry's voice at the copy. No volume multiplier; there is a standing rule in this project about that, learnt the hard way in the pub.
Then came the part that usually gets skipped. A Blueprint edit only reaches a placed character if the placed copy has not overridden it, and the hall was not loaded, so the editor could not see the table at all. The agent searched the table's saved file on disk for the old asset's name instead and found nothing. Then it ran the same search for a setting it knew that table did override, to prove the search could see inside the file at all. That one came back. An empty search on its own proves nothing. An empty search sitting next to a positive control proves the thing is not there.
It said at the time that 450 was a calculated first guess rather than a measurement, and that if he was still quiet the next step was 700, not a multiplier.
A mouth to go with it
The lip sync was the bigger job, and it went in the same lunchtime. Barry has 53 things he can say, and each one is a sound cue with several recorded takes inside it. When the game asks for a line, the cue picks a take at random, inside the engine. That is a sensible way to stop a character repeating himself. It also means the Blueprint that plays the line never finds out which take was chosen, so it can never know which mouth to play.
The agent moved the pick out of the engine and into the graph: six flat tables, 53 cues, 142 recordings and 142 baked face animations, 138 MB of them. The graph now chooses the take and starts the matching face clip at the same moment. Five separate checks came back green in the editor.
Two more things turned up on the way. The raw recordings carried no sound class - that lived on the cue - so played directly they would have dropped out of the voice mix entirely, which would have made the quiet problem worse on the same afternoon it was fixed. And the setting that puts his face back after the winner's handshake pointed at a face animation with no slot for lip sync and no blink. The first handshake of any frame would have switched his mouth off for good, and said nothing. Both were fixed before the evening.

The rest of the phone day was small. Labels went on the snooker hall's light switches at half one, and the check that reads them back came up green.
Ninety minutes, best break seven
He got home, closed the editor, and launched the game standalone - the way a player runs it, and the only honest way to measure it. Then he sat and watched Barry play Mervyn.
Mervyn is the regular in the waistcoat and red tie, and he is a beginner by design. The frame would take 90 minutes and 226 shots, and neither man would make a break higher than seven.

Mervyn attempted 56 pots all evening and got three of them, and he got worse as the frame went on. Barry won 93-18, and most of both men's points came from the other man's fouls. James's notes from the sofa were that Barry missed some easy ones and that Mervyn played a little too poorly. The log agreed with both.
The first one turned out to be a tuning question about how Barry plays a frame against another local. He is the snooker hustler, and the details of how he plays stay off this page on purpose - he only works if you do not know how he works. Mervyn needs tuning up a notch, and that gets done against the offline model of the table, never in the game.
And both break-offs of the night hit the pink first. Mervyn's aimed at the end red, and the line ran straight through the pink on its spot. Six away before a red had moved. That one is a proper bug in how the NPCs choose a break-off.
A fiver on Barry
Then James put a fiver down and played him.
He broke off at nearly full power and hit the pink as well. Six to Barry. He rolled two reds after that, both legal, neither dropping. Barry went in-off once, which gave James his four. It stood at four-six when James put the cue down. Every point on the board had come from a foul.

He recorded all of it, Barry against Mervyn and then James against Barry, and the whole session is up.
Watch the full session on YouTube
Arm's length
The handshake at the end of the NPC frame was wrong, and it was James who saw how. The two men walked together, came apart again, and then shook hands at arm's length. The lodge members elsewhere in the game shake hands properly. Their marks were set by hand and their fingers overlap.
The agent's first reading, from a still out of the video, was that the men were turned about 90 degrees from each other. James said the facing was fine. He was right, and that line was closed.
Its second point was more use. The obvious fix was to copy the lodge's spacing across, and the agent measured both before suggesting it: the snooker marks were already closer together than the lodge's. Copying them would have put the two men 10 to 20 cm further apart. So the marks were not the fault. Something moves one of them away after they arrive - the end of a glide snapping to the wrong point, a stop that overshoots, or a clip whose body stands further back than its own origin - and the log cannot say which. The next step is to print where both men are, every frame, from arrival to grip, and find the frame they jump on.
People on X that day were going through GTA 6 footage a frame at a time, and some of them were having a go at Rockstar for the smallest slips. James posted asking whether anybody knew a better way of getting animation to look true without motion capture. Rockstar have capture stages and actors in suits. He has two MetaHumans, a log file and whatever turns up in the replies.
Eight frames a second
The last thread of the night was the one that mattered most. The standalone run averaged about ten frames a second for 95 minutes. With the balls rolling it was 7.9; with the table still, about 12.5.
Three frame dumps from a standalone run - the intro, a street full of pigeons, and the snooker hall - all said the same thing. The graphics card was fine. The scene itself rendered in 11 to 14 milliseconds. The game thread, which runs all the Blueprint logic one thing after another, was taking 90, 93 and 154.
In the hall, the snooker table on its own took 69 milliseconds, the most expensive single thing in the game. Its physics cuts each frame into smaller steps so that fast balls cannot pass through each other, and the number of steps grows with the length of the frame. A slow frame buys more physics, which makes the next frame slower. It had hit its cap of 24 and stayed there. One function inside it read the same arrays 66,528 times in a single frame.
Everywhere else, the biggest cost was the birds. Every one was searching the whole level for litter, every frame, to decide whether to go and peck at it, and that cost 16 to 20 milliseconds in every dump. That was fixed the same night, along with the rats looking for scrap, the magpies looking for food, the pothole manager, the taxis and the coin pusher in the arcade. Each now does its search every 20-odd frames rather than every one, or slows right down when nobody is near. One loop in the middle of that got wired to a pin name the engine does not have, compiled clean anyway, and would have done nothing at all. The agent caught it by reading the node back.
None of that touched the snooker. Its fixes change where the balls end up if they are done wrong, so they get proved against the Python model of the table first. He was spending the last one per cent of the week on Opus 5.5 at its lowest setting, and he liked what he was getting for it. It was gone half nine.
The fault
One NPC's dialogue is too quiet, and its Sound Attenuation asset is shared by 58 referencers in Unreal Engine 5.8
Barry's lines faded before the far end of a 3.6 m snooker table. His AudioComponent used ATT_DealerVO: AttenuationShapeExtents (the full-volume radius) 160 cm, falloff 1400 cm, -36 dB at max.
The obvious fix - widen ATT_DealerVO - would have raised four unrelated characters map-wide. It had 58 referencers, including the roulette table, a roulette punter and two street NPCs. No error, no warning; they would simply have got louder.
A second trap sat under the first. The placed table's streaming cell was unloaded, so a level readback found 0 tables and could prove nothing about an instance override.
The fix
Duplicate the attenuation asset, change one field, repoint one component
- List every referencer before touching any
ATT_*asset. Monolith'saudio_query find_audio_referencesdoes it in one call. - Copy it field for field to
ATT_SnookerVO, change onlyAttenuationShapeExtents160 to 450 cm, and point only theBarryVoicecomponent at it.VolumeMultiplierstays 1.0. - To prove the placed actor inherits the change without loading its cell, grep its external-actor package for the old asset name and for a setting you know it does override. The known hit is the positive control; without it, "no hits" could be a broken grep.
The guard: a shared audio asset is changed by copying, never in place. 450 cm is a first guess; the next dial is the same field at 700, never a multiplier.
The fault
Lip sync cannot follow a SoundCue with a Random node - Blueprint never learns which wave played
With several takes per line behind a SoundNodeRandom, the take is chosen inside the audio engine. The Blueprint that plays the cue gets no index back, so it cannot choose the face animation that matches.
Two latent faults came out during the rework:
- the raw
SoundWaveassets had no sound class (SCL_VOwas set on the cue), so playing them directly dropped the character out of the VO mix; - the face AnimBP restored after the post-match handshake (
ABP_Face) had noDefaultSlotand no blink, so the first handshake would have silently stopped lip sync for the rest of the session.
The fix
Move the random pick into the graph
Flatten the cues into tables - here six of them, covering 53 cues, 142 waves and 142 baked face clips, one to one - let the Blueprint choose the index, then play wave n and face clip n together. Set SoundClassOverride = SCL_VO on the placed AudioComponent, and point the post-handshake face at an AnimBP that has the slot (ABP_FaceExpressive).
The guard: if a random choice has to drive an animation, it has to be made where Blueprint can read the result.
The fault
Blueprint physics hits MaxSubsteps and a standalone Unreal Engine 5.8 build drops to 8 fps
Standalone, editor closed, 95 minutes: about 10 fps on average, 7.9 with snooker balls rolling, about 12.5 with the table still. stat dumpframe -ms=1 at three spots gave a game thread of 90 / 93 / 154 ms against a scene render of 11-14 ms. Not a GPU problem.
| Cost | ms per frame |
|---|---|
BP_SnookerTable, the solver, pinned at 24 substeps (the cap) | 69.3 |
of which pair collisions: 5,544 calls, 66,528 Array_Get | 32.2 |
BP_UKBirdBase.FindLitter, GetAllActorsWithTag every tick | 16-20 |
BP_RatBase scrap search | 8.0 |
Substeps scale with frame time, so a slow frame buys more physics and the next frame is slower still. The cap was reached and held.
The fix
Throttle the per-tick world searches, and fix the solver against the oracle
Built the same night, compiled and saved: the bird litter, rat scrap and magpie food searches run every 20-40 ticks and keep the last target in between, with an IsValid guard wherever a consumer reads that target unguarded. Pothole bump tests only run within 150 m of the player, far taxis tick every 0.1 s, and the coin pusher's sweep runs every 8-12 ticks.
A For Each Loop's input exec pin is named Exec, not execute. A connect_pins to the wrong name failed, left the loop unreachable, and still compiled clean. Read the node back after every connection.
The guard for the solver: a fixed substep budget per rendered frame with an accumulator, so a bad frame runs the simulation slower instead of making the next frame worse. It moves where balls stop if it is done wrong, so it goes through the Python oracle offline before it goes in.
The kit
| Tool | Why this one |
|---|---|
| A standalone run with the editor closed | the only frame-rate numbers worth having |
stat dumpframe -ms=1 | names the Blueprint function eating the frame, down to call counts |
Monolith, audio_query find_audio_references | every user of an audio asset in one call |
| grep over the external-actor packages, with a control | checks a placed actor's overrides without loading its cell |
| The Python model of the snooker table | where snooker physics gets tuned, never in play mode |
| The YouTube recording | settled what the log could not: the men met, then moved apart |
Plain English
| Term | What it means |
|---|---|
| attenuation | How a sound fades with distance, and how far away it can still be heard. |
| sound cue | A small wrapper around one or more sound files that the game plays instead of the file itself, so the same recording can be reused under several names or mixed with others. |
| sound class | The group a sound belongs to for mixing - music, speech, effects. A sound with no class sits outside the mix and ignores every volume rule set for it. |
| animation blueprint | The logic deciding which animation a character plays and how one blends into the next. |
| lip sync | Moving a character's mouth to match the words being said. Here it means one pre-baked face animation per recorded line, played at the same moment as the audio. |
| standalone | Running the game on its own with the editor closed, the way a player would. The only honest place to measure frame rate, because the editor eats memory and processor time of its own. |
| game thread | The part of the engine that runs the game's own logic - every Blueprint, every tick - one thing after another. If it takes 90 milliseconds, nothing else matters: the frame cannot finish sooner. |
| substep | One slice of a physics calculation when a frame is cut into several smaller ones so fast-moving balls do not pass through each other. More slices is more accurate and more expensive, and if the number grows when a frame is slow, a slow frame makes the next one slower still. |
| tick | Code that runs every single frame. Cheap once, ruinous sixty times a second across a thousand actors. |
| oracle | A second, simpler implementation of the same rules, written only to be argued with. The real one is run against it thousands of times, and wherever the two disagree, one of them is wrong. |
| positive control | A deliberate attempt to make the thing fire, run before you trust its silence. If the alarm does not sound when you set fire to the test rig, the absence of an alarm the rest of the day means nothing. Here it meant calling the interaction function directly on the live player, watching it work perfectly, and thereby proving the fault was in the key press and not in anything it leads to. |
| MetaHuman | Epic's system for realistic human characters - face, body, hair and clothing, ready to animate. Every named person in the game is one. |
| World Partition | Unreal's system for very large maps. It chops the world into a grid and only loads the squares near the player. |
| in-off | Snooker: the cue ball going into a pocket after hitting another ball. A foul, and the points go to the other player. |
Things we still need to fix tomorrow:
- Stand at the far cushion and listen to Barry at 450 cm. If he is still quiet, 700 is next.
- Watch the recording for his mouth against his words.
- Tune how Barry plays when his opponent is another local, and bring Mervyn up a notch against the offline model.
- Give the NPC break-off a clear-path check, so the line to the end red stops running through the pink.
- Print both men's positions every frame of the handshake and find the frame where they move apart.
- The substep budget for the snooker solver, then the cheaper pair reads.
- A standalone run to check the new throttles: pigeons still going to litter, a rat still grabbing scrap, a car still bobbing over a pothole.
- One log warning fired 62,581 times in the run:
GetPositioncalled on a body in Animation Blueprint mode, where it returns zero. Find the caller.