← Back to Blog

Building Signal Hop: A Browser Game as an npm Package

Play Signal Hop →
Share

Why I Built Signal Hop

I wanted to build original browser games for my portfolio, not clones of existing ones. Signal Hop came from a simple question: what if maintaining a network was a game? You are a signal hopping between nodes, and every node you leave starts dying. When a node dies, its connections break. If all your neighbors are dead, you are stranded and the game is over.

The constraints were clear: Canvas 2D for rendering, no game frameworks, mobile-friendly with touch support, and packaged as a reusable npm component. The game needed to be simple enough to learn in 30 seconds but deep enough to reward strategic thinking.

Game Design

Signal Hop is a reflex/strategy hybrid. You start on a random node in a procedurally generated network graph. Every other node has a countdown timer. When a timer reaches zero, that node dies and all its edges disappear. Hopping to a node resets its timer. Your score is the number of hops you complete before getting stranded.

The difficulty ramps over time. Node timers start at 5 seconds and decrease by 0.5 seconds every 45 seconds, down to a minimum of 2 seconds. Early game is relaxed, letting you learn the graph layout. Late game is frantic, forcing quick decisions about which nodes to save and which to sacrifice.

The key strategic element: saving the nearest dying node is not always optimal. If you hop to a dying node on the edge of the graph, you might strand yourself. The best players think 2-3 hops ahead, maintaining circuits through the graph rather than reacting to the most urgent timer.

Graph Generation Algorithm

The network is generated procedurally each game. Ten nodes are placed randomly in a normalized 0-1 coordinate space, with a minimum spacing constraint of 0.15 to prevent overlapping. Nodes are placed one at a time, rejecting candidates that are too close to existing nodes.

typescript
function generateNodePositions(count: number) { const positions: { x: number; y: number }[] = []; let attempts = 0; while (positions.length < count && attempts < count * 100) { const x = EDGE_PADDING + Math.random() * (1 - 2 * EDGE_PADDING); const y = EDGE_PADDING + Math.random() * (1 - 2 * EDGE_PADDING); const candidate = { x, y }; const tooClose = positions.some( (p) => distance(p, candidate) < MIN_NODE_SPACING ); if (!tooClose) positions.push(candidate); attempts++; } return positions; }

Edges are created using proximity. Each node connects to its 2-4 nearest neighbors. This creates an organic-looking network where nearby nodes are connected and distant nodes are not. After building the adjacency list, a BFS check verifies the graph is fully connected. If any nodes are isolated, bridge edges are added to the nearest visited node.

This approach produces graphs that look different every game but always have consistent properties: every node is reachable, no node has fewer than 2 connections, and the layout feels natural rather than random.

Game Loop Architecture

The game loop uses requestAnimationFrame for smooth rendering. A custom useGameLoop hook manages the animation frame lifecycle and computes delta time between frames. The delta is capped at 100ms to prevent huge jumps when the browser tab is backgrounded.

Game Loop Flow
requestAnimationFrame
    |
    v
[Compute Delta] -- cap at 100ms
    |
    v
[tick(state, delta)] -- decrease timers, kill expired nodes
    |
    v
[Check Game Over] -- any alive neighbors left?
    |
    v
[Render Canvas] -- edges, nodes, timer arcs, glow effects
    |
    v
[Next Frame]

The game state is pure TypeScript with no DOM dependencies. The tick function takes a state and a delta in seconds, returns a new state with decreased timers, dead nodes, and a game-over check. The hop function validates the target is an alive neighbor, moves the player, and resets the target's timer. Both functions are pure, making them easy to test.

typescript
export function tick(state: GameState, deltaSeconds: number): GameState { if (state.phase !== 'playing') return state; const nodes = state.nodes.map((node) => { if (!node.alive || node.id === state.currentNodeId) return node; const newTimer = node.timer - deltaSeconds; if (newTimer <= 0) { return { ...node, timer: 0, alive: false }; } return { ...node, timer: newTimer }; }); const newState = { ...state, nodes, elapsed: state.elapsed + deltaSeconds }; if (isGameOver(newState)) return { ...newState, phase: 'over' }; return newState; }

Canvas 2D Rendering

Rendering uses the Canvas 2D API directly. No libraries, no abstractions. The drawing order matters: background first, then edges between alive nodes, then the nodes themselves with their timer arcs, then the current-node glow effect. Dead nodes get a subtle X marker that fades out.

Each node displays a circular timer arc, like a pie countdown. The arc color shifts from green to yellow to red as the timer decreases. The current node has an accent-colored glow ring, and reachable nodes pulse gently to indicate they can be tapped. All positions are stored as normalized 0-1 coordinates and scaled to canvas dimensions at render time, making the game resolution-independent.

Responsive sizing is handled by a ResizeObserver on the container element. The canvas dimensions update to match the container while accounting for device pixel ratio, keeping the rendering crisp on high-DPI screens.

Mobile-First Input Handling

Input uses the Pointer Events API for unified mouse and touch handling. A single onPointerDown handler covers both desktop clicks and mobile taps. Hit detection computes the distance from the pointer position to each reachable node center, selecting the closest node within a 30-pixel touch target radius.

Only reachable nodes (alive neighbors of the current node) register taps. This prevents frustrating misclicks on dead or disconnected nodes. The canvas sets touch-action: none to prevent scroll and zoom gestures from interfering with gameplay.

Packaging as npm

The game ships as @gagandeep023/signal-hop on npm with three subpath exports: ./frontend for the React component, ./types for TypeScript types, and ./frontend/styles.css for the stylesheet. React and ReactDOM are peer dependencies, so the package stays small and avoids version conflicts.

json
{ "exports": { "./frontend": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" }, "./types": { "types": "./dist/types.d.ts", "import": "./dist/types.mjs", "require": "./dist/types.js" }, "./frontend/styles.css": "./dist/signal-hop.css" } }

tsup handles the build, producing both CommonJS and ESM outputs with TypeScript declarations. The CSS file is copied into dist as a post-build step. Consuming the game in any React app is three lines of code: import the component, import the stylesheet, render it.

Lessons Learned

Separating game logic from rendering paid off immediately. The engine is pure TypeScript with no DOM dependencies, which means the 36 tests run in Node without any browser simulation. Testing graph connectivity, timer mechanics, and game-over detection is straightforward because the state functions are pure.

Canvas 2D is surprisingly capable for this kind of game. No WebGL, no game frameworks, just drawArc and drawLine. The rendering code is under 200 lines. The Pointer Events API eliminates the need for separate mouse and touch handlers.

The biggest design challenge was difficulty tuning. Too fast and players feel helpless. Too slow and there is no tension. The 45-second ramp interval with 0.5-second reductions creates a natural curve: the first minute is approachable, and by two minutes the game demands real strategy.

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 →