G GAMESRICH
Anti-Stress Games · Intermediate

How to Build a Multi-Toy Fidget Board: Fidget Santai

Simulate rotational inertia, flick velocity physics, toggle switches, and balloon pop cycles.

By GamesRich Engineering 7 min read Zero External Libraries

Play the Live Game First

Experience the mechanics firsthand to understand what you are building.

PLAY LIVE GAME ↗

Architecture & Core Concept

The fidget board combines four distinct interactive sensory components: 1. The Fidget Spinner: - Maintains angle and velocity - Swipe gesture measures swipe distance and duration: spinSpeed = (distance / elapsed) * 950 - Each frame: angle += velocity * dt; velocity *= Math.pow(0.13, dt) 2. Mechanical Rocker Switch: - Toggles boolean state with realistic click audio and ambient card glow 3. Expanding Balloon: - Taps incrementally scale up the balloon (scale(0.65 + taps * 0.15)) - Tap 5 triggers a loud pop tone, haptic rumble, and resets 4. Sensory Clicker: - Instant auditory click feedback with persistent counter.

Key Game Mechanics Taught:

Step-by-Step Implementation

1

Model Rotational Inertia with Angular Friction

The spinner rotates based on angular velocity, decaying continuously with exponential damping.

Step 1 Implementation
let angle = 0;
let velocity = 0;

function updateSpinner(dt) {
  angle += velocity * dt;
  velocity *= Math.pow(0.13, dt); // Smooth bearing friction
  if (Math.abs(velocity) < 0.1) velocity = 0;

  spinnerEl.style.transform = `rotate(${angle}deg)`;
}
2

Calculate Pointer Flick Velocity on Swipe Release

When the user releases a swipe on the spinner, calculate swipe distance and elapsed time to apply proportional angular momentum.

Step 2 Implementation
let dragStart = null;

zone.addEventListener('pointerdown', e => {
  e.preventDefault();
  dragStart = { x: e.clientX, y: e.clientY, time: performance.now() };
});

zone.addEventListener('pointerup', e => {
  if (!dragStart) return;
  const dx = e.clientX - dragStart.x;
  const dy = e.clientY - dragStart.y;
  const elapsed = Math.max(60, performance.now() - dragStart.time);
  const distance = Math.hypot(dx, dy);

  // Apply spin impulse
  const speed = (distance / elapsed) * 950;
  const direction = (dx + dy < 0) ? -1 : 1;
  velocity += speed * direction;

  playSpinSound();
  dragStart = null;
});
3

Assemble Mechanical Switch and Balloon Pop

Connect modular event listeners for toggle switches and inflating balloon scales.

Step 3 Implementation
let balloonTaps = 0;

balloon.addEventListener('click', () => {
  balloonTaps++;
  if (balloonTaps < 5) {
    balloon.style.transform = `scale(${0.65 + balloonTaps * 0.15})`;
    playClickSound();
  } else {
    // Pop!
    playPopSound();
    navigator.vibrate?.(25);
    balloon.style.visibility = 'hidden';
    setTimeout(() => {
      balloonTaps = 0;
      balloon.style.transform = 'scale(0.65)';
      balloon.style.visibility = 'visible';
    }, 800);
  }
});

Complete Standalone Source Code

Here is the full, self-contained implementation. Save this as an index.html file on your computer and open it in any web browser to run your game immediately:

Complete Runnable Game (index.html)
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Fidget Board Demo</title>
<style>
body { margin:0; background:#0f172a; display:flex; justify-content:center; align-items:center; height:100vh; font-family:sans-serif; }
.board { display:grid; grid-template-columns:1fr 1fr; gap:20px; padding:24px; background:#1e293b; border-radius:16px; width:360px; }
.toy { background:#334155; border-radius:12px; height:140px; display:flex; flex-direction:column; align-items:center; justify-content:center; color:#fff; cursor:pointer; user-select:none; }
#spinner { width:70px; height:70px; background:#f43f5e; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:32px; }
#switch { width:60px; height:32px; background:#64748b; border-radius:16px; position:relative; transition:background 0.2s; }
#switch.on { background:#22c55e; }
#switch:after { content:""; width:26px; height:26px; background:#fff; border-radius:50%; position:absolute; top:3px; left:3px; transition:transform 0.2s; }
#switch.on:after { transform:translateX(28px); }
</style></head><body>
<div class="board">
  <div class="toy" id="spinZone">
    <div id="spinner">⚙️</div>
    <small style="margin-top:8px">Swipe to Spin</small>
  </div>
  <div class="toy" id="switchToy">
    <div id="switch"></div>
    <small style="margin-top:12px">Toggle Switch</small>
  </div>
</div>
<script>
const spinner = document.querySelector('#spinner'), spinZone = document.querySelector('#spinZone');
const sw = document.querySelector('#switch'), swToy = document.querySelector('#switchToy');

let angle = 0, vel = 0, last = performance.now(), drag = null;
spinZone.onpointerdown = e => { drag = { x: e.clientX, t: performance.now() }; };
window.onpointerup = e => {
  if(!drag) return;
  const dx = e.clientX - drag.x, dt = Math.max(50, performance.now() - drag.t);
  vel += (dx / dt) * 1200; drag = null;
};

swToy.onclick = () => sw.classList.toggle('on');

function loop(t) {
  const dt = Math.min((t-last)/1000, 0.04); last = t;
  angle += vel * dt; vel *= Math.pow(0.12, dt);
  spinner.style.transform = `rotate(${angle}deg)`;
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body></html>

Ideas to Expand Your Game

Now that you have the core mechanics running, consider adding: