Skip to content
KR.

BrainTrade · DSpearhead — Japan

2026

Plinko

A Matter.js plinko board where the invisible physics is fitted, by regression, to the rails painted in the background plate.

Design · Front-end · MotionOffline

Shipped

Twice

BrainTrade · DSpearhead

Engine

Matter.js 0.20

Build: Vite 8, no framework

Techniques

5

12 constants, formulas and guards

A tap-to-drop arcade game built as the entry point of a Japanese acquisition campaign. A ball falls through eight rows of pegs into one of eleven multiplier slots; the score it lands on decides which reward tier the player is offered at the end. It is a full front-end product rather than a banner — physics, procedural audio, particles, an offline-capable manifest and a Japanese interface, shipped as a standalone installable page.

Offline

Built for two acquisition campaigns and deployed to S3 sub-paths behind a pipeline variable. There is no public address, and there never was one that outlived the campaigns — so nothing on this page can be checked against a running site. It is checkable against the source, which is what the page quotes.

One engine, two partner deployments, and only the two screens carrying the offer ever changed.

================DIFFDIFF=======
23 identical hashesui/intro.js · ui/gameOver.js
Every file under src/, compared by git blob hash across the two partner deployments. Twenty-three hash identically. The two that differ are the intro and the game-over screen — the two screens that carry the offer.

23 / 25

source files byte-identical

2

files carrying the offer

4 vs 3

prize tiers between partners

9k vs 11k

points to reach the top tier

Built once, shipped twice.

  • BrainTrade

    Japan

    ja-JP

    Four prize tiers, Diamond opening at 9,000 points, and an explainer panel above the tier ladder on the intro screen.

  • DSpearhead

    Japan

    ja-JP

    Three tiers with the lowest removed, Diamond held back to 11,000 points, a quarter-size partner logo, and a legal-links row the first build has no equivalent of.

The mechanic, rebuilt and running.

Rebuilt from the constants quoted below, on this page, in your browser. It is not the client’s board — none of their artwork, copy or offer is here. What is reproduced is the part that was mine to reproduce.

Loading the board…

10×5×2×1×0.5×0.2×0.5×1×2×5×10×

Drops 0

Score 0

—

Click the board to drop a ball

Eight rows of pegs into eleven slots, on a solver running at the shipped gravity. Every ball takes a path nothing authored — which is the entire reason a real engine is here rather than a scripted fall.

The rails sit where the original's regression put them. The stuck-ball detector is live: leave it running and a ball that wedges will be nudged, then removed, on the timers in the table below.

The engine only runs while this section is on screen, and stops when the tab is hidden.

Each one starts with something that was broken.

  1. 01

    The physics is fitted to the painting

    The failure

    The board is a painted image — rails, funnel, slot tubes, all illustration. The bodies that actually deflect the ball are invisible. Place them by eye and the ball visibly clips through a painted rail or bounces off empty air, and the whole board stops reading as a physical object.

    The decision

    Sample clean pixels down the painted funnel walls, fit a straight line to each side, and drive the collision geometry from the fit. Every structural body is then positioned as a fraction of the playfield, so the calibration survives any viewport the plate is scaled to.

    • formulasrc/scenes/board.js
      Rail regression (clean pixels y=0.285→0.666): L: x=-0.734y+0.488, R: x=+0.734y+0.510
    • constantsrc/scenes/board.js
      FUNNEL_TOP_FRAC 0.287 · FUNNEL_BOTTOM_FRAC 0.665 · SLOT_TOP_FRAC 0.837 · SLOT_BOTTOM_FRAC 0.951

    What it cost

    The constants are married to that specific plate. Re-illustrate the background and the regression has to be re-run — which is a real cost, and the comment in the source exists so the next person knows the fit is derived rather than tuned.

  2. 02

    A stuck ball is found by drift, not by speed

    The failure

    Occasionally a ball wedges between a peg and a wall and oscillates. The obvious detector — flag anything moving slowly — never fires on it, because a ball rattling in a corner has plenty of velocity. It simply is not going anywhere. The round hangs, and the player is left watching a ball vibrate.

    The decision

    Ignore velocity entirely. Anchor each ball at a position and measure how far it has drifted from that anchor; the anchor only moves once the ball genuinely relocates. A ball that oscillates therefore accrues age without accruing drift, and is nudged, then removed. Both timers escalate by zone, because a ball stuck in a corner is more obviously broken to a watching player than one dawdling mid-field.

    • constantsrc/physics/world.js
      STUCK_DRIFT = 18
    • constantsrc/physics/world.js
      nudge / remove — corner 250ms / 700ms · near wall 450ms / 1100ms · open field 700ms / 1800ms
    • guardsrc/physics/world.js
      inCorner = p.y > h * 0.70 && (p.x < w * 0.15 || p.x > w * 0.85)

    What it cost

    The nudge applies a real force, so a rescued ball lands somewhere the simulation would not have put it. Accepted deliberately: a fair board that hangs is worse than a very slightly nudged board that never does.

  3. 03

    The soundtrack has no files in it

    The failure

    A plinko board wants a sound on every peg strike. Shipping samples means a download the campaign's mobile traffic pays for before the first frame, and a board hitting six pegs a second turns overlapping sample playback into mud.

    The decision

    Synthesise everything. Peg strikes are a short sine burst with the frequency jittered per hit so no two are identical; the drop is a pitch sweep; a win is a three-note square arpeggio fired on timers. Strikes are rate-limited, so a dense cascade thickens rather than distorting.

    • constantsrc/audio/synth.js
      playPegBounce — 600 + random × 200 Hz, 0.04s sine at 0.03 gain
    • constantsrc/audio/synth.js
      peg-strike throttle — 40ms minimum between sounds
    • constantsrc/audio/synth.js
      win arpeggio — 600 / 800 / 1000 Hz square at 0 / 60 / 120ms
  4. 04

    iOS is assumed hostile, and handled

    The failure

    Mobile Safari blocks audio until a user gesture, and silently closes an AudioContext after a long backgrounding — so a player who takes a call returns to a permanently mute game, with no error anywhere.

    The decision

    Unlock on first touch by playing a one-sample silent buffer, which is the documented gesture Safari accepts, and re-create the context on demand whenever it is found closed rather than caching it once at load.

    • guardsrc/audio/synth.js
      if (!audioCtx || audioCtx.state === 'closed') — recreate rather than reuse
    • constantsrc/audio/synth.js
      unlock — ctx.createBuffer(1, 1, 22050), started once on first gesture
  5. 05

    It gives up effects before it gives up frame rate

    The failure

    A paid-social campaign in Japan lands on a wide range of phones, including four-year-old mid-range Android. A particle budget tuned on a good device turns those into a slideshow at exactly the moment the board is most active.

    The decision

    Size the particle pool from the reported core count at startup and never allocate beyond it — the pool is fixed, so a burst that would exceed it simply emits fewer particles instead of growing the heap. Stop the runner and the renderer entirely when the tab is hidden, rather than letting an invisible simulation hold the main thread.

    • guardsrc/effects/particles.js
      LOW_END = (navigator.hardwareConcurrency || 4) < 4 — pool 100, else 200
    • guardsrc/physics/world.js
      visibilitychange — Render.stop + Runner.stop while hidden

    What it cost

    A weak device gets a visibly thinner burst on a big win. That is the correct thing to lose: the payoff of a 10× slot is the shake and the sound, and both survive.

Every animation, with what it does when asked for less.

The last column is the one worth reading. “Respects reduced motion” is easy to assert and easy to skip, so where it is not true here, it says so.

Screen shake on a win

10× slot: 15px / 500ms · 5×: 10px / 400ms · 2×: 4px / 200ms

Quadratic decay — (1 − t)², re-randomised each frame

Not gated. On a full-screen tap-to-play game the shake IS the win feedback, and the honest note is that a reduced-motion player gets it anyway.

Peg dip on strike

4px travel, 400ms

Time-based, driven off the physics tick rather than CSS

Not gated — same reason.

Particle burst

0.2–0.4s lifetime, gravity 0.15 per frame-equivalent

Linear fade on remaining life, alpha = life / maxLife

Pool halves on low-core devices; unaffected by motion preference.

Orientation hint

2s loop, infinite while the phone is landscape

ease-in-out

Not gated. It is an instruction, not decoration — stopping it would remove the only thing telling the player what to do.

What it was built on, what is claimed, and what is not.

Engine

Matter.js 0.20

A real rigid-body solver rather than a scripted fall. The ball's path is not authored, which is the point — a scripted plinko is a video, and players can tell within two drops.

Build

Vite 8, no framework

No React, no component layer. The whole interface is imperative DOM built by the module that owns it, because every screen here is either a canvas or a single overlay and a virtual DOM would have been ceremony around one element.

Audio

Web Audio oscillators

No audio files ship. Every sound is built per event from an oscillator and a gain ramp, so the entire soundtrack costs zero bytes of download and never blocks first paint.

Render

Canvas 2D at device pixel ratio

Matter's own renderer, re-pixel-ratioed on resize. Particles draw into the same context on `afterRender` rather than a second canvas, so there is one compositing layer and one frame budget.

Delivery

Installable page, service worker

Manifest, Apple touch icon and a status bar style, so the campaign's traffic — overwhelmingly phones from paid social — gets a full-screen app rather than a page inside browser chrome.

Claimed — and how to check it

  • 23 of 25 source files are byte-identical across the two deployments

    Compare git blob SHAs for src/ in both repositories; only ui/intro.js and ui/gameOver.js differ.

  • The collision geometry is fitted to the background artwork

    The regression and its sampled range are written into board.js above the fraction constants they produce.

  • Eight peg rows into eleven multiplier slots

    PEG_NUM_ROWS and SLOT_MULTIPLIERS in board.js.

  • No audio file is shipped

    There is no audio asset in either repository; synth.js builds every sound from an oscillator.

  • The two builds differ in reward economy, not skin

    Tier counts and Diamond thresholds differ between the two gameOver.js files — four tiers at 9,000, three at 11,000.

Not claimed

  • No play count, completion rate, or registration conversion. The campaigns' analytics sit in the client's account and were never shared.
  • No public URL, then or now. Both builds deployed to S3 sub-paths behind a pipeline variable, so there is nothing for a reader to open.
  • No claim that the reward tiers were designed here. The offers were the partners'; the build encodes them.
  • No A/B test, no measured comparison between the two deployments. They ran for different partners, not as arms of an experiment.
  • The rebuild on this page is a reconstruction of the mechanic, not the shipped board. It uses the same engine and the same published constants; it does not use the client's artwork, copy, or offer.

Every number is read out of the two repositories' source at the file named beside it. The identical-source claim is a comparison of git blob SHAs across both repositories under src/, not a visual diff — 23 of 25 match, and the two that differ are named on this page.