Building Pulse Weave: A Rhythm-Timing Game as an npm Package
Why I Built Pulse Weave
Pulse Weave started from a visual: concentric rings rotating at different speeds, each with a gap. A dot sits in the center. Tap to advance outward. If the gap is aligned when you advance, you pass through. If not, you bounce back. The concept is pure timing distilled into its simplest form.
One-button games have a specific design challenge: all depth must come from timing and level design, not from control complexity. Every game in this series has a different input model. Signal Hop is tap-to-navigate, Drift Well is drag-to-aim, Glyph Lock is tap-to-fill, Spore Field is tap-to-place. Pulse Weave is just tap.
Ring System
Each ring is defined by its radius, rotation angle, rotation speed, gap position, and gap size. Rings rotate continuously. Adjacent rings rotate in opposite directions, creating a visual weaving pattern. The outermost rings rotate faster and have smaller gaps, making them harder to pass through.
function generateRings(level: number, config: GameConfig): Ring[] {
const count = getRingCount(level);
const rings: Ring[] = [];
for (let i = 0; i < count; i++) {
const radius = config.innerRadius + (i + 1) * config.ringSpacing;
const direction = i % 2 === 0 ? 1 : -1;
rings.push({
radius,
angle: Math.random() * Math.PI * 2,
speed: getRotationSpeed(i, level) * direction,
gapAngle: Math.random() * Math.PI * 2,
gapSize: getGapSize(i, level),
});
}
return rings;
}Ring count increases by one every two levels, starting at 3 and capping at 10. The first few levels are gentle introductions. By level 15, you are threading through 10 rings with varying speeds, directions, and gap sizes.
Gap Alignment Detection
The core math problem: when the player taps, determine if the gap in the next ring is aligned with the player's current angle. This sounds simple, but handling the angular wrap-around at 0/2PI requires careful normalization.
function isGapAligned(playerAngle: number, ring: Ring): boolean {
const gapCenter = normalizeAngle(ring.gapAngle + ring.angle);
const halfGap = ring.gapSize / 2;
let diff = normalizeAngle(playerAngle - gapCenter);
if (diff > Math.PI) diff -= Math.PI * 2;
return Math.abs(diff) <= halfGap;
}The normalizeAngle function maps any angle to the [0, 2PI) range. The diff calculation handles the wrap-around case: if the gap spans across the 0-degree boundary, a naive subtraction would give the wrong result. Normalizing the difference and checking if it exceeds PI corrects for this.
Player Movement and Animation
When the player taps, the dot does not teleport. It animates outward over 150ms using a lerp between the current ring radius and the next. This brief animation creates visual feedback and a small input delay that prevents spam-tapping. If the gap is not aligned, the bounce animation sends the dot back to the center with a red flash.
During animation, input is disabled. The isBouncing and isAdvancing flags on the player state prevent new taps from registering until the current animation completes. This makes the game feel deliberate rather than frantic.
Visual Design
Canvas 2D rendering draws rings as arc segments (the solid parts) with the gap left undrawn. The player dot has a subtle glow effect using radial gradients. Successfully passing through a ring triggers a brief pulse animation on that ring.
The color scheme uses the portfolio's accent color (#64ffda) for the player and cleared rings. Uncleared rings are rendered in a muted gray. The current target ring pulses brighter, drawing the player's attention to where they need to time their next tap.
Level Progression
Completing all rings in a level advances to the next. The player keeps their score (total levels cleared) and lives (starts at 3, no replenishment). The game becomes a survival run: how many levels can you clear before three bounces end your run?
Ring parameters change per level: gap sizes shrink from 0.8 radians to 0.3 radians, rotation speeds increase, and ring count grows. The difficulty curve is steeper than the other games in this series because the core mechanic is simple enough to master quickly.
Testing Approach
48 tests across three files cover rings, player mechanics, and game state. The most interesting tests verify gap alignment detection with edge cases: gaps that span the 0-degree boundary, gaps at exactly PI, and gaps at the minimum size. Floating-point precision matters here, so the tests use generous but well-defined epsilon values.
The state machine tests verify the full game lifecycle: start, advance through rings, complete a level, start the next level, bounce, and game over. Each transition is tested as a pure function call, making the tests fast and deterministic.
Lessons Learned
One-button games live or die by feel. The 150ms advance animation, the bounce-back timing, the gap size tolerance, all these parameters define whether the game feels fair or frustrating. I spent more time tuning these numbers than writing the game logic.
The opposite-direction rotation of adjacent rings creates a hypnotic visual that doubles as a gameplay element. Players naturally sync with the rotation rhythm, and the direction changes break that sync, requiring conscious timing adjustment at each ring. It is emergent difficulty from a simple visual choice.
Get more posts like this
I write about system design, backend engineering, and building npm packages from scratch. Follow along on Substack for new posts.
Subscribe on Substack →