When we added a piano game to GGAMES.MOBI with 100 playable songs, we needed sheet music that could render any piece—from Twinkle Twinkle to Bach’s Prelude in C—in real time on a canvas, and also export a print-ready PDF via SVG. Libraries like VexFlow exist, but they are large, opinionated, and not designed for a scrolling falling-notes view. So we built one from scratch. This post walks through the hardest problems we solved.

The Foundation: Staff Positions

Everything in music notation starts with the staff: five lines and four spaces. We assign each vertical position an integer. For the treble clef, the bottom line (E4) is position 2, and each line or space above increments by 1. Middle C (C4) is position 0. This means a note’s name maps to a deterministic Y coordinate:

// Map note name to staff position (diatonic)
// C4=0, D4=1, E4=2, F4=3, G4=4, A4=5, B4=6, C5=7 ...
function noteToStaffPos(noteName) {
  var letter = noteName.charAt(0);
  var octave = parseInt(noteName.match(/-?\d+$/)[0]);
  var LETTERS = {C:0, D:1, E:2, F:3, G:4, A:5, B:6};
  return (octave - 4) * 7 + LETTERS[letter];
}

// Convert position to pixel Y on canvas/SVG
function posToY(pos) {
  return STAFF_TOP_Y + (TOP_POSITION - pos) * STEP;
}

The STEP constant is the pixel distance between two staff positions (one line or one space). Everything scales from this single value—noteheads, stems, beams, accidentals, ledger lines. Change STEP and the entire engraving resizes proportionally.

Noteheads: Duration Encoding

A notehead’s appearance encodes its duration. Filled noteheads are used for quarter notes and shorter. Open (hollow) noteheads indicate half and whole notes. A dot adds 50% to the duration. The classification is a simple cascade of thresholds:

var isWhole      = beats >= 3.5;       // open, no stem
var isDottedHalf = beats >= 2.5;       // open, with dot
var isHalf       = beats >= 1.8;       // open, no dot
var isDottedQtr  = beats >= 1.2;       // filled, with dot
var isEighth     = beats >= 0.35;      // filled, with flag or beam
var isSixteenth  = beats <  0.35;      // filled, with two flags or beams

var isOpen = isWhole || isHalf || isDottedHalf;
var hasDot  = isDottedHalf || isDottedQtr;

The tricky case is non-standard durations like 1.75 beats (a note that starts on the second sixteenth of a beat and sustains for the rest of a half-bar). No single notehead represents 1.75 beats. The solution: split it into valid durations and connect them with ties. We cover that later.

Stems: Direction Rules

Stem direction follows a simple rule: notes above the middle line of the staff point down; notes below point up. For chords, the direction is based on the highest and lowest notes in the entire group:

var staffMid = 6; // B4 for treble, D3 for bass

// For single notes:
var stemDown = notePos >= staffMid;

// For chords (or beam groups):
var groupMax = Math.max.apply(null, allPositions);
var groupMin = Math.min.apply(null, allPositions);
var stemDown = ((groupMax + groupMin) / 2) >= staffMid;

When the average of the outer notes is above the middle line, stems go down. This keeps the stems pointing toward the center of the staff, which is both conventionally correct and visually balanced.

Beams: The Hardest Problem

Connecting consecutive eighth and sixteenth notes with beams (thick horizontal bars) is the single most complex part of notation rendering. The rules are:

Here is how we compute the beam slope and adjust stem endpoints:

var first = stems[0], last = stems[stems.length - 1];
var beamY1 = stemDown ? first.stemBotY : first.stemTopY;
var beamY2 = stemDown ? last.stemBotY  : last.stemTopY;
var slope = (beamY2 - beamY1) / (last.x - first.x);

// Translate beam so it clears every intermediate notehead
for (var i = 1; i < stems.length - 1; i++) {
  var s = stems[i];
  var beamAtS = beamY1 + slope * (s.x - first.x);
  var needed = stemDown
    ? (s.noteheadBotY - beamAtS)  // stem extends down
    : (beamAtS - s.noteheadTopY); // stem extends up
  if (needed > maxShift) maxShift = needed;
}
beamY1 += stemDown ? maxShift : -maxShift;
beamY2 += stemDown ? maxShift : -maxShift;

The secondary beam logic walks the stems to find consecutive runs of sixteenth notes. A pattern like [16th, 16th, 8th, 16th, 16th] produces two separate secondary beam segments with a gap at the eighth, clearly indicating the shorter duration in the middle.

Ties: Non-Valid Durations and Bar Lines

Ties connect two noteheads of the same pitch, indicating a sustained duration. We need ties in two situations:

1. Notes that cross bar lines. A half note starting on beat 3.5 in 4/4 time extends past the bar boundary. We split it into two segments—one per bar—and draw a tie between them.

2. Notes with non-standard durations. A 1.75-beat note cannot be represented by a single notehead. We split it into valid pieces (e.g., dotted quarter + sixteenth = 1.5 + 0.25) and tie them together.

var VALID_DURATIONS = [4, 3, 2, 1.5, 1, 0.75, 0.5, 0.25];

function splitIntoValidDurations(d) {
  var pieces = [], remaining = d;
  while (remaining > 0.01) {
    for (var i = 0; i < VALID_DURATIONS.length; i++) {
      if (VALID_DURATIONS[i] <= remaining + 0.01) {
        pieces.push(VALID_DURATIONS[i]);
        remaining -= VALID_DURATIONS[i];
        break;
      }
    }
  }
  return pieces;
}

