Game + Platform / Brand Campaign2026

Alpacalypse — Al's Cheatcode

Scan an official pint glass in a pub, unlock a browser endless runner, win real prizes. I built the game, the authoritative backend, and the admin console — where a successful scan is the only way to open a run, and every scan stores a camera frame as evidence for prize verification.

Role
Full-stack engineer
Client
SALT Beer Factory × Molson Coors
Agency
Spark Play
Year
2026
Alpacalypse — Al's Cheatcode

Stack

  • Three.js
  • WebGL Shaders
  • 8th Wall
  • Next.js
  • Supabase / Postgres
  • Anti-fraud

The brief

Alpacalypse is a 4.3% session IPA from SALT Beer Factory, the Yorkshire craft brewer, launched in January 2025 through a partnership with Molson Coors that took it into national distribution. The campaign built around it works like this: buy a pint in a participating pub, scan the official glass with your phone, and you unlock a level of a browser endless runner. Finish it and you win something real, with instant prizes on levels 1 and 2 and entry to a draw on level 3.

I worked through Spark Play. Quantum owned the consumer-facing web app, the venue finder, the age gate, and the brand surface. I built the other three apps in the monorepo: the game, the API that holds all the authoritative state, and the admin console the campaign team runs it from. The game and the API were the bulk of it, and I worked alongside Quantum's engineers across the contract between us.

The runner, level 1
Portrait endless runner in Three.js, with a pixel-art treatment and the world-curve shader bending the street away

Real prizes change the threat model

The prizes are physical, which means someone will try to get one without buying a pint. Anything the client can decide, the client can forge, so the rule I built to was that the browser gets to render state and nothing else.

That shows up most concretely in how a game session starts. There is no access token handed out ahead of time and no separate "unlock" call the frontend can invoke. A successful pint scan is the only code path in the entire API that produces a runId, and runId is required to submit a completion. "You must scan to play" is not a rule enforced by the UI; it is a consequence of there being exactly one entry point that creates a run.

Completion is one database transaction: close the run, advance the user's level progression, start the daily cooldown, and emit the prize event. Those four things are either all true or none of them are. Splitting them would leave a window where a user has a prize but no cooldown, and that window is worth money to somebody.

Scanning a transparent glass

The scan target is a clear pint glass, which is close to the worst case for image recognition. Glass is transparent, so most of what the camera sees through it is whatever happens to be behind it. It is reflective, the beer level changes as you drink, and pub lighting is dim and coloured.

A recognised pint on a real phone
Recognition locked onto the printed artwork, outlined in pink. The bulk of the trackable signal is the print, not the glass

The recognition keys on the printed artwork rather than the vessel, which is what makes it tractable at all.

Evidence, because a human checks the winners

Prizes go through manual verification before anyone gets one, and the draw has a second review on top of that. An admin looking at a winning claim days later needs to see what the camera actually saw at the moment of the scan.

I found that 8th Wall exposes a frame-capture API, so I take a camera frame at the moment of recognition and store it as scan evidence in Supabase Storage, linked to the scan event. The admin console has a screen for reviewing those frames next to the claim.

Two decisions inside that. Evidence upload is best-effort by contract: every failure path returns null instead of throwing, so a missing or oversized upload never blocks a paying customer from playing. The system records that the upload failed rather than punishing the user for it. And the signed URLs expire in five minutes, because these are camera frames of people in pubs and an admin reviewing one image does not need a durable link to it.

The prize state machine behind that review runs pending_verification → won → fulfilled or rejected, with every transition audit-logged. Level 3 involves an external draw with its own verification, so the log is a compliance requirement rather than a debugging aid.

The black camera that a refresh appeared to fix

The worst bug on this project cost me most of a day. On some devices the scan page loaded, showed no error, and gave you a black viewport. The camera was never requested at all. Refreshing often fixed it, which is the detail that made it so hard to pin down: an intermittent bug that a refresh clears reads like a device or permissions problem, and I spent hours there.

It was script execution order. The 8th Wall runtime and A-Frame have a strict dependency chain: A-Frame has to register its component system before xrextras registers its components, and <a-scene> must not mount until both are in place. Loading them with Next's afterInteractive puts four scripts in a race. Lose the race and xrweb never attaches, so the engine never asks for a camera, and nothing errors because from the runtime's point of view nothing went wrong. A refresh reshuffles the timing and often wins the race, which is why it looked fixed.

The fix was to stop leaving order to chance: inject the scripts myself, sequentially, awaiting each one's execution before the next, with async = false so insertion order holds even when a later file arrives first. Downloads still happen in parallel through preloads and a preconnect, since only execution order matters. I left the field-report date in the code comment so the next person to touch that file knows it was a real incident and not a defensive guess.

The world curve

The runner bends the street away from the camera the way Subway Surfers does. It looks like a post-processing effect and it is not one. It is a vertex deformation: a shader injected into every material via onBeforeCompile, displacing geometry by distance along Z.

Doing it in the vertex shader is what keeps it cheap on a phone, since the curve costs a few instructions per vertex and nothing per pixel. The catch is that it is per-material, so every material created at runtime has to be registered with the modifier. Miss one and that mesh stays rigidly straight while the entire world bends around it, which looks less like a missing effect and more like the object is broken. Obstacles spawn from pooled clones and one level swaps in a GLB for its finish line, so runtime registration is not an edge case here.

What I take from it

The black-camera bug is the one I think about. The runtime failed silently, the symptom pointed at permissions, and the intermittency actively misled me. What eventually worked was reading the dependency chain of the libraries I was loading instead of investigating the symptom, and that would have been the faster move an hour in.