03 · LAB NOTE

How StickFight draws with a pen

Under the hood of StickFight's procedural pen-and-paper rendering engine: procedural line jitter, ink bleeding, and skeletal stickman math on HTML5 Canvas.

In school notebooks, margins were where stickmen fought. When designing StickFight: Notebook Duel, we wanted that ballpoint pen aesthetic to feel genuine on the web.

Rather than loading megabytes of PNG sprite sheets or canned frame sequences, we committed to zero image assets. Everything on screen—from the ruled paper and red margin line to the fighters, sword clashes, and ink splatters—is calculated and drawn live in code on HTML5 Canvas.

Here is how the procedural line boiling, skeletal kinematics, and rendering engine work under the hood.

1. Why procedural pen lines instead of sprite sheets

Traditional 2D fighting games rely on sprite sheets: hundreds of pre-rendered frames packed into PNG texture atlases. For a browser game, sprite sheets bring three drawbacks:

  1. Heavy payload: High-resolution frames across dozens of moves quickly total 15–30 MB of downloads, killing load times on cellular connections.
  2. Fixed resolution: Sprites blur on high-DPI mobile screens with a devicePixelRatio of 2 or 3 unless you double texture sizes.
  3. Rigid animations: Canned frames cannot react dynamically to hit reactions, weapon deflections, or variable jump arcs.

By drawing stickmen as vector skeletons directly in Canvas 2D, the entire game code, combat engine, and sound generator load in under 1.2 MB. Because paths are calculated relative to screen scale, lines stay crisp on any screen resolution from an iPhone to a 4K monitor.

2. Skeletal math & forward kinematics

Each fighter is modeled as a hierarchical kinematic tree starting at the root hips (rootX, rootY). Bone lengths and joint radii are defined in code:

// Stickman anatomical proportions
this.headRadius = 11 * scale;
this.torsoLength = 38 * scale;
this.upperArmLength = 18 * scale;
this.forearmLength = 18 * scale;
this.upperLegLength = 22 * scale;
this.lowerLegLength = 22 * scale;
this.bladeLength = 44 * scale;

Each frame, the forward kinematics pass calculates world positions for every joint using trigonometry, taking facing direction (f = 1 or -1) and a subtle squash-and-stretch factor into account:

// Torso to chest
const torsoAngle = this.angles.torso * f;
const chestX = rootX + Math.sin(torsoAngle) * this.torsoLength * sx;
const chestY = rootY - Math.cos(torsoAngle) * this.torsoLength * sy;

// Weapon arm (shoulder -> elbow -> hand)
const rShoulderAngle = torsoAngle + this.angles.rightShoulder * f;
const rElbowX = chestX + Math.sin(rShoulderAngle) * this.upperArmLength * sx;
const rElbowY = chestY + Math.cos(rShoulderAngle) * this.upperArmLength * sy;

const rElbowAngle = rShoulderAngle + this.angles.rightElbow * f;
const rHandX = rElbowX + Math.sin(rElbowAngle) * this.forearmLength * sx;
const rHandY = rElbowY + Math.cos(rElbowAngle) * this.forearmLength * sy;

// Sword blade tip
const swordAngle = rElbowAngle + this.angles.swordAngle * f;
const tipX = rHandX + Math.sin(swordAngle) * this.bladeLength * sx;
const tipY = rHandY - Math.cos(swordAngle) * this.bladeLength * sy;

Geometric blade collision & parries

Collision detection does not use loose bounding boxes. The sword blade is treated as a 2D line segment between swordHilt and swordTip.

During active attack frames, the engine calculates the shortest distance from the blade segment to the opponent’s circular hurtboxes (head, chest, and hips). If that distance falls below the hurtbox radius, a hit registers.

If the opponent activates block within a 6-frame window right before impact, the engine triggers a parry:

  • Both fighters freeze in place for a 0.15-second hitstop.
  • The canvas flashes white for two frames.
  • A metallic clash plays via the Web Audio API synthesizer.
  • The attacker is staggered into an open recovery state.

3. The ink jitter and paper bleed effect

A clean vector stroke looks sterile. Real ballpoint sketches have friction: hand tremor, paper grain resistance, and ink pooling at line intersections.

Decoupled 12 FPS “line boiling”

In hand-drawn animation, lines jitter because every frame is redrawn by hand. But running procedural jitter at 60 FPS makes the character look like they are vibrating or electrified.

We decoupled the animation boil from the physics loop:

  • Combat physics and joint interpolation update at 60 FPS for responsive input.
  • Visual line jitter updates at 12 FPS (1000 / 12 ms), cycling through 8 deterministic seed offsets.
getJitter(seed = 0, scale = 1.5) {
  const s = Math.sin(seed * 91.345 + this.boilIndex * 43.123);
  const c = Math.cos(seed * 54.123 + this.boilIndex * 67.456);
  return {
    x: s * scale,
    y: c * scale,
    w: 1 + Math.abs(s) * 0.5,
  };
}

Multi-pass sketched lines

Every bone stroke is drawn through a multi-pass sketching routine. The segment between (x1, y1) and (x2, y2) is subdivided into 20-pixel chunks, each displaced by the deterministic jitter values.

The renderer performs two passes with slightly different seeds and varying stroke widths (0.85 to 1.05), recreating the look of a student going over a pencil outline with a ballpoint pen.

The ruled notebook paper

The background is generated with procedural notebook rules:

  • Base paper tone set to warm cream #fbf7eb.
  • A 256×256 procedural grain texture applied as a repeating pattern at 65% opacity.
  • Horizontal blue lines (rgba(70, 120, 200, 0.28)) spaced at 32 px, curved slightly with Math.sin(y * 0.1) * 0.4.
  • A vertical red margin line (rgba(215, 60, 60, 0.4)) drawn at x = 80.

4. Performance: keeping it 60 FPS on mobile browsers

Running procedural math and drawing hundreds of segmented strokes every frame can easily drop frames on mobile devices if unoptimized. We implemented three strict architectural rules:

1. Offscreen background caching

Drawing 50+ ruled lines and sampling noise patterns every frame saturates GPU fill-rate on mobile browsers.

We generate the entire notebook page once onto an offscreen canvas. Each frame, the main renderer simply blits the cached background with ctx.drawImage(), applying a modulo offset on camera scroll. The offscreen canvas is only redrawn if the viewport resizes or a lightning flash occurs.

2. Zero garbage collection allocation in the tick loop

Allocating temporary { x, y } objects inside the game loop causes garbage collection spikes every few seconds, resulting in stutter.

All joint positions, bounding boxes, and collision segments are pre-allocated on the skeleton instance at boot. Joint calculations mutate existing properties in place without creating new objects.

3. Path batching

Calling ctx.beginPath() and ctx.stroke() for every individual segment is expensive. Where possible, strokes of identical color and width (such as particle ink drops and background debris) are chained into single paths before dispatching a single stroke command.

Summary

By replacing heavy raster graphics with procedural math, StickFight delivers an authentic margin-doodle fighter that downloads instantly, scales to any screen, and runs at a steady 60 FPS on mobile hardware.