// 1.75 beats → [1.5, 0.25] → dotted quarter tied to sixteenth
// 3.5 beats → [3, 0.5] → dotted half tied to eighth

Each segment becomes a separate notehead in the sheet view, with a curved tie arc connecting consecutive segments. The tie curves away from the stem direction—upward for stem-down notes, downward for stem-up notes.

Accidentals and Key Signatures

Accidentals (sharps, flats, naturals) must respect the key signature. If a piece is in G major (one sharp, F#), then every F is implicitly sharp. We only draw an explicit accidental when the note differs from what the key signature dictates.

The algorithm processes each bar independently. Within a bar, once an accidental appears, it persists for the rest of that bar:

// Per-bar accidental annotation
function annotateAccidentals(items, keySharps, keyFlats) {
  var seen = {}; // letter → accidental in this bar
  items.forEach(function(it) {
    var letter = it.displayLetter;
    var inKeySig = keySharps.indexOf(letter) >= 0
                || keyFlats.indexOf(letter) >= 0;

    if (it.needsAccidental && !inKeySig && !seen[letter]) {
      it.displayAccidental = it.accidentalType; // 'sharp'|'flat'|'natural'
      seen[letter] = it.accidentalType;
    } else if (inKeySig && !it.isSharp && !keyFlats.length) {
      // Natural needed: letter is in key sig but note is natural
      it.displayAccidental = 'natural';
      seen[letter] = 'natural';
    }
  });
}

For second-interval chords (two adjacent positions like C and D), the upper note is shifted horizontally to avoid overlap. Accidentals on shifted notes need extra horizontal offset so they do not collide with the adjacent notehead.

Chord Symbols: Auto-Detection

Lead sheets show chord symbols above the staff (C, G7, Am, Fmaj7). Rather than hand-annotate every chord for every song, we auto-detect them from the left-hand accompaniment:

  1. Segment the LH notes by pedal changes (each pedal-down typically marks a new chord).
  2. Collect pitch classes (mod 12) for each segment.
  3. Template-match against known chord types: major, minor, diminished, dominant 7th, major 7th, minor 7th, sus4, augmented.
  4. Identify the bass (lowest note) for slash chords like C/E.
  5. Score candidates with tie-breakers: root-position bonus, chord-type commonality (major beats diminished), and diatonic preference.
var CHORD_TEMPLATES = {
  major:      [0, 4, 7],
  minor:      [0, 3, 7],
  diminished: [0, 3, 6],
  dominant7:  [0, 4, 7, 10],
  major7:     [0, 4, 7, 11],
  minor7:     [0, 3, 7, 10],
  sus4:       [0, 5, 7],
  aug:        [0, 4, 8]
};

// For each candidate root (0-11), check how many template
// intervals are present in the pitch class set
function identifyChord(pitchClasses, bass) {
  var best = null;
  for (var root = 0; root < 12; root++) {
    for (var type in CHORD_TEMPLATES) {
      var intervals = CHORD_TEMPLATES[type];
      var matched = intervals.filter(function(iv) {
        return pitchClasses.indexOf((root + iv) % 12) >= 0;
      }).length;
      var completeness = matched / intervals.length;
      // ... scoring with bonuses for bass=root, type, diatonic
    }
  }
  return best;
}

This approach correctly identifies chords for most classical and popular music. The main limitation: if the root is absent from the LH arpeggio (e.g., a C7 chord voiced as E-G-Bb without C), the detection may misidentify it.

Two Rendering Targets

We render the same notation to two completely different targets:

Canvas (live view): A scrolling falling-notes display where notes approach a playhead line in real time. The player hits keys as notes arrive. This uses requestAnimationFrame with a game loop, and notes fade in/out at the edges of the visible window. The beam groups, stems, and accidentals all update every frame as positions scroll.

SVG (print view): A static, paginated layout designed for A4 paper. Systems are packed greedily—each system gets as many bars as fit within the printable width. Each system is a self-contained <svg> element with its own clef, key signature, and time signature header. CSS @media print handles page breaks, and max-width:100% scales wide systems to fit the page.

The core notation logic (staff positions, beam grouping, tie metadata) is shared between both targets. Only the drawing primitives differ: ctx.fillRect() vs <rect> elements.

Print Layout: Greedy Bar Packing

For the SVG print view, we need to fit bars into systems optimally. Each bar has a minimum pixel width based on its note content. The algorithm greedily adds bars to the current system until the next bar would overflow:

var systems = [], curSys = [], curW = 0;

bars.forEach(function(bar, i) {
  var barWidth = computeBarMinWidth(bar) + PADDING;
  if (curW + barWidth > MAX_CONTENT_WIDTH && curSys.length > 0) {
    systems.push(curSys);
    curSys = [];
    curW = 0;
  }
  curSys.push(bar);
  curW += barWidth;
});
if (curSys.length > 0) systems.push(curSys);

Within each system, bars are sized proportionally to their content density. A bar with 16 sixteenth notes gets more horizontal space than a bar with one whole note. This produces sheet music that reads naturally—dense bars are wider, sparse bars are narrower.

Lessons Learned

Building a music notation engine from scratch was one of the most technically challenging projects on this site. But the result is a rendering pipeline that handles 100 songs across multiple time signatures and key signatures, produces both interactive and printable output, and fits in a single file with zero dependencies. You can see it in action on the piano game—toggle the sheet music view and try the Download button for the print-ready version.