Like Mahjong but with aiming.
This is a Cheery Chimp original.
How To Play Mahjong Toss
Click any cube to toss. Match symbols to clear the field!

Like Mahjong but with aiming.
This is a Cheery Chimp original.
Click any cube to toss. Match symbols to clear the field!
Feel free to contact us with any bugs, complaints, or requests.
Notes from building Mahjong Toss, a 3D physics game where you lob mahjong cubes at a stack of other mahjong cubes.
The premise is simple enough to explain in a sentence. You have a pile of cubes in your hand, a formation of cubes on the floor, and a tap that throws one in an arc. Matching symbols touch, both vanish. Clear the board.
Simple to explain, and a good deal less simple to build, because almost everything interesting in it happens at a seam: between the renderer and the physics engine, between the camera and the throw, between what a test says and what a player sees. I spent most of my time at those seams, and nearly every real bug I found had been sitting there for a while, harmless, waiting for some unrelated change to make it reachable.
Mahjong Toss runs inside a parent shell that owns the mute button, dark mode and pause. The shell exposes a file
called parent_common.js, and the game talks to it through a bridge.
I didn't have that file when I started the integration. So I wrote a stand-in based on what I assumed its interface looked like, built the bridge against my stand-in, and watched every test pass.
Three things were wrong. The real file derives the app's storage namespace from its own module URL, so it has to
be loaded from <app>/js/ — load it from the app root and every save goes to a junk
":" namespace. React's StrictMode double-mounts effects, so connect() had to be
idempotent; mine wasn't, and the second caller got a half-built state and concluded there was no shell at all.
And the handle exposes mirrored flags as objects with a .value, not as plain booleans.
My mock reproduced none of that, because my mock was made of my assumptions. It was a machine for confirming what I already believed. I deleted it and tested against the real file, and all three failures surfaced within minutes.
The lesson isn't "don't use mocks." It's that a mock you author from your own model of a system can only ever validate your model, never the system. If the thing you're integrating with exists, test against the thing.
Matt reported that pause worked and mute didn't. My test suite was green on both.
The cause was a stale closure — the audio helpers lived inside an effect with an empty dependency array, so
they'd captured the value of muted from the first render and would keep it forever. Straightforward
to fix with a ref.
The interesting part is why the test missed it. My mute test asserted that the mute flag had flipped. It had. The flag was fine; the five functions reading it were looking at a photograph of the flag taken at mount. Pause passed for a reason that had nothing to do with my carefulness — its test compared canvas pixels, so it accidentally asserted the effect rather than the signal.
I rewrote the mute test to count oscillators created by the Web Audio context. That's the effect. A muted game creates zero.
I then proceeded to make the same mistake twice more. Much later, testing whether a change had broken match detection, I asserted "the field grew by fewer cubes than I threw" — and it passed cleanly with matching switched off entirely, because thrown cubes roll off the field on their own. Different month, same error: I'd reached for something correlated with the effect instead of the effect.
For most of the game's life, every cube on the floor carried a unique symbol. That was never a deliberate rule; it fell out of the level configs, which all happened to set the symbol pool equal to the field size.
When I reworked the level ladder, I wanted duplicates on the field — two of each symbol, so a well-aimed shove can knock one cube into its twin. A carom. It makes early levels forgiving and adds a skill ceiling.
The moment I did that, levels began destroying themselves on spawn. Cubes vanished before the player threw anything.
The cause was three bugs stacked on each other, all of them years-old in spirit and none of them reachable before:
Rapier's contactPair() does not mean what the code assumed. It hands you a contact
manifold for any pair its broad phase has associated, and that manifold can be empty. The code set
areTouching = true when the callback merely fired, never checking numContacts().
Measured on one level: cubes with centres 2.0 units apart — a full empty cube between them —
were annihilating each other. This had been invisible for exactly as long as field symbols were unique, because
then the only pair that could ever match was a thrown cube and its one target. It just made throws feel
generous.
The broad-phase filter in front of it contradicted its own comment. The comment said two cubes
in contact are at most one cube-diagonal apart, which is right — √3, about 1.73. The constant underneath said
√2 × 1.2 + 0.4, which is 2.10. So pairs that couldn't possibly be touching sailed through to a
check that wasn't checking.
The adjacency test the level validator used saw a quarter of reality. It tested face contact with the other two axes aligned, which misses half-offset brick stacking entirely — the four cubes actually holding a cube up — along with diagonal contact and the cube resting on top. On one formation it reported 2 of 8 real neighbours. The validator was confidently certifying that no two same-symbol cubes started out touching, using a definition of "touching" that couldn't see most of the touching.
Three wrong things that summed to zero for as long as one unrelated coincidence held. The design change didn't create them. It revoked their permission to stay hidden.
The throw is ballistic: pick a target, solve for the launch velocity. The solver picks an arc height — five world units, scaled up with distance — and derives flight time from it.
That's a choice with consequences, and it's where the camera quietly gets a vote. Arc height is in world units, not screen space. The game blends two camera rigs by aspect ratio: a shallow, near-side-on view on a laptop, and on a phone a higher camera looking down the length of the field. The same five-unit arc is a different gesture through those two lenses. Nothing about the physics changes; everything about how it reads does.
It also turned out to be about two seconds of flight, which is how it wrecked the end of every level. The "level complete" modal ran on a fixed two-second timer started when the throw pile emptied — and the pile empties when the last cube leaves your hand, not when it lands. The timer and the final cube were racing. The timer usually won, so the modal covered the match you'd just earned. It now waits for the board to actually go quiet.
Building that turned up something worse underneath. The removal animation was "30 frames." The particles, "60." The flash, "15." Every visual effect in the game was counted in frames, which means a match played out over half a second on a 60 Hz display, a quarter second on a 120 Hz phone, and several seconds on anything struggling. The physics had the same disease in a different organ — it stepped twice per animation frame, so a 120 Hz phone ran the simulation at twice the rate of a 60 Hz laptop, and throws genuinely flew differently on different hardware.
Both are now wall-clock. A second is a second.
I'd like to report that once I started measuring things, I stopped being wrong. Here is the actual record.
I claimed a change had broken match detection. It hadn't — I'd compared two runs with different random seeds in a chaotic physics simulation and read noise as signal. Four trials each showed identical totals.
I measured a draw-call improvement after reverting one of two files, so the "before" build was silently using the new settings. The real numbers were better than the ones I'd reported, which is not the point.
I wrote a check for black bands at the edge of the frame that reported every edge black on every viewport, while
the screenshots were plainly correct — reading a WebGL canvas back with getImageData returns
transparent black unless the renderer was built with preserveDrawingBuffer. I nearly reported a
rendering bug that didn't exist.
And I wrote a stability test that reported one level's formation drifting 7.13 units — further than the diagonal of the entire floor. It compared snapshots by array index, so the instant a match removed a cube it was comparing two unrelated cubes.
Every one of those was caught by the same reflex: when a number is surprising, check the instrument before you believe the reading. A drift larger than the room is not a drift.
Test against the real thing, not your model of it. Assert the effect, not a signal correlated with the effect — and prove the assertion can fail by breaking the code on purpose and watching it go red. When a bug appears the moment you change something unrelated, don't assume you caused it; check whether you merely made it reachable. And be at least as suspicious of your measuring tools as you are of the code, because a confident wrong number costs more than no number at all.
The cubes land properly now. It only took being wrong in about nine distinguishable ways to get there.

