Second Chance DevlogThe Gazette

Every bottle I scatter stands bolt upright, and most of it is floating

unreal-enginepythonscatterplacementproceduralworld-partitionline-traceverification

Litter is one of those things nobody notices until it is wrong. A British estate street without a few crushed cans and a wind-blown crisp packet reads as a film set. So this game scatters rubbish along its pavements automatically — a few hundred items placed by script against the walkable footway, so the place looks lived in without anybody hand-dressing three kilometres of kerb.

Rockstar have people whose entire job is deciding where a crisp packet lies. I have a Python script and an agent, and yesterday both of us got it wrong in an interesting way.

The report came in as an aside to something else entirely:

some rocks are still balanced unrealistically. can you make them lay on the side that

they would fall on with gravity? a rock isn't likely to stand perpendicular. **the same

bug exists with placed litter as well in the game.**

Two halves of one complaint, and — this is the part I did not see coming — the two halves had opposite causes. The rocks around the cave well-head were being tilted 35 to 90 degrees deliberately, for visual variety, so they ended up balanced on their edges like dominoes caught mid-fall. The litter had the reverse problem: every item got a measly ±8 degrees of jitter, and the rubbish meshes are shop products authored standing upright. Cans, bottles, a milk pint. So the pavement was lined with about a hundred and seventy bottles standing to attention, and the fix for one half was the exact inverse of the fix for the other.

What I was trying to do

Make dropped objects come to rest the way dropped objects come to rest. That is the whole brief, and it turns out to be a genuinely interesting little problem, because you cannot simply simulate it — running physics on several hundred props at load time is not free, and the result is not reproducible between runs. You want an analytic rule that gets it right from the mesh's own bounds, and you want it to run in a fraction of a second.

The work was done from Claude Code driving Unreal 5.8 through an editor MCP server that executes Python inside the running editor. No Blueprint compile anywhere in this job, which is a small mercy — everything here is scripted placement and measurement.

Wrong turn one: "is it tall? then lay it down" passes the exact bug it was written to catch

My first rule was the obvious one. If a mesh is much taller than it is wide, it is a bottle, so lay it over. Anything squat is already flat, so leave it alone. In code that is one comparison — is the vertical half-extent more than about 1.3 times the larger horizontal one.

Then I measured the actual meshes, and the test died on the spot.

A sharing bag of crisps in this project measures roughly 9.2 by 0.7 by 10.6 centimetres. A small plastic baggie is 4.2 by 1.4 by 5.1. Those are sheets. Their height-to-width ratio is about 1.15 — comfortably under the threshold — so my clever test looked at them, said "squat, already lying flat, leave it", and moved on.

They were not lying flat. They were standing on their bottom edge, twenty-nine of them, like little cardboard tombstones, which is precisely the bug I had been sent to fix. The test meant to catch it passed it. Meanwhile most of the cans turned out to be modelled already crushed and lying down, and only three meshes in the whole set — a vodka bottle, a sparkling wine bottle and a milk pint — were genuinely bottle-shaped in the way I had assumed all of them were.

If I had shipped that version, the verification would have looked fine and the street would have looked haunted.

The rule that actually works: shortest local axis vertical

Here is the whole thing, and it needs no per-mesh table, no tagging, no author discipline and no physics:

An object that falls comes to rest on its broad face — which means its SHORTEST local axis ends up VERTICAL.

Read the mesh's own bounds, find which of its three local axes is smallest, and put that one straight up:

| shortest axis | what that means | what to do | |---|---|---| | Z | already on its broad face | leave it, ±12° of jitter, free yaw | | X | standing on a narrow face | pitch ±90°, which swings local X up | | Y | standing on a narrow face | roll ±90°, which swings local Y up |

Run that against the crisp packet: its shortest axis is the 0.7 cm one, so it gets rolled 90 degrees and lands flat on the pavement like litter does. Run it against the vodka bottle: the shortest axis is horizontal, so it is laid on its side. Run it against a crushed can that was modelled lying down: shortest axis is already vertical, so it is left exactly as it is. One rule, three correct answers, and I never had to know what any of the objects were.

Two refinements matter as much as the rule itself.

Leave an upright minority. A bottle standing on its base against a kerb is a real thing you see on a real pavement, and a street where literally everything is horizontal reads as though a bomb went off. So fifteen percent of items that could stand are left standing. "Could stand" has to be defined carefully, or you are straight back to tombstone crisp packets: it means a roughly round footprint — the smaller horizontal half-extent at least seventy percent of the larger — and taller than it is wide. A crisp packet scores 0.08 on that roundness test, so it can never be chosen to stand. A can scores nearly 1.0.

Never emit anything between 20 and 70 degrees. This is the bit I would most like other people to steal, because it reframes the problem. A prop leaning at 45 degrees is not a halfway house between "standing" and "lying down". That intermediate angle is the bug. It is the exact look the developer described as "balanced unrealistically". So the forbidden band is written into the generator as a hard rule — with two axes of ±8° jitter the output can only ever land about 11 degrees off whichever pole it was aimed at — and it is written into the verifier as a failure condition. There is no compromise angle to tune. There are two good answers and a forbidden middle.

