Boreumdal — a one-person studio · 한국어 · 日本語

A gayageum with no audio files

How a small mobile parry game synthesizes every note of its Korean zither soundtrack at runtime, and a demo you can play below.

Boreumdal (FullMoon), solo developer · October 2026

An ink-brush swordsman parrying three arrows from a boss drawn in black ink on hanji paper
Ink Parry: Seonbi Swordsman. Every sound in the game is generated in code, and so is every brush stroke.

Ink Parry: Seonbi Swordsman is a one-thumb parry game set in Joseon-era Korea. Attacks come at you from the edge of a circle; you swipe toward them at the right moment. I wanted it to sound like Korea, which for me means the gayageum, a twelve-string zither with silk strings that bend and tremble under the left hand.

I didn't have recordings I could license, and the game also had to stay small enough to run inside a mobile webview. So the game ships with zero audio files. The music and the sound effects come from about 1,100 lines of TypeScript on top of the Web Audio API. This post walks through how that works.

Try the string

Tap a string. This is the exact synthesis code from the game, running in your browser. Turn silk excitation off to hear the textbook version.

Listen first

These are recorded straight from the game's synth with an offline audio context. Nothing is edited except a fade at the end.

Title screenSlow phrases, lots of vibrato on the long notes.
A run, getting fasterTempo and note density rise with how long you survive.
Final bossJanggu drums on top, low strings pushing on every half beat.
Parry hitsThe hit sounds are tuned to the same scale as the music.

1. Karplus-Strong, tuned properly

Karplus-Strong is the classic plucked-string trick. Fill a short delay line with noise, then keep feeding its output back through a gentle averaging filter. The loop length sets the pitch, and the averaging slowly eats the high frequencies, which is what a real string does as it rings.

The catch is tuning. A delay line has a whole number of samples, so at 44.1 kHz the higher strings land noticeably sharp or flat. The fix is a first-order allpass filter in the loop that adds the missing fraction of a sample:

const period = sampleRate / freq;
const L = Math.floor(period - 0.5);   // whole samples (averaging adds 0.5)
const d = period - 0.5 - L;           // the leftover fraction
const C = (1 - d) / (1 + d);          // allpass coefficient

// per sample:
const avg = 0.5 * (x + last) * rho;   // averaging + decay
const ap  = C * avg + apX - C * apY;  // fractional delay

Decay is set per string from a T60 time (how long until the note is 60 dB quieter): rho = 10 ** (-3 / (freq * t60)). Low strings get about 3 seconds, high strings under one, so the bass notes bloom and the top notes stay crisp.

2. Making it sound like silk, not steel

Plain Karplus-Strong sounds like a bright steel string or a harpsichord. Gayageum strings are twisted silk: the attack is soft and the tone is hollow. Three small changes got me most of the way, and you can hear the difference with the toggle in the demo.

Finally, a 4 ms fade-in at the start of each note gives a tiny fingertip-on-silk feel rather than a click.

3. Bake once, play cheaply

Running the loop live for every note would be wasteful on a phone. Instead, the first time a string is needed, the game renders it once into an AudioBuffer (each string has its own fixed noise seed, so it sounds the same every time) and plays it back with an AudioBufferSourceNode. A whole game uses a couple of dozen distinct strings at most, so the cache stays tiny.

4. The left hand: nonghyeon and kkeokgi

What makes a gayageum sound like a gayageum isn't the pluck. It's what the left hand does afterwards. Players press the string behind the bridge to make the pitch tremble (nonghyeon) or to push it up and let it fall back (kkeokgi). Both are just automation on the buffer's playbackRate:

// kkeokgi: push up two semitones and let it fall back
const up = 2 ** (2 / 12);
rate.setValueAtTime(1, t + 0.18);
rate.linearRampToValueAtTime(up, t + 0.34);
rate.setValueAtTime(up, t + 0.50);
rate.linearRampToValueAtTime(1, t + 0.68);

// nonghyeon: 5.2 Hz vibrato that deepens after the attack
curve[i] = 2 ** (depth * Math.min(1, u * 2.5) * Math.sin(u * dur * 2 * Math.PI * 5.2) / 12);
rate.setValueCurveAtTime(curve, t + 0.22, dur);

The delays matter more than the shapes. The vibrato starts about 0.2 seconds after the pluck and grows in, the way a player lets the note speak first.

5. One scale for everything

All pitches come from one function. It uses pyeongjo, a five-note mode (Hwang, Tae, Jung, Im, Nam) that I set at Hwang ≈ E♭4 = 311.13 Hz:

const PYEONGJO = [0, 2, 5, 7, 9];        // semitones above Hwang
function scaleFreq(step) {
  const oct = Math.floor(step / 5);
  const deg = ((step % 5) + 5) % 5;
  return 311.13 * 2 ** (oct + PYEONGJO[deg] / 12);
}

The parry sounds use the same function, so a perfect parry lands on a note of the scale (which note depends on the direction you swiped). In a busy fight the hits and the music never clash, and the hits almost become part of the melody.

6. A composer that doesn't repeat itself

Loops get tiring fast in a game you replay a hundred times. So instead of a track, there is a tiny composer that writes 12-beat phrases as it goes:

Density and tempo come from the game: the longer you survive, the faster it goes (76 to 102 BPM) and the more short notes it writes. Endless mode moves through day, dusk, night and dawn, which shifts the register down or up and adds more vibrato at night. Boss fights add janggu (hourglass drum) patterns built from filtered noise and pitched sine sweeps.

7. The music runs on the audio clock, not the game clock

Parry games love hit-stop, where time freezes for a few frames on a big hit. If the music followed game time, every hit-stop would hiccup the beat. So the scheduler only looks at AudioContext.currentTime: each frame it schedules whatever beats fall in the next 0.4 seconds, and game time never enters into it.

Mixing is set so the parry is always the hero. The music sits around −17 dBFS during play while hit sounds peak near −6. On each parry the music ducks to 45% in 20 ms and recovers over about a third of a second. When your health is low, the whole music bus slides from a 4.2 kHz to a 700 Hz low-pass, which sounds muffled and slightly underwater without needing any extra sound.

The core play screen: a swordsman inside an ink circle, attacks coming from the edge The final boss Mukgwi, an ink demon dripping ink A blade clash with the boss, ink splattering

What I'd tell myself at the start

It's not a sampled gayageum, and a real player will hear the difference. But it's small, it never repeats, it's always in tune with the gameplay, and it sounds like the place the game is set in, which was the point.

Ink Parry: Seonbi Swordsman is out on iPhone and Android. It's a one-time purchase, with no ads and no in-app purchases.

See the game · App Store · Google Play