GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

JavaScript Pinball Game: 9 Physics Scenes

JavaScript pinball game for raffles. Balls dropped onto named slots: why do middle names keep winning?

Nine Stages Where Balls Roll, From a Raffle Board to a Mini Table

Auto-plays · click a tile to jump to its section · all nine in one zip

Leave the grid alone and every cell replays its own fixed-seed board. The boards that have an end (01, 07 and 08) hold the result for 1,600ms and then restart from the same seed, while in 02 and 09 a virtual input works the flippers, pressing when the ball enters the zone above the bar and letting go 240ms later (220ms in 09). The plungers in 05 and 09 pull back and fire by themselves when a ball arrives, and 09 nudges its table once at 1,300ms and again every 3,600ms. The first button press ends the demo and from then on the board follows your input; "Draw again" in 01, 07 and 08 and "Roll again" in 03, 04 and 06 raise the seed by one and open a fresh board.

Each step down the list adds one more kind of force on the ball. 01 only bounces off pins that stay put, 02 has a moving bar lend the ball its own speed, 03 hands out a fixed kick on contact, 04 adds a boost off a long edge, and 05 pushes in proportion to how far it was pulled. 06 brings a bar that keeps turning, 07 separates the field with slopes and rolling friction, 08 trades one ball for sixty so that probability shows up as a pile, and 09 puts 02, 03 and 05 on a single table.

JavaScript Pinball Game: 9 Scenes — Name-Ball Raffle Board, Flipper, Pop Bumper, Slingshot Kicker, Plunger, Windmill Bar, Marble Race, Landing Histogram and a Mini Table

Every stage runs on the 2D canvas and plain JavaScript. There is no physics engine, library, WebGL, CDN or image file in the set, and the randomness comes from a single place, makeRng, which mixes 32-bit integers instead of calling Math.random. The gaps reported by requestAnimationFrame are stacked up and spent as fixed 16ms ticks, at most 6 ticks or 100ms of catch-up per frame, and each stage cuts every tick again into 4 to 6 steps (SUBSTEPS) before it checks collisions. A fixed clock is a common first decision in pinball physics programming, and here it means the step size does not depend on how often the screen refreshes.

What 23 frame-to-frame comparisons recorded

Each stage was rendered in a 480×300 window and every capture was set against the one just before it. Area changed is the share of the stage whose pixels changed at least once, and frames that moved counts how many of the 23 comparisons found any change at all.

Stage Area changed Frames that moved Mean intensity
01 Name-ball raffle board 19.1% 23/23 99.9
02 Flipper 12.5% 23/23 98.5
03 Pop bumper 21.5% 23/23 83.1
04 Slingshot kicker 16.8% 23/23 97.3
05 Plunger 8.5% 23/23 98.7
06 Windmill bar 28.8% 23/23 103.9
07 Marble race 14.2% 23/23 72.4
08 Landing histogram 8.8% 23/23 82.7
09 Mini table 14.3% 23/23 85.5

Not one stage held still between any two captures. 06 covers the widest area at 28.8%, likely because two three-armed rotors keep turning and bat seven marbles all over the stage, and 05 covers the least at 8.5%, which appears to come from most of the stage sitting idle while the plunger is drawn back and released.

Where each board fits, from a stream raffle to a team's speaking order

For a viewer raffle on a live stream, 01 is the closest fit. The winner is the first ball over the line, so anyone watching can follow the result with their own eyes, and the eight balls now tagged A to H can carry participant names in those label spots. When a single person has to be chosen fast, the wheel in spin wheel roulette physics may be the simpler tool.

07 suits picking a speaking order at a meetup, because unlike 01, which leaves one winner, its leaderboard keeps all six rows with arrival order and time. 08 is for showing the process rather than only the outcome: it draws the pull toward the middle first and then shuffles the names, so on a prize-draw page the picture explains why sitting in a middle slot gives no name an edge.

