Second Chance DevlogThe Gazette

A scratchcard you hold in your hand, and the two ways it went invisible

unreal-engineminigameuimaterialsunreal-engine-5.8

The betting shop in this game sells a £2 scratchcard. Nine boxes, match three emblems, a printed prize key down the side. It exists to get the player through the door and onto the roulette table, so it is deliberately a loss-leader: the deal structure is engineered to break exactly even over a finite run of cards rather than to hold an edge, and the true odds are printed on the card the way a real one prints them.

The first version was a full-screen UMG widget — click the machine, the card fills the screen, drag to rub. It lasted about a day. This is the rebuild into a card the player actually holds in the world, how the scratching works underneath, and the two separate ways the card ended up invisible on the way there.

Why the flat version went

Two reasons, and only one of them was a bug.

The bug: in the widget the wax layer never rendered. That produced three symptoms which looked unrelated — a white box instead of a card, an unlimited supply of "next card", and cards that stayed hanging out of the machine forever. All three were one fault. If the wax never draws you can never finish a card, so the settle step never runs, so the "remove this card from the strip" step never runs, so the strip never shrinks and the next-card read returns index zero for ever.

Two silent render-only faults sat underneath that, and both are worth naming.

A layout anchor preset was silently ignored by the spec-driven UI builder. Asking for a "fill" anchor produced a slot anchored at (0,0)-(0,0) with a 100×30 pixel box in the corner. Every element that carried an explicit position and size — the nine symbols, all the text — laid out perfectly. So the result read as "the card has no background" rather than "the layout call did nothing". The fix is to set the anchors and offsets explicitly and then read the widget tree back, because the readback prints the real anchors.

Putting a texture in the UI texture group excludes it from the world streamer. The card face, the card back and the wax were sampled by two different things: the Slate widget and the lit card material on the strip of cards hanging out of the machine. In the widget they were crisp. In the level, every hanging card rendered as the brown streaming placeholder checkerboard — which looks exactly like "the wax is covering the whole card", and that is precisely how it was reported. Every asset-level check passed: the alpha channel was fine, the material rendered perfectly on a test sphere, the mesh had clean 0–1 UVs. Nothing was wrong with the asset. The fault was in which streamer was allowed to see it. The answer is the world texture group plus never-stream, which is crisp in the widget and fed to the 3D material.

The second reason was better than the bug: a card filling the screen does not feel like a scratchcard. It feels like a menu. So the design changed to a card held at arm's length, with a 50p piece you push around it.

The constraint that decided the architecture, and then dissolved

The flat widget had been chosen in the first place because of one project setting. Reading a texture coordinate off a line trace — which is how you would normally work out where on a 3D card the player just touched — requires UVs from hit results to be enabled, and that switch was off. Turning it on means an editor restart and a collision re-cook. The full-screen widget needed none of that, so the widget won.

Once the rebuild started, that trace work got done properly. It works: exact UVs, error 0.00000 across six probes, and it survives arbitrary rotation of the card. Two facts came out of it. The trace must be complex — a simple trace returns face index −1 and no UV at all. And the card mesh's convex hull sits 0.4 millimetres proud of the real surface, so the simple hull can never be the scratch surface.

And then the shipped mechanic did not use any of it.

The design changed so that the mouse moves the coin, in the card's own space, rather than moving a cursor over the card. The coin is a child of the card. Its card-local position therefore is the contact point, and the mapping to a texture coordinate is a direct affine expression — two divides. No trace, no hit result, no project setting.

That is not wasted work. The trace pass is what unblocked the approach, confirmed the config flag was the real blocker, and measured the affine mapping that replaced it. It is kept documented as the fallback for the day the card gets a non-trivial UV layout. But the constraint that dictated the entire first architecture turned out to be dissolvable by a design decision, and it is worth noticing how often that is true.

How the scratching actually works

A render target holds the wax mask. The brush is a small texture, full white in RGB with a shaped alpha, which is why it can be stamped additively — repeated overlapping stamps accumulate toward opaque instead of fighting each other.

Each tick, the coin's card-local position converts to millimetres, then to render-target pixels at a fixed 8.65 pixels per millimetre, and a 50-pixel brush stamp goes down. The seeding script asserts that both axes of the render target agree on that scale, because a render target that is subtly non-square in millimetre terms produces a scratch that drifts and nothing tells you.

The card material then composites three layers. Roughly: take the printed face, lerp the revealed symbol over it by the symbol's alpha, then lerp the wax over that by the wax's alpha multiplied by one minus a smoothstep of the mask. Erode the mask and the wax disappears locally, showing the symbol that was always underneath.

Two decisions in there earned their keep.

The symbols are an atlas, not twelve textures. Picking from twelve texture references means a twelve-entry array on the class default object, and in this project a later Blueprint compile silently wipes exactly that. With a 4×3 atlas the choice is arithmetic — column is the symbol index modulo four, row is the index divided by four — and there is nothing for a compile to lose. Each atlas cell is the finished revealed panel, card stock and emblem together, with alpha zero outside the rounded rectangle, because the printed face already carries a panel with the word SCRATCH on it that would otherwise show through behind the emblem.

Progress is measured as travel, not as coverage. Which of the nine boxes the coin is in is pure arithmetic from a 17 mm pitch, no array lookup. Each box accumulates the drag length that happened inside it, and once that passes a threshold the whole box is filled opaque in a single draw. The first design called for a 144-cell sub-coverage grid to check every box was properly cleared. It is unnecessary: a missed stamp between two fast mouse events cannot leave a speck of wax behind, because the box completes on distance travelled rather than on pixels cleared.

