The betting shop got a proper interior, and the ceiling signs ended up floating in the middle of the room
The bookies on the corner has had a roulette table in it for weeks. It has also had the walls of a stripped-out flat: bare housekit plaster, a bare floor, and a chandelier hanging a metre below a ceiling it was never designed for, because the fitting was authored for a 2.80 m room and the shell it ended up in is 3.70 m to the soffit.
Last night it got fitted out properly. Carpet, a rug, wallpaper and a dado, a lowered branded ceiling raft that comes down to meet the chandelier, a betting counter, internal signage, window vinyls and a pavement A-board. 106 placed actors, 42 textures, 37 materials, 16 lights. Same day, the fruit machine got two new brands so the shop isn't just showing the pub's cabinet twice over, and both of them are now standing on its floor.
This is the bit I keep coming back to when people ask what the point of a small game against a giant one is. In most open-world games the bookies on the corner is a photograph of a bookies stuck to a shut door. Here you walk in, in first person, and the roulette table works, the coin pusher works, and both fruit machines take your money with three reels, holds and nudges. Free play, no thirty-quid shark cards, no cutscene asking how you feel about your cousin. The whole fit-out is about eighteen thousand triangles. It is not a better-looking room than anything in GTA 6 will be. It is a room you can actually walk into and lose a tenner in, which is a different thing, and I'd argue it is the thing that has been missing.
Four traps, all of them the same species: everything I checked came back green.
The ceiling signs are hanging in mid-air in the middle of the room
The raft has four branded fascias round its edge - long boxed strips, 4.6 m on the long sides. I placed them with maths I had checked twice, and they came out floating several metres in from the wall, at head height, in the middle of the shop.
That does not look like a pivot problem. It looks like the layout arithmetic is wrong, so I went and audited coordinates that were entirely correct, twice, before the penny dropped.
The project's own notes carry a warning that a cube mesh here has its pivot at the minimum corner rather than its centre, so placement code must offset by half the size. That note is true. It is also about a completely different mesh to the one I was using. The engine's own basic cube is centre-pivot:
b = unreal.EditorAssetLibrary.load_asset(path).get_bounds()
print(b.origin, b.box_extent) # origin (0,0,0) => centre-pivot
Apply a min-corner convention to a centre-pivot mesh and every object is displaced by half its own size, in its own rotated frame - so a 4.6 m fascia does not slide 4.6 m along a world axis where you would spot it instantly. It wanders off diagonally by 2.3 m into the room and looks for all the world like bad maths.
The joke is that a third mesh in the same job has a third convention: a thin ceiling plane with its pivot at one corner in X and the opposite corner in Y, and its whole body sitting above zero. Three meshes, three conventions, every one of them a reasonable choice by whoever authored it.
Measure the mesh. Never inherit a pivot convention by name. Two lines of Python against the asset beats any note, including this one.
The rug renders as a beige checkerboard and the material readback says it is perfect
Every surface in this fit-out is generated - the carpet, the wallpaper, the notices, the posters - so there is a script that builds two master materials and parents thirty-one instances to them. The signs came out beautifully. The rug under the roulette table came out as the engine's default checkerboard.
So I checked the instance. The texture parameter read back correctly: right texture, right parameter name, bound. I checked it again off disk after a reload. Still correct. Nothing in the log. The engine will happily tell you a material instance is perfectly configured while quietly refusing to compile the thing it inherits from.
The mechanism is a one-liner:
**A texture sample parameter created with no texture is not empty - UE gives it a white colour texture as its default. On a parameter declared as a normal map, or a mask, that is a sampler-type mismatch. The master** fails to compile. Every instance of it falls back to the checkerboard, and the instance's own overrides are beside the point, because they never get the chance to run.
What genuinely cost me time is that the sign master was built with exactly the same mistake and works fine, because it is colour-only, so there is no mismatch to hit. "All the posters are correct" felt like evidence that the material-building code was sound. It was evidence about one sampler type and nothing else.
Two rules out of it:
- Load the real textures first and pass them as the parameter defaults, of the right
kind for each sampler type. Never leave one empty.
- Render the material and look at it. A 512-pixel preview onto a plane costs about a
third of a second and shows a checkerboard immediately. A parameter readback cannot see a broken master. A picture can.
The second run says it cannot wallpaper the walls it has just wallpapered
The placement pass is meant to be idempotent - run it as often as you like, it moves nothing that is already right. One group broke that on the very first re-run, reporting that it could not find the wall slot to paper.
It could not, because it was hunting for the original housekit wall material on the component in order to work out which slot to override. First run: finds it, overrides it, job done. Second run: that slot now holds the wallpaper, the search finds nothing, and the script announces failure about work it did itself.
The fix is to search the mesh asset's material list rather than the component's. The asset's slots are what an override is layered on top of, and an override never changes them. A lookup that consumes the thing it searches for will always work exactly once, which is the worst possible number of times for a bug to work.
A "full" re-run placed ten things out of seventy-two and reported success
Smaller, nastier, and worth knowing if you drive the editor from an agent: the editor's Python session outlives a single call. A global you set in one call is still sitting there in the next one.
I had run a single group by setting a variable to name it. The next call was meant to be a full nine-group re-run. It inherited the leftover global, placed ten actors out of seventy-two, and reported a clean run - because it had done everything it believed it had been asked to do.
Set every driving global explicitly, every call, including the ones you are "obviously" not using this time.
The editor says the machine is not in the level, then two of them turn up in play
Earlier the same day, on the coin pusher, and this one I nearly published a wrong diagnosis about.
The world is a World Partition map, and the standard call to list every actor in a level only returns actors that are currently loaded. An actor that exists, is saved, and is entirely correct reads back as absent. I read that absence as "this never got saved", said so out loud, and spawned a replacement at the origin.
The origin already had a fruit machine test bench standing on it. So the new pusher went inside the fruit machine, and the next physics test had coins pinging off a cabinet that was not supposed to be in the scene at all - which I came very close to writing up as a fault in the pusher's playfield. Meanwhile there were now two machines with the same name, and play mode loads everything, so both of them turned up.
On World Partition, query the actor descriptors instead. They see unloaded actors:
wpl = unreal.WorldPartitionBlueprintLibrary
descs = wpl.get_actor_descs() # sees UNLOADED actors
wpl.load_actors([d.to_dict()['guid'] for d in descs])
The guard that goes with it is the real lesson: **any script that looks an actor up by name should refuse on zero matches and on more than one**, and say which it got. I had a check for absence. I had nothing at all for "there are two of these", and a duplicate is every bit as damaging and far harder to spot.
Three fruit machines that are not the same fruit machine
The shop needed cabinets that are not the pub's, so the machine grew two sibling brands - a green one and a blue one, different artwork, different names, same guts underneath.
The obvious way to do that in Blueprint is a texture variable on the cabinet that the setup function reads. I built it that way and threw it away, for a reason that will save somebody an evening: an unset texture blanks the backglass, so it needs a validity check, and the validity check used as a condition resolves to the wrong node - an execution macro rather than the pure test - which silently truncates the rest of the function. No error, no warning. The function simply stops doing anything from that point on.
The brand is now a plain integer and the setup function swaps artwork by path in a couple of branches. Boring, and it compiles into something that actually runs.
What is still open, honestly
- Standalone frame rate in that room, with the editor shut, has never been measured,
and it is the one number that matters. Every FPS reading ever taken on this project with the editor open has been wrong. The likely bottleneck in there is physics rather than the game thread - there are loose coins on a moving deck.
- The new cabinets are placed but provisional. They were dropped somewhere sensible on
the floor so they can be walked round and shoved about by hand.
- The fruit machine's volume was doubled after a playtest note that it was too quiet.
Measured, read back, and completely unproven until somebody stands next to it. It is louder at every distance now, so it may well carry further across the pub than anyone wants.
What to take from it
- Measure a mesh's pivot before you place anything with it. Conventions do not travel
by name, and a half-size offset in a rotated frame reads as a coordinate bug.
- A material instance readback cannot see a broken master. If it renders as a
checkerboard, suspect the parent, not the override.
- Never create a texture parameter with no default. What you get instead is a colour
texture, and on a normal or mask sampler that quietly kills the material.
- One thing working is not evidence that a similar thing works. The colour-only master
survived the identical mistake and made me confident for entirely the wrong reason.
- A lookup that consumes what it searches for works exactly once. Search the source of
truth, not the thing you have already modified.
- Anything that lists level actors in a streamed world is lying by omission. Query the
descriptors, and fail loudly on duplicates as well as on absences.
- Set your globals every call when you are driving a long-lived scripting session. It
remembers far more than you do.