One thing has to change before real use. The demo is seeded with a fixed value, so its results can be reproduced; for an actual draw, the seed should come from an outside input. Seed 4761 makes A win at 1,040ms however many times you reopen it, and "Draw again" only runs seed += 1 and then restart(), so the board after 4761 is 4762 and is already decided.

01A Raffle Board Where Name Balls Fall Through Pins

Eight balls tagged A to H fall through 66 pins, each pushed out by however deep it overlaps a pin, with only the incoming normal speed flipped and scaled by the 0.5 restitution. A ball wins once its whole body clears the finish line at y 238, which suits a viewer raffle screen for a live stream because the audience sees exactly who crossed first. On seed 4761, A wins at 1,040ms, and after 127 pin contacts all eight settle into slots by 5,616ms.

A Raffle Board Where Name Balls Fall Through Pins
Open in a new tab

01 · A Raffle Board Where Name Balls Fall Through Pins — Measured area 19.085% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

PEG_ROWSRESTITUTIONSUBSTEPS
    function hitCircle(b, cx, cy, cr, e) {
        const dx = b.x - cx;
        const dy = b.y - cy;
        const d = Math.hypot(dx, dy);
        const overlap = b.r + cr - d;
        if (overlap <= 0) {
            return -1;
        }
        // If the centers coincide exactly, no direction can be found — push upward
        const nx = d > 1e-9 ? dx / d : 0;
        const ny = d > 1e-9 ? dy / d : -1;
        b.x += nx * overlap;
        b.y += ny * overlap;
        contact.nx = nx;
        contact.ny = ny;
        contact.px = cx + nx * cr;
        contact.py = cy + ny * cr;
        const vn = b.vx * nx + b.vy * ny;
        if (vn >= 0) {
            return 0;
        }
        b.vx -= (1 + e) * vn * nx;
        b.vy -= (1 + e) * vn * ny;
        return -vn;
    }

02A Flipper That Hands Its Spin Speed to the Ball

The flipper finds the point on the bar closest to the ball, turns the pivot-to-point vector by 90° and multiplies it by the angular speed to get the contact velocity used for the bounce. That is the heart of pinball flipper physics here: the tip moves at 12 rad/s × 86px = 1,032px/s and one swing takes 77ms, so a ball resting on top leaves at 815px/s from 0.3 of the length out and at 1,496px/s from the tip. On an event page with a browser mini game, the demo's zone rule stands in for a player's hand, and because it is a zone rule rather than a timetable, the same seed presses at the same moment.

A Flipper That Hands Its Spin Speed to the Ball
Open in a new tab

02 · A Flipper That Hands Its Spin Speed to the Ball — Measured area 12.542% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FLIPPER_SPEEDFLIPPER_LENGTHFLIPPER_REST_ANGLE
    // One step (h seconds) — turn toward the target angle, clamping the remaining gap to speed × h. At the end, w is 0
    function moveFlipper(f, h) {
        const target = f.pressed ? f.up : f.rest;
        const d = clamp(target - f.angle, -f.speed * h, f.speed * h);
        f.angle += d;
        f.w = d / h;
    }
    // Flipper and ball — find the closest point on the bar and add that point's spin speed (angular speed × distance from the pivot) to the relative velocity.
    // The farther from the pivot the hit lands, the faster the contact point and the harder the ball flies
    function hitFlipper(b, f, e) {
        const tip = flipperTip(f);
        const c = closestOnSegment(b.x, b.y, f.px, f.py, tip[0], tip[1]);
        const rx = c[0] - f.px;
        const ry = c[1] - f.py;
        // Contact velocity from the rotation — turn the pivot-to-contact vector by 90° and multiply by the angular speed
        const svx = -f.w * ry;
        const svy = f.w * rx;
        const vn = hitSegment(b, f.px, f.py, tip[0], tip[1], f.half, e, svx, svy, 0);
        if (vn > 0 && Math.abs(f.w) > 0.5) {
            f.hitR = Math.hypot(rx, ry);
            f.hitSurf = Math.abs(f.w) * f.hitR;
        }
        return vn;
    }

03A Pop Bumper That Kicks Back With a Set Force

The ball first rebounds off the bumper as a circle-circle collision at 0.5, then gets BUMPER_KICK 320px/s added along the contact normal, so balls coming straight down at 100, 300 and 600px/s leave at 370, 470 and 620px/s. The radius springs only in the drawing, swelling 11.8%, overshooting to −6.8% and staying inside ±1% from 480ms on, while the hit test keeps radius 24. It fits a banner for an app launch event where a few balls should keep bouncing, and over 10 s of the demo the four bumpers took 17 hits.

A Pop Bumper That Kicks Back With a Set Force
Open in a new tab

03 · A Pop Bumper That Kicks Back With a Set Force — Measured area 21.528% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BUMPER_KICKBUMPER_RADIUSBUMPER_STIFFNESS
    // Ball and bumper — a circle-circle collision pushes out the overlap and bounces, then a kick of the same strength is added along the normal, whatever the incoming speed.
    // The hit radius is r itself, not the swollen radius in the drawing (the swelling is drawing only)
    function hitBumper(b, m, e) {
        const vn = hitCircle(b, m.x, m.y, m.r, e);
        if (vn <= 0) {
            return vn;
        }
        b.vx += contact.nx * m.kick;
        b.vy += contact.ny * m.kick;
        m.dv += m.pop;
        m.flash = 1;
        m.hits += 1;
        return vn;
    }
    // Radius spring — a pull back proportional to the swelling (stiff × d) plus drag proportional to speed (damp × dv). The drag is small, so it overshoots once and returns
    function springBumper(m, h) {
        m.dv += (-m.stiff * m.d - m.damp * m.dv) * h;
        m.d += m.dv * h;
        m.flash *= Math.exp(-h * 7);
    }

04A Triangle Kicker That Bends Like a Rubber Band and Snaps

The triangle's long edge collides as a segment with a half-thickness of 2, and a 360px/s boost along the normal is added only when the incoming speed tops 40px/s and the contact normal faces largely the same way as the edge's outer side. The middle of the edge bends in the drawing only: a head-on hit at 150px/s dents it −2.24px, rebounds to +1.27px and stays within ±0.3px after 292ms, while the ball leaves at 435px/s. Hung as decoration on a game site's first screen, balls that slip through the center gap drop in again from the top, and 10 s of the demo counted 24 kicks.

A Triangle Kicker That Bends Like a Rubber Band and Snaps
Open in a new tab

04 · A Triangle Kicker That Bends Like a Rubber Band and Snaps — Measured area 16.78% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SLING_BOOSTSLING_BEND_MAXSLING_DAMPING
    // Long edge — bounce with a segment collision, then if the normal faces out of the edge and the hit was fast enough, add a boost along the normal and push the edge inward
    function hitSling(b, sl) {
        const vn = hitSegment(b, sl.a[0], sl.a[1], sl.c[0], sl.c[1], 2, RESTITUTION, 0, 0, 0);
        if (vn <= SLING_MIN) {
            return;
        }
        if (contact.nx * sl.nx + contact.ny * sl.ny < 0.5) {
            return;
        }
        b.vx += sl.nx * SLING_BOOST;
        b.vy += sl.ny * SLING_BOOST;
        sl.bendV -= vn * SLING_PRESS;
        sl.flash = 1;
        sl.hits += 1;
    }
    // Bend — damped wobble from the straightening force (stiff × bend) and drag (damp × bendV), depth clamped within SLING_BEND_MAX
    function bendSling(sl, h) {
        sl.bendV += (-SLING_STIFFNESS * sl.bend - SLING_DAMPING * sl.bendV) * h;
        sl.bend = clamp(sl.bend + sl.bendV * h, -SLING_BEND_MAX, SLING_BEND_MAX);
        sl.flash *= Math.exp(-h * 6);
    }

05A Plunger That Stores Force as It Is Pulled

While held, the plunger draws back at 60px/s to at most 40px; on release it returns with an acceleration of 900 times the pulled length, and the first time it passes 0 it hands 0.85 of its own speed to the ball as upward velocity. Pulls of 10, 20, 30 and 40px turn into launches of 255, 510, 765 and 1,020px/s (0.85 × √900 × pull), and the ball leaves 64ms after release. On a gamified sign-up celebration screen the time spent holding the button is the strength, and after the handoff the plunger overshoots 24px past its rest spot and stays within ±1px from 352ms.

A Plunger That Stores Force as It Is Pulled
Open in a new tab

05 · A Plunger That Stores Force as It Is Pulled — Measured area 8.451% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

PLUNGER_STIFFNESSPLUNGER_MAX_PULLLAUNCH_SCALE
            const before = p.pull;
            const a = -p.stiff * p.pull - (p.free ? 0 : p.damp * p.v);
            // Integration that updates velocity first
            p.v += a * h;
            p.pull += p.v * h;
            if (p.free && before > 0 && p.pull <= 0) {
                p.free = false;
                if (p.ball) {
                    const speed = Math.abs(p.v) * p.scale;
                    p.ball.vx = 0;
                    p.ball.vy = -speed;
                    p.lastPull = p.releasedPull;
                    p.lastLaunch = speed;
                    p.ball = null;
                }
            }
            // Released with no ball, or after only a tiny pull: let it wobble and come to rest
            if (p.free && p.releasedPull <= 0) {
                p.free = false;
            }

06Marbles Swept Off by a Spinning Windmill Bar

Two three-armed rotors turn in opposite directions at 6 rad/s, and the spin velocity of the point a marble touches goes straight in as the contact velocity. A resting marble placed 15, 30, 45 and 56px from the hub leaves at 170, 295, 423 and 514px/s once an arm strikes it, so the farther out the hit, the farther it flies. On a turn-order screen for a group game the seven marbles scatter differently from each hit: across 30 s of the demo, 15 hits within 25px of the hub averaged 260px/s and 79 hits beyond 45px averaged 496px/s.

Marbles Swept Off by a Spinning Windmill Bar
Open in a new tab

06 · Marbles Swept Off by a Spinning Windmill Bar — Measured area 28.824% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ROTOR_SPEEDROTOR_ARMSROTOR_LENGTH
    // Arm and ball — the spin velocity of the closest point on the arm becomes the contact velocity
    function hitArm(b, r, k) {
        const w = r.dir * ROTOR_SPEED;
        const tip = armTip(r, k);
        const c = closestOnSegment(b.x, b.y, r.x, r.y, tip[0], tip[1]);
        const rx = c[0] - r.x;
        const ry = c[1] - r.y;
        const vn = hitSegment(b, r.x, r.y, tip[0], tip[1], ROTOR_HALF, RESTITUTION, -w * ry, w * rx, 0);
        if (vn > 0) {
            lastR = Math.hypot(rx, ry);
            lastV = Math.hypot(b.vx, b.vy);
            r.glow[k] = 1;
        }
    }

07A Marble Race Ranked by Arrival Down the Ramps

Six marbles roll down four zigzag ramps, and every step on a ramp removes a 1 − e^(−0.4 × factor × h) share of tangential speed, with the factor set per marble by the seed between 0.7 and 1.3. On seed 4767 they finish E, B, A, C, D, F between 4.51 and 5.18 s, and with friction at 0 the same seed reorders them to B, E, C, A, F, D. Used as a draw page for team presentation order, the leaderboard reshuffles by course progress while marbles run and locks each row with its time as they cross the line.

A Marble Race Ranked by Arrival Down the Ramps
Open in a new tab

07 · A Marble Race Ranked by Arrival Down the Ramps — Measured area 14.235% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