Wrong turn two: the verifier reported a street full of edge-balanced packets as 100% clean

This one is nastier than the first, because a bad fix that reports itself as bad is merely annoying, whereas a bad fix that reports itself as good is how things ship broken.

The obvious way to audit "is this item lying down?" is to take the rotation, transform the object's local +Z, and measure the angle to world up. Zero degrees means standing, ninety means lying down. Simple, and it is what I wrote.

Now look again at that crisp packet standing on its bottom edge. Its local +Z is perfectly vertical. The audit measures zero degrees, classifies it as a bottle standing proudly on its base, ticks it off as intentional, and moves on. The wrong axis is the vertical one and the instrument cannot see it, because the instrument was told to look at Z rather than at the axis that actually matters.

The fix is to measure the mesh's own shortest axis against world up — the same quantity the placement rule is built on. Then the classification is independent of which direction the artist happened to call "up" when they exported the thing, and it gives three states worth distinguishing: resting (shortest axis vertical, correct), standing (shortest axis horizontal, correct only for something that could genuinely balance), and edge (neither, propped at a forbidden angle, always wrong).

If your fix and your check share an assumption, your check is decoration. That is the generalisable version, and it applies a very long way beyond scatter tools.

Fixed all the angles, and 235 of 260 items were floating above the pavement

The gravity half was only half the job, because the moment you lay a bottle down you break everything about how it was being seated. Three faults in what was, honestly, about three lines of code.

It seated on the ground at the item's centre point. The footways in this town carry a 2.5% crossfall — they slope towards the gutter, because real pavements do — so an item laid on its side sits on ground that is lower at one end than at the other. Sample only the centre and the downhill end hangs in the air. The fix is to sample the lowest ground under the item's whole footprint: centre plus six points on a ring at the footprint radius, take the minimum. Cap that ring radius, though, or on a narrow pavement it reaches over the kerb and cheerfully seats your bottle thirteen centimetres into the paving slabs.

It assumed the mesh pivot sits at the mesh's base. About half of these shop products have a centred pivot instead, so "place it at ground level plus three centimetres" buried them. And this fault is invisible until you start rotating things — an upright bottle with a centred pivot is only half-sunk and you might not notice, whereas laying it on its side moves the underside somewhere else entirely. The fix is to solve for the actor location that puts the mesh's underside on the ground for the chosen rotation and scale, rather than assuming the actor location and the underside are the same point.

And the loop ran in the wrong order. It chose a rotation, spawned the actor, traced the ground, and only then assigned the static mesh and the scale. Every one of those steps depends on the ones after it: you cannot know the footprint radius until you know the rotation and the scale, and you certainly cannot pick a sensible resting attitude for a mesh you have not chosen yet. A laid-down bottle's footprint is roughly four times an upright one's. Order of operations in a placement loop is not cosmetic.

The measurements, including the one I do not entirely trust

Two independently written instruments — the generator's own report, and a separate auditor that shares only the geometry helpers — agreed on this, across 520 items:

| | before | after | |---|---|---| | upright items that should have fallen | all of them | the intended minority, ~10% | | sheets standing on their narrow edge | 29 † | 0 | | propped at 20–70° | 0 | 0 | | floating more than 1 cm off the ground | 235 of 260 | 0 | | buried in the pavement | 2 | 0 | | worst underside-to-ground gap | +101 cm / −122 cm | −1.3 cm … −0.1 cm |

That last row is the one I am happiest about. Everything now sits between one and thirteen millimetres into the ground — a deliberate four percent bed-in, so nothing shows a hairline of daylight underneath it when the sun is low.

Derived, not measured, and I am flagging it rather than quietly rounding it into the table. The before-run predates the counter that catches sheets on their edge, so that 29 is the crisp packets and baggies counted from the mesh spread, all of which were necessarily standing under the old ±8° jitter. The two "upright" rows also use different denominators — the before-run classified with the old tall-versus-short ratio, the after-run with the proper standability test — so read that row as "all of them" versus "the intended minority", not as a tidy number that went from 43 to 18. Writing down the shape of a comparison you cannot make cleanly is more useful than a neat figure that quietly means two different things in its two columns.

The litter count also doubled from 260 to 520 in the same pass, on the developer's instruction, with the minimum-separation and station-spacing dials moved down to match. 520 of 520 placed successfully from a candidate pool of 2,137 footway points.

Two traps in the plumbing that cost real time

Both of these produce error messages that point at completely the wrong thing.

Purge first, build the ignore list second. These traces need a list of actors to ignore. I built the list, then purged the old litter, then started tracing — so the list contained destroyed actors. Every subsequent trace died with a message about not being able to convert an object because an instance was null. That reads exactly like a call-signature problem, and I went hunting for the wrong argument order for a while. It is not a signature problem. It is a dead pointer in a list you handed the engine a moment before killing its contents.

A prop answers its own probe. Anything that re-seats an existing actor has to put that actor — and its neighbours in the same cluster — into the ignore list, or the downward trace hits the item's own collision and reports that the ground is exactly where the item already is. Self-fulfilling, perfectly stable, and completely wrong.

And the trace hits things that are not the ground. In a World Partition world the downward trace can land on a baked proxy of the terrain rather than the terrain itself — at the cave well it answered 93 cm too high. So the proxies go in the ignore list and any hit on a landscape mesh proxy component is rejected outright. Worth noting that this reject rule is not portable between scripts: the well-head dressing asserts that a valid ground hit must be a landscape collision component, which is right there and would reject every single valid hit for the litter, because litter sits on road-profile footway meshes rather than on the landscape. A guard copied without its context is a guard that fails.

The other lesson from the same day: every grid resolution gave a different confident answer

Separate job, same underlying disease, and it is the better story.

The cave shell — the big sculpted mesh that forms the underground network — was poking through the terrain somewhere near the well-head, and I needed to know exactly where so I could dress over it. So: survey it. Sample points on a grid, trace, report every place where the shell is above the ground.

| survey | protrusions found | |---|---| | 150 cm grid | 1 | | 80 cm grid | 2 others — and it missed the one the 150 cm grid found | | 40 cm grid | 7 — and still missed the longest run | | polar scan, 2° steps | 65 exposed directions | | polar scan, 1° steps | 116 — the exposure is near-continuous, not dotted |

The feature was a razor-thin ridge, a few centimetres wide, following a circle. Every grid steps over a 4 cm line somewhere, and each grid steps over it in a different place. So each survey returned a confident, plausible, wrong answer, and the answers did not even agree with each other. A dressing plan built off the 150 cm survey would have placed a single rock and declared the job finished.

Three rules came out of it:

  • Match the scan pattern to the shape of the thing you are hunting. For a feature that

follows a ring, walk outwards along each azimuth. Do not cross it with a grid.

  • Halve the step and re-run before you trust a count. If the count moves a lot, you are

still under-sampling, and everything you plan off it is planned off noise.

  • A sample count is not an extent. A one-sample cluster is a sliver, not a small patch.

And the only honest test that cover actually covers anything: counting what you placed is not evidence the thing is hidden. Re-run the same survey with the cover left in the trace — whatever is still reported is still visible. That turns into a rather satisfying convergence loop, which in this case ran 116 exposed directions, then 61, then 32, 29, and finally 11.

One trap inside that loop, because it fails silently in the most flattering way imaginable: a top-up pass must use a smaller spacing than the pass before it. It seeds its overlap-rejection with the props already placed, and the gaps it exists to fill are by definition right next to those props. At the original spacing every candidate is rejected, so it places nothing — and reports success.

What is still broken, plainly

  • **Fly-tip heaps, the pub's empties and the original street level's litter are all

untouched.** They were explicitly out of scope for this pass. The fly-tips carry the seating half of this bug — they seat on ground-at-centre — and their tilt has never been checked against the shortest-axis rule at all. That is a known, unfixed fault sitting in the game right now.

  • Two items are in the wrong place and always were. One is indoors under a bin, one is on

top of a building. Both predate this work; they are misses in the candidate gate that decides where litter may go, which is a different piece of code, and gravity has nothing to do with either.

  • Nobody has played it. This is the honest bit, and it is exactly the same caveat the bus

fix carried last week. Every number above came from measurement, not from watching. In particular the rats in this game pick up scraps and drop them again, and dropping re-enables physics — so a bottle that is now resting neatly on its side may well roll down the crossfall the first time a rat drops one. It would be very funny. It has not been observed.

The relief on this one arrives when I stand on that pavement in a play session and it simply looks like a street somebody lives on. Until then it is a good rule with good arithmetic behind it, which is not the same thing at all.

What to take from this

  • A dropped object rests with its shortest local axis vertical. Read it from the mesh's

bounds. It needs no per-mesh table and it handles sheets, bottles and already-crushed cans correctly with one comparison.

  • The intermediate angle is the bug, not a compromise. Forbid 20–70° in the generator

and in the verifier. There is nothing to tune in between.

  • If your fix and your check share an assumption, your check is decoration. Mine measured

the object's own +Z, which is precisely the axis that was innocent.

  • Seat props on the lowest ground under the whole footprint, not the ground at the centre

— anything with a slope will float one end.

  • Do not assume a pivot is at the base. Solve for the location that puts the mesh's

underside where you want it, for the rotation and scale you have actually chosen.

  • Choose rotation and scale before you trace, because the footprint depends on both.
  • Purge before you build an ignore list, and never let a prop trace onto its own

collision.

  • A guard copied without its context is a guard that fails. The right ground-hit assertion

for a landscape is the wrong one for a road mesh.

  • Halve the survey step and re-run before trusting any count — and if a top-up pass places

nothing while reporting success, check its spacing before you believe it.

← 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