Travel per tick is clamped to half a centimetre. That clamp does two jobs — it removes the jump on the first tick after the button goes down, and it stops a fast mouse flick clearing an entire box in one frame.

The input model is deliberately cheap. While the card is up the camera look input is ignored at the player controller, which touches nothing in the character's input chain and restores cleanly. The mouse button is polled rather than bound to an input action, so no new input asset had to be authored at all.

Symptom: the card is pure black, but the coin on it looks perfect

This one cost real time because it looks like an exposure problem, and it is not.

The room's lamps are 131 to 157 cm away. The card hangs 35 cm in front of the eye with the player's body directly behind it. Every one of those lamps is occluded. The 50p looks flawless throughout the whole episode because it is metallic — a metal surface lives off reflections and will find something to reflect. A matte card needs incident light and there was none. The same mechanism explains the maddening intermittent version of the report, "it appeared when I moved the mouse": turning the body changed which lamp was blocked.

Exposure was the wrong place to look, and in this scene it is not even available: the post-process volume is unbound, high priority, with auto-exposure minimum equal to maximum. Exposure is pinned by design and must stay that way.

The fix is a light on the card rig itself, and the number is the interesting part. It is 3 candelas, not 700 lumens. The lamp sits about 30 cm from its subject rather than the five metres a room lamp works over, and against a pinned exposure a half-albedo card wants only single-digit lux. A room-scale bulb whites the card out completely. Attenuation is capped at 90 cm so the thing cannot spill into the room and change the shop.

Symptom: I compiled the Blueprint and the placed card still shows the old version

A test copy of the card rig was placed in the level while iterating. That placed actor keeps its own component overrides for ever. Editing the Blueprint and compiling never reaches it — so it renders stale component data while every check made against the asset comes back clean. That produced a complete round trip of "the card is invisible" chasing a material that was fine.

The guard is that the seeding script deletes and re-places rather than reusing, and it is off by default, because a card rig sitting in the level grabs the camera, locks look input and zooms the lens the instant you press Play — you spawn inside the scratch view and cannot walk anywhere.

A second structural note from the same rebuild: the card must not be the root component. As root, the spawn transform overwrites the seeded pose and the card hangs 7.4 cm out of place. An empty scene component sits above it and the card is posed relative to that.

Symptom: I probed the render target and it reads zero, so the draw is broken

It was not. A single-pixel probe read zero and looked like total failure. The live tick overwrites the coin position between one scripted call and the next, so by the time the probe ran the stamps had landed somewhere else. Scanning a region instead showed pixels going from 0 to 255 right across the stroke, with the correct box index and an accumulated travel figure to match.

Probing a running system at a single point is a coin toss dressed as a measurement.

The framing, since none of it is obvious

The view narrows the lens to 38° rather than using the pawn's 90°. At 90° the card is only 21% of screen width — too small to aim a coin at nine boxes. Moving the card closer instead stops it reading as held, so the lens changes rather than the distance: 51% of the width at an unchanged 35 cm. A longer lens is also what physically produces the soft background, so that came free.

The card is tipped 27° and sits slightly low, and the reason is the grid rather than the card. The nine panels are not centred on the card — their centre is 7.5 mm below it — and the tip perspective-magnifies the near bottom edge. Solving for pure optical centring moves the card by 0.16 cm, which is proof the card was already centred and that "it looks low" was always about the boxes.

What is not proven

  • The full loop — buy, hold, scratch, settle, pay out, discard the card — was driven end to

end in a live play session on the day of the rebuild, including the case of trying to open a card when you own none. That much was watched.

  • The symbol layer under the wax went in the following day. The notes for that day record

the material change but not a fresh play-through, so treat "the right emblem appears when the wax comes off" as built, not seen.

  • One known defect was found and deliberately left: the settle step writes its result

message for three prize tiers, and a later rebalance added a fourth. A win at that new tier pays the money correctly and displays "no win". It is queued to be fixed as settle moves fully into the 3D view.

  • Separately, the machine's collision hull turned out to be 100× too large — large enough to

engulf the level — which means the interact ray may have been hitting the machine from anywhere. The hull is fixed, but the buy-and-aim flow has not been re-played since, so "you have to be standing in front of it" is currently an assumption.

  • Wax flakes as particles were on the wish list. Nothing in the record says they were built.

What to take from it

  • Three symptoms that look unrelated are usually one bug with a chain behind it. White

box, infinite cards, cards that never clear — one failure to render, three consequences.

  • An asset used in two places needs both places' settings. Every asset-level check

passes when the fault is in the streamer, so "the material is fine on a test sphere" proves nothing about how it looks in the level.

  • A constraint that dictates your architecture is worth re-testing after a design change.

The trace machinery that forced a full-screen widget became irrelevant the moment the input moved into the card's own space — and the research still paid, because it measured the mapping that replaced it.

  • Measure progress with the quantity you actually control. Distance dragged is

continuous and cannot skip; pixels cleared is a sampling problem with a gap between every pair of mouse events. Likewise, prefer arithmetic over a stored array anywhere a compile can silently empty it.

  • When something renders black, ask whether it is lit before you touch exposure — and

size a close-up light against its real distance rather than copying a room lamp.

  • A placed copy of a Blueprint is a fork, and a single-pixel probe is a coin toss. Two

different ways of measuring the wrong thing while every instrument reports success.

← 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