RAMP_FRICTIONRAMP_SLOPEMARBLE_COUNT
    // Course progress — ramps already passed above × 1000 + distance rolled on the current ramp. Finished marbles rank by arrival order first
    function progressOf(b) {
        if (b.finishMs >= 0) {
            return 1e7 - b.finishMs;
        }
        let k = 0;
        for (let i = 0; i < ramps.length; i += 1) {
            const r = ramps[i];
            const x = clamp(b.x, Math.min(r[0], r[2]), Math.max(r[0], r[2]));
            if (rampY(r, x) < b.y) {
                k = i + 1;
            }
        }
        const on = ramps[Math.min(k, ramps.length - 1)];
        const along = Math.abs(clamp(b.x, Math.min(on[0], on[2]), Math.max(on[0], on[2])) - on[0]);
        return k * 1000 + along;
    }
    function standings() {
        const idx = [];
        for (let i = 0; i < balls.length; i += 1) {
            idx.push(i);
        }
        return idx.sort(function (a, b) { return progressOf(balls[b]) - progressOf(balls[a]); });
    }

08A Landing Board Whose Stacked Slots Reveal a Bell Shape

Sixty balls drop 70ms apart into nine slots, each slot's count is tallied, and every bar closes 0.14 of its remaining gap per tick beneath an expected curve drawn from binomShare. In the seed 4768 demo, 16 of 60 balls (26.7%) landed in the middle slot, close to the expected 27.3%. Once all have dropped, the shuffled name tags glide into place over 700ms and the H in slot 7, where the lemon ball fell, wins, which lets a page explaining a fair prize draw show both the lean and the shuffle on one screen.

A Landing Board Whose Stacked Slots Reveal a Bell Shape
Open in a new tab

08 · A Landing Board Whose Stacked Slots Reveal a Bell Shape — Measured area 8.804% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

DROP_INTERVAL_MSSLOT_COUNTSHUFFLE_SLOTS
    // Binomial expected share — if every row splits half and half, share of slot k = (ways to choose k of ROWS) / 2^ROWS
    function binomShare(k) {
        let c = 1;
        for (let i = 0; i < k; i += 1) {
            c = c * (ROWS - i) / (i + 1);
        }
        return c / Math.pow(2, ROWS);
    }

09A Mini Pinball Table With Flippers, Bumpers and a Scoreboard

Two flippers (length 70) driven by the same functions as 02, three bumpers from 03 (radius 19, 300px/s) and the 05 plunger share one table, where 100 points per bumper and 10 per flipper hit roll up over 600ms on a curve that is slow at both ends. The "Nudge" button adds 160px/s sideways and 56px/s upward to the ball on the table and gives the screen only a spring speed, so the view shifts 6.39px, rebounds to −3.79px and settles within ±0.5px at 592ms while the physics coordinates stay put. On the first screen of a game launch landing page, 20 s of demo piles up 1,580 points and 6 nudges on its own.

A Mini Pinball Table With Flippers, Bumpers and a Scoreboard
Open in a new tab

09 · A Mini Pinball Table With Flippers, Bumpers and a Scoreboard — Measured area 14.274% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TABLE_GRAVITYNUDGE_IMPULSESCORE_ROLL_MS
    // Nudge — add the same impulse to the ball on the table and give the screen a spring speed (the screen shake is drawing only; the ball only gets the impulse)
    function nudge() {
        if (!plunger.ball && !ball.out) {
            ball.vx += nudgeDir * NUDGE_IMPULSE;
            ball.vy -= NUDGE_IMPULSE * 0.35;
        }
        shakeV += nudgeDir * SHAKE_KICK;
        nudgeDir = -nudgeDir;
        nudges += 1;
    }

Push Out the Overlap, Bounce Along the Contact Direction — Circle and Segment Collisions and the Contact Speed of a Spinning Flipper

Circle collision that reads only overlap depth and normal speed

