Ember Beats - Project Story
The Spark
I've always loved rhythm games. That thing where your hands sync up with a beat and everything else melts away. I wanted to build something that felt like that, but with a visual identity that felt alive. Instead of cold note highways and static UI, I imagined a tiny flame character that would feel the music with you. Bobbing along, getting excited on combos, drooping on misses.
That character became Ember. A reactive flame mascot drawn with pure SVG. When you hit a Perfect, Ember burns brighter. When you miss, Ember flickers sadly. The game itself is simple: notes fall in four lanes, mapped to the D, F, J, and K keys. Hit them in time, keep your combo alive, and watch Ember grow.
"Strike the beat. Feed the flame."
How It Works
The game loop runs at roughly 60fps using a setInterval in React. Notes spawn on each beat interval and take a set amount of time to fall toward the receptor zone. The judgement system measures the delta between when you pressed and when the note was supposed to be hit:
$$\Delta t = |t_{\text{input}} - t_{\text{target}}|$$
- Perfect (\(\Delta t \leq 150\text{ms}\)) -- +10 points, triggers a shockwave ring
- Good (\(150\text{ms} < \Delta t \leq 350\text{ms}\)) -- +5 points
- Miss (\(\Delta t > 350\text{ms}\) or no input) -- breaks combo, loses a life
As your score goes up, the BPM scales dynamically:
$$\text{BPM}(n) = \text{BPM}_0 + \left\lfloor\frac{n}{15}\right\rfloor$$
where \(\text{BPM}_0\) depends on difficulty (Easy: 66, Normal: 80, Hard: 100). Caps at 140, so things get hectic fast.
Your score feeds into Ember's visual state too. Ember gets bigger as your combo racks up, and orbiting baby flames appear at score thresholds (30, 70, 120). Combo milestones trigger banner popups.
Lives = Streak
Your daily streak determines how many lives you start with. A 10 day streak means 10 lives. Each miss costs 1 life, and Ember shrinks proportionally as lives drop (55px base, +5px per life, capped at 220px). The home screen shows Ember growing the same way based on your current streak. New players get a minimum of 3 lives.
How It's Built
| Layer | Technology |
|---|---|
| Framework | React 19 + Vite |
| Styling | Tailwind CSS |
| Animation | Framer Motion + CSS keyframes |
| Audio | Web Audio API (procedural percussion) |
| Routing | React Router v7 |
| Database | Firestore (stats via device ID or Google UID) |
| Auth | Firebase Auth (Google sign-in, optional) |
| Persistence Fallback | localStorage |
| Hosting | Firebase Hosting |
| Build/Runtime | Rolldown (via Vite) |
Audio - Zero Audio Files
All percussion sounds are synthesized at runtime using the Web Audio API. Four instruments, each with a different synthesis strategy:
- Bass Drum (D) -- Sine oscillator with rapid frequency sweep (\(150\text{Hz} \to 0.01\text{Hz}\))
- Snare (F) -- White noise buffer through a highpass filter at 1kHz
- Hi-Hat (J) -- White noise buffer through a highpass filter at 7kHz, very short decay
- Wood Block (K) -- Square wave oscillator at 800Hz with fast gain envelope
This means zero audio dependencies for core gameplay. Works offline, instantly, with no loading.
The Mascot - Ember
Ember is a pure SVG flame inside a Framer Motion wrapper. The flame shape uses two cubic bezier paths (outer and inner), each filled with linear gradients. The flame wobbles on a looping y animation. On excited mood (perfect hit), the SVG group scales rapidly in a scaleX/scaleY rhythm. On sad (miss), it droops vertically with dimmed flame opacity and animated tear drops.
When you have a high streak, Ember appears bigger on the home screen and in-game. Lose a life and Ember shrinks accordingly. The death screen shows Ember at minimum size with the sad animation.
Authentication & Sync
Google sign-in is optional and handled through Firebase Auth. When signed in, stats are stored under the user's Google UID in Firestore instead of a random device ID. On sign-in, any device-local stats get merged into the user's account so nothing is lost. Sign out falls back to the device ID. All of this is transparent to the game logic -- syncStatsToFirebase and loadStatsFromFirebase just use whichever ID is current.
Game State Machine
countdown -> playing -> gameover
^ |
+----------+ (Play Again)
The countdown phase is a simple 3-2-1-GO using Framer Motion AnimatePresence transitions. Once playing, the game loop spawns circles at each beat interval and checks for expired notes (misses) every frame. On gameover, a score tally animation counts up from 0 to the final score.
Themes
Five dark themes (Campfire, Ocean, Forest, Midnight, Dawn). Each defines 11 color tokens for backgrounds, accents, borders, and text. The theme context persists to localStorage and wraps the whole app via React Context.
Challenges Faced
1. Timing Accuracy
The biggest challenge was making the rhythm feel right. The game loop runs every 16ms (~60fps), but JavaScript timers are inherently imprecise. I used relative timestamps (Date.now() - gameStartTime) instead of frame counters to decouple timing from render frames. The 350ms hit window gives enough forgiveness without feeling sloppy.
2. Ember Movement
Ember has to jump between note positions smoothly. The first version used framer-motion animate calls, but rapid note spawns created input lag. I switched to CSS transitions on left/top with a dynamic jumpDuration (\(0.55 \times \text{beatInterval}\)) so Ember's movement stays synchronized with the BPM.
3. State Synchronization
Managing game state in React while updating circles, scores, combo, lives, Ember position, Ember mood, shockwaves, and judgement text -- all at 60fps -- required careful use of useCallback and useRef to avoid stale closures. The worst bug was handleMiss having a stale score reference inside setLives, which I fixed with functional state updates.
4. Mobile Responsiveness
The game was built desktop-first (keyboard input), but the play area adapts to any screen width. Lanes are computed dynamically from the play area's getBoundingClientRect, so the same code handles mouse clicks on mobile with onClick coordinate mapping. The settings gear button originally sat behind a glow div on narrow screens -- fixed by making the glow div ignore pointer events and giving the header a higher z-index.
On touch devices, key labels (D/F/J/K) are hidden, lane receptors are larger (72px vs 64px), and tapping creates visible ripple effects. Automatic detection via ontouchstart and maxTouchPoints switches the experience without any manual toggle.
What I Learned
- Web Audio API -- Synthesizing drums procedurally is surprisingly expressive. A 50ms white noise burst with a highpass filter sounds basically identical to a hi-hat sample.
- Framer Motion + SVG -- Animating SVG paths and group transforms with Framer Motion gives you character animation without any sprite sheets or external assets. The flame uses two cubic bezier paths with linear gradients, and mood states are pure transform animation.
- Game loop design in React -- Using refs for mutable game state and state for render-critical data keeps the UI responsive without over-rendering.
- LaTeX in rhythm games -- The timing math boils down to absolute deltas and threshold comparisons, which maps cleanly to mathematical notation.
What's Next
- Leaderboard and daily challenges
- Custom beat maps / BPM patterns
- Better onboarding for non-rhythm-game players
Team Name: Ember Beats
Members: Himnish Dukkola
Contact: [email protected]
Built with React, Vite, TailwindCSS, Framer Motion, and the Web Audio API.
Deployed on Firebase at emberbeats.web.app.
Follow the flame.
Built With
- framer-motion
- react
- tailwindcss
- vite
- web-audio-api
Log in or sign up for Devpost to join the conversation.