hitCircle measures the distance d between the ball and pin centers, and when it is shorter than the two radii added together, it pushes the ball out by the difference along the line joining the centers. It then splits the velocity into the part vn along that line and the rest, and only when vn points inward does it subtract (1 + e) × vn, which turns the normal part into −e times itself. The sliding part is left untouched, so a ball grazes the side of a pin and leaves at a slant, and with 01's e of 0.5, half of the approaching normal speed survives as bounce. The falling text physics post treats each letter as a box with corners, while here ball and pin are both circles, so one center line settles the whole contact.

A segment turns into a circle at its closest point

The flippers in 02 and 09, the long edge in 04, the arms in 06 and the ramps in 07 are all segments. closestOnSegment drops a perpendicular from the ball center onto the segment and clamps it to the 0 to 1 range, and that point is then handled as a circle whose radius is half the line thickness, with the same push-out and bounce. The one difference from a fixed circle is that the contact point can move, so hitSegment measures the bounce on the relative velocity, the ball's velocity minus the contact velocity (svx, svy). That is also why 07 needs no separate downhill force: the collision removes only the part perpendicular to the ramp and leaves the share of gravity that runs along it.

Speed of a point on a spinning bar equals angular speed times distance

Rotating the pivot-to-contact vector (rx, ry) by 90° gives the direction that point travels, and multiplying by the angular speed w gives the contact velocity (−w·ry, w·rx). Its size is w × r, so 02's tip at 86px runs 12 × 86 = 1,032px/s and 06's tip at 58px runs 6 × 58 = 348px/s. The paddle in brick breaker game patterns chooses a bounce angle from where the ball lands, but on a flipper the hit position is that point's speed, so angle and strength change together. The uneven results at 0.6 and 0.7 of the length in 02 (1,269 and 1,057px/s) likely come from the contact point sliding along the bar mid-swing.

How a 40px pull becomes 1,020px per second

05's plunger gets a pushback acceleration of 900 × x for a pull of x. If all the stored energy comes back, its speed at the moment it passes 0 is √900 × x = 30x, and multiplying by LAUNCH_SCALE 0.85 gives the 25.5x handed to the ball. That makes 1,020px/s for 40px and 255px/s for 10px, but a 10px shot does not clear the lane: it peaks at y 204 and drops back onto the plunger. Drag stays at 0 until the first pass through 0 after release so the pull is returned in full, and the drag of 24 switches on only once the ball has been handed off.

Why Does a Marble Race Name Picker Keep Landing in the Middle Slots?

Eight rows of even splits and where the balls gather

In 08 each ball passes 8 rows of pins and goes left or right at every row. Its count of right turns is the slot it lands in, and there are 70 paths with exactly four right turns out of eight but only 1 path with all eight. Out of 256 paths, that gives the middle slot 70/256 = 27.3% and each end 1/256 = 0.4%, and dropping 3,000 balls for real gave 28.7% in the middle and 0.3% and 0.2% at the two ends. The bell depends on cutting sideways speed to 0.45 at each pin hit and mixing in a seeded ±55px/s; with jitter at 0 and sideways speed kept as it was, the slot shares spread to a nearly flat 7.9 to 13.0% and the bell was gone.

Shuffled names bring the wins back toward even

A lean toward the middle means names pinned to fixed slots inherit that lean. Over 300 boards on seeds 1000 to 1299 without shuffling, the middle names D, E and F won 51 to 52 times each, while A at one end won 3 times and I at the other won 4. With SHUFFLE_SLOTS on, each board swaps name tags from the back one spot at a time using the seeded random, cutting the link between slot and name, and the same 300 boards gave each name 27 to 40 wins. An even split would be about 33 per name, so the counts gather around that. These boards count only the slot of the single lemon ball, which has a radius of 5 against 3.5 for the ordinary balls tallied in the 3,000-ball run, and that larger ball flattens the spread: unshuffled, the middle slot won 52 of 300 boards (17.3%), and with its radius set to 3.5 it won 91 (30.3%).

        // Name assignment — when shuffling, swap one spot at a time from the back using the seeded random
        names = LABELS.slice(0, SLOT_COUNT);
        if (SHUFFLE_SLOTS) {
            for (let i = names.length - 1; i > 0; i -= 1) {
                const j = Math.floor(rng() * (i + 1));
                const t = names[i];
                names[i] = names[j];
                names[j] = t;
            }
        }

The slots still crowd toward the middle, but which name sits in a middle slot changes from board to board. The shuffle does not remove the lean; it mixes up who receives it.

Higher Bounces, Faster Flippers, Harder Bumpers — Variations That Change One Value Each

Variant Changed value How it feels
Make the balls bounce higher 01 RESTITUTION 0.5 → 0.8 Pin contacts rise from 127 to 151 as the balls ping around, the winner moves from A at 1,040ms to E at 1,456ms, the last ball from 3,360ms to 5,744ms, and the full settle from 5,616ms to 9,712ms
Swing the flipper faster 02 FLIPPER_SPEED 12 → 20 The tip goes from 1,032px/s to 1,720px/s, a swing shrinks from 77ms to 46ms, and over 20 s of demo the ball leaves each hit at 1,041 to 2,623px/s instead of 683 to 1,815px/s (17 hits become 20)
Make the bumper kick harder 03 BUMPER_KICK 320 → 640 A ball arriving from above at 300px/s leaves at 790px/s instead of 470px/s, and bumper hits in 10 s climb from 17 to 53

All three make the board busier, but each one costs something different. In 01 the wait for a result grows by more than 4 s and the winner changes from A to E, so a single bounce value picks a different winner on the same seed. Raising 02 to 20 while computing each tick as a single step grows the spots where the ball tunnels from 1 to 5, so read it together with the next section, and in 03 the hits more than triple, which appears to keep the ball bouncing between bumpers for longer.

Fast Balls Tunneling Through a Thin Flipper, Marbles Wedged Between Pins, a Resized Stage — Where Pinball Physics Breaks

Moving more than 16px in one step skips the bar

In one tick (16ms) 02's tip travels 1,032 × 0.016 = 16.51px. The ball counts as touching the bar within 16px, its radius of 10 plus the half-thickness of 6, so with one step per tick (SUBSTEPS 1) the tip can leap clean past the ball. Resting the ball at eight spots on top of the bar (0.3 to 1.0 of the length from the pivot) and pressing, SUBSTEPS 1 let it tunnel through at the tip spot only, and 2, 3 and 6 let it through nowhere. Raising FLIPPER_SPEED to 20 stretches a step to 27.52px, and SUBSTEPS 1 then failed at five spots from 0.6 to 1.0, while 2 steps (13.76px) brought it back to zero. With the 6 steps the demo uses, a step covers 2.75px, far under the 16px contact distance. 01's pin board follows the same pattern: the deepest a ball sank into a pin went from 3.38 to 0.87 to 0.58px at SUBSTEPS 1, 4 and 8, and since pin plus ball radius is 12px, nothing tunneled there.

One archive holds the collision helpers, all nine index.html stages and React builds generated from the same source; type z3hydw63 when it asks for the password.

A ball balanced on the exact top of a pin

A ball set at rest on a perfectly symmetric spot gets pushed only upward, with no reason to roll off either side. With one ball placed on top of the center pin at (240, 74) and STUCK_KICK set to 0, it was still at x 240.00 after 300 ticks (4.8 s). At 90, a ball moving under 12px/s for 500ms above the finish line counts as stuck, so it took one kick at 512ms, cleared the pin by half a pin spacing (22px) at 768ms and dropped to a bottom slot. In a busy stream raffle a single ball parked above the line would keep the board from finishing, and that one small kick is what lets it end.

Running physics in screen pixels changes the winner on the same seed

Physics runs in 480×300 logical coordinates whatever the display size, and fitCanvas multiplies by display width / 480 only when drawing. With a display width of 418.6px in a 480 window, 294px in a 320 window and a device ratio of 3, running the same seed to the end gave winner A at 1,040ms and the same final x for all eight balls to three decimal places, and only the canvas pixels differed at 419, 294 and 837. Had the physics run in screen pixels, the same 760px/s² would act 480/294 times as strong in logical terms, and in the 320 window the same seed would have picked C at 1,376ms instead.

    function fitCanvas() {
        // Cap the ratio at 2 — so a ratio-3 device does not get 2.25 times the pixels and that much more work per frame
        const dpr = Math.min(window.devicePixelRatio || 1, 2);
        const cssW = cv.getBoundingClientRect().width || W;
        viewScale = cssW / W;
        const k = viewScale * dpr;
        cv.width = Math.max(1, Math.round(W * k));
        cv.height = Math.max(1, Math.round(H * k));
        // Only when drawing, multiply by the logical-coordinate-to-pixel scale
        ctx.setTransform(k, 0, 0, k, 0, 0);
    }

Announce the Winner in Text Too — A Live Result Region and Reduced-Motion Accessibility

One status line reads the result aloud

Every stage writes its result as text in a status line, <p data-role="status">, under the canvas, and when the demo boards finish they read "Winner: A" in 01, "Ranking: E, B, A, C, D, F" in 07 and "Winning slot 7 → H" in 08. The canvas carries aria-hidden="true", so reading falls to that line. While the demo runs, aria-live stays off so repeated demo results are not announced, and the first button press switches it to polite (aria-live on MDN). The text is rewritten only when its content changes, and a fast-moving state such as 05's pull is rounded to steps of 5px and 10px/s.

Reduced motion shows one finished board

With prefers-reduced-motion: reduce, none of the nine stages starts the animation loop; each computes the same seeded board to its end with settle() and draws that last scene once, with the result text (prefers-reduced-motion on MDN). 01, 07 and 08, which have an end, stop when their result arrives within 1,200, 1,300 and 900 ticks, and the open-ended 02 to 06 and 09 show the scene after 80 to 240 ticks. Pressing a button in this mode computes straight to that input's outcome and draws once, so in 05 pressing shows the pulled plunger and releasing shows the scene after launch. Switching the setting midway is picked up at once through the change event.

Hold-down buttons work from the keyboard too

Every control is a real <button type="button">. The buttons in 02 and 05 and the two flippers in 09 act only while held, so along with pointer down and up they take keydown and keyup for Space and Enter, keeping the flipper raised and the plunger pulled while the key is down. Losing focus (blur) counts as a release, the focus ring is a 2px solid :focus-visible outline, and 09's "◀ Flipper" and "Flipper ▶" are read as "Left flipper" and "Right flipper" through aria-label.

Common Questions Before Putting a Raffle Board or Pinball Table on Your Site

Can this pinball html5 board run a real event draw?

The physics gives the same result for the same input, so fairness comes down to where the seed comes from. With 4761 baked in as in the demo, anyone can run the result ahead of time, so passing in a number picked in front of the participants right before the draw, or a value issued by a server, is the safer route. If you draw by slot with 08, keep the name shuffle on so the pull toward the middle slots does not pile onto particular names.

Which stage should I study first when building a pinball game for the first time?

Start with something like 01, where balls hit circles that stay put. hitCircle, gravity and a fixed tick are enough to send balls through the pins; add segment collision and you get 04's long edge and 07's ramps, add contact velocity and you get 02's flipper and 06's windmill. 09 combines all three, so it makes sense to open it last, and if path finding on a grid in the same game category interests you, the maze generator post makes a good next read.

How do I stop balls from passing through walls or vanishing off screen?

Check the distance moved in one step first. The fastest object should travel less per step than the ball radius plus half the wall thickness, and 02 splits each tick into 6 steps to hold the flipper tip's move to 2.75px. When a tab sits in the background for a while, the gap between screen updates grows large, and all nine stages cap catch-up at 100ms and 6 ticks per frame so balls do not leap far in one go.

Enter the archive password

The password is inside this article. You will find it as you read.