Building a Visual Attack–NPC Pairing Editor in Three.js

A browser-based tool for choreographing combat animations without recompiling your game.

What This Tool Does

You have a set of player attack animations and a set of NPC reaction animations. You need to define, for each attack:

  • Which NPC reaction(s) fire — one or more, chosen randomly
  • Where the NPC stands — per-attack distance in meters
  • When the reaction fires — as a fraction of the attack clip (e.g. 60%)
  • How fast the reaction plays — a speed multiplier

Doing this by editing JSON and refreshing the game is agonizing. This tool lets you do it visually, preview it live in 3D, and export the config as a single JSON file your game reads at runtime.

Feature List

FeatureWhat it does
Auto-discovery + fallbackLoads GLBs from folder listing or a hardcoded list
Auto-classificationFiles starting with NPC_ are NPC assets; everything else is a player attack
Blender-style editorBottom split-pane that keeps the 3D view visible
Live previewsClick ▶ on any row to play that clip on the 3D scene
Per-attack slidersRange, hit timing, and NPC speed — all saved per attack
Auto-preview on releaseReleasing a slider plays the result instantly
localStorage persistencePairings save automatically, survive refreshes
Schema migrationOld saved data auto-upgrades to newer formats
JSON export / importThe config contract between tool and game
Static minimum rangeSlider can’t go below a value that would clip the models

Architecture

text

┌─────────────────────────────────────────────────────────┐
│  PAIRING EDITOR (browser tool)                          │
│                                                         │
│  • Loads GLBs, classifies player vs NPC                 │
│  • Bottom pane: attacks × NPCs                          │
│  • ▶ preview per row                                    │
│  • + / × to link / unlink                               │
│  • 3 sliders per attack: range, hitAt, npcSpeed         │
│  • Auto-saves to localStorage                           │
│  • ↙ Import / ↗ Export as JSON                          │
└──────────────────────────┬──────────────────────────────┘
                           │
                           ▼  fight_pairings.json
┌─────────────────────────────────────────────────────────┐
│  GAME RUNTIME                                           │
│                                                         │
│  • Loads GLBs, reads pairings.json once                 │
│  • On attack: look up entry, position NPC, schedule     │
│    reaction, play at tuned speed                        │
└─────────────────────────────────────────────────────────┘

The tool’s only job is to produce the JSON. Everything else — the UI, the storage, the previews — exists to make that JSON cheap to produce and easy to iterate.


Feature 1 — File Loading That Never Fails

Browsers can’t enumerate a folder. But a directory listing (like Python’s http.server generates) exposes <a href="file.glb"> links we can scrape. The problem: as soon as you add an index.html, the listing disappears.

Solution: hybrid loading. Try to scrape; merge the result with a hardcoded fallback list. The tool always has files to work with.

js

const FALLBACK_FILES = [
    'Boxing_clean.glb',
    'flying_kick_clean.glb',
    'Headbutt_clean.glb',
    'Kicking_clean.glb',
    'leg_sweep_clean.glb',
    'martelo_2_clean.glb',
    'quad_punch_clean.glb',
    'roundhouse_kick_clean.glb',
    'surprise_uppercut_clean.glb',
    'NPC_receive_punch_to_the_face_clean.glb',
    'NPC_receive_stomach_uppercut_clean.glb',
    'NPC_surprise_uppercut_clean.glb',
    'NPC_taking_punch_clean.glb',
    'NPC_knocked_out_clean.glb',
    'NPC_standing_death_backward_01_clean.glb',
    'NPC_standing_death_forward_01_clean.glb'
];

async function discoverFiles(){
    try {
        const res = await fetch(location.href, { cache: 'no-store' });
        if (!res.ok) return [];
        const text = await res.text();
        const re = /href\s*=\s*["']([^"']+\.glb)["']/gi;
        const found = new Set();
        let m;
        while ((m = re.exec(text)) !== null){
            const name = decodeURIComponent(m[1]).split('/').pop();
            if (name) found.add(name);
        }
        return [...found];
    } catch { return []; }
}

/* In boot(): */
const discovered = await discoverFiles();
const merged = Array.from(new Set([...FALLBACK_FILES, ...discovered]))
    .filter(n => /\.glb$/i.test(n));

Also dedupes foo.glb vs foo_clean.glb — keeping only the _clean one:

js

function dedupeClean(files){
    const set = new Set(files);
    return files.filter(f => {
        const plain = f.replace(/_clean\.glb$/i, '.glb');
        if (plain === f) return true;
        return !set.has(plain);
    });
}

Feature 2 — Player vs NPC Classification

Naming convention does all the work:

js

const idleFile      = allFiles.find(f => /NPC_bouncing_fight_idle/i.test(f)) || null;
const reactionFiles = allFiles.filter(f => /^NPC_/i.test(f) && f !== idleFile);
const attackFiles   = allFiles.filter(f => !/^NPC_/i.test(f));
  • NPC_*.glb → NPC reaction pool
  • NPC_bouncing_fight_idle_clean.glb → special: NPC base mesh + idle loop
  • Everything else → player attack

Both loaders re-center their mesh on origin and reset the feet to Y=0:

js

function prepareCharacter(root){
    root.updateMatrixWorld(true);
    const box = new THREE.Box3().setFromObject(root);
    const size = box.getSize(new THREE.Vector3());
    const center = box.getCenter(new THREE.Vector3());
    root.position.x -= center.x;
    root.position.z -= center.z;
    root.position.y -= box.min.y;
    root.updateMatrixWorld(true);
    return size;
}

Feature 3 — The Blender-Inspired Pairing Editor

The editor opens from the bottom of the viewport. The 3D scene stays visible above it. This is the single most important UX decision — every tuning action has immediate visual feedback.

text

┌──────────┬──────────────────────────────────────┐
│ SIDEBAR  │          3D VIEWPORT                 │
├──────────┴──────────────────────────────────────┤
│  PAIRING EDITOR                      [× close] │
│  ─────────────────────────────────────────────  │
│  ATTACKS            │  NPC POOL                 │
│  ▶ Boxing     [2]   │  ▶ Block             +    │
│  ▶ Kicking    [1]   │  ▶ Face Hit          +    │
└─────────────────────────────────────────────────┘

The panel is one <section> in the layout, toggled by a class:

js

function openPairing(){
    pairingEl.classList.remove('hidden');
    pairSelectedIndex = currentAttackIndex;
    renderPairingAttacks();
    renderPairingNpcs();
    renderPairingFoot();
}

Each attack track shows:

  • ▶ — preview button
  • Name — click to select this attack
  • Badge — how many NPC reactions are assigned
  • Timeline bar — clip duration with a pink marker at the hit frame
  • Chips — one per assigned NPC, with an × to remove
  • Sliders — only shown when this attack is selected

Feature 4 — Live Previews

Any row can be previewed by clicking ▶. This plays the clip on the 3D scene without committing anything.

js

function previewAttack(index){
    const c = playerClips[index];

    previewing = true;   // suppress auto-reactions
    attacking  = false;

    /* cancel any in-flight attack state */
    clearTimeout(attackEndTimer);
    clearTimeout(hitTimer);

    /* move NPC to this attack's tuned range */
    moveNpcTo(c.file);

    playerMixer.stopAllAction();
    playerAction = playerMixer.clipAction(c.clip);
    playerAction.reset();
    playerAction.setEffectiveTimeScale(speed);
    playerAction.setLoop(THREE.LoopOnce, 1);
    playerAction.clampWhenFinished = true;
    playerAction.play();

    setPlayingIndicator(-1, index);
    previewEndTimer = setTimeout(() => {
        previewing = false;
        setPlayingIndicator(-1);
    }, (c.clip.duration || 1) * 1000 / speed + 200);
}

function previewNpc(npcFile, overrideSpeed = null){
    const r = npcReactionClips.find(x => x.file === npcFile);
    if (!r || !npcMixer) return;

    const chosenAttackFile = playerClips[pairSelectedIndex]?.file
                          || playerClips[currentAttackIndex]?.file;
    const spdMul = overrideSpeed ?? npcSpeedFor(chosenAttackFile);

    if (npcIdleAction) npcIdleAction.fadeOut(0.12);
    if (npcReactionAction){ npcReactionAction.stop(); npcReactionAction = null; }

    npcReactionAction = npcMixer.clipAction(r.clip);
    npcReactionAction.reset();
    npcReactionAction.setEffectiveTimeScale(speed * spdMul);
    npcReactionAction.setLoop(THREE.LoopOnce, 1);
    npcReactionAction.clampWhenFinished = true;
    npcReactionAction.fadeIn(0.1).play();

    const dur = (r.clip.duration || 1) * 1000 / (speed * spdMul);
    reactionEndTimer = setTimeout(() => {
        if (!npcReactionAction) return;
        npcReactionAction.fadeOut(0.2);
        if (npcIdleAction){
            npcIdleAction.reset();
            npcIdleAction.setEffectiveTimeScale(speed);
            npcIdleAction.setLoop(THREE.LoopRepeat, Infinity);
            npcIdleAction.fadeIn(0.2).play();
        }
        npcReactionAction = null;
        setPlayingIndicator(-1);
    }, dur);
}

The currently-playing row highlights red via setPlayingIndicator:

js

function setPlayingIndicator(npcFile, attackIndex){
    document.querySelectorAll('.atk-track').forEach((el, i) => {
        el.classList.toggle('playing', i === attackIndex);
    });
    document.querySelectorAll('.npc-pool-row').forEach(el => {
        el.classList.toggle('playing', el.dataset.file === npcFile);
    });
}

Feature 5 — Per-Attack Metadata: Three Sliders

Each attack stores three tunables:

SliderRangeMeaning
↔ Range0.35 – 4.0 mWhere the NPC stands
🎯 Hit At5% – 95%When the reaction fires (fraction of clip)
⏱ Speed0.5× – 2.5×Reaction playback speed

Defaults live at the top of the script:

js

const DEFAULT_RANGE     = 1.6;
const MIN_RANGE         = 0.5;
const MAX_RANGE         = 4.0;

const DEFAULT_NPC_SPEED = 1.0;
const MIN_NPC_SPEED     = 0.5;
const MAX_NPC_SPEED     = 2.5;

const DEFAULT_HIT_AT    = 0.60;
const MIN_HIT_AT        = 0.05;
const MAX_HIT_AT        = 0.95;

Only the selected attack renders its sliders, keeping the panel clean:

js

if (isSel){
    /* Range slider */
    const rangeWrap = document.createElement('div');
    rangeWrap.className = 'atk-range first';

    const rInput = document.createElement('input');
    rInput.type  = 'range';
    rInput.min   = Math.max(MIN_RANGE, minSeparation);   // ← collision floor
    rInput.max   = MAX_RANGE;
    rInput.step  = 0.05;
    rInput.value = range;

    rInput.addEventListener('input', () => {
        const v = parseFloat(rInput.value);
        setRangeFor(c.file, v);
        if (playerClips[currentAttackIndex]?.file === c.file){
            npcBaseTargetZ = v;
            npcTargetZ     = v;
            updateDistanceGuide();
        }
        updateStatusForAttack(c);
    });
    /* ...append to track... */

    /* Hit timing slider */
    const tInput = document.createElement('input');
    tInput.type  = 'range';
    tInput.min   = MIN_HIT_AT;
    tInput.max   = MAX_HIT_AT;
    tInput.step  = 0.01;
    tInput.value = hitAt;

    tInput.addEventListener('input', () => {
        const v = parseFloat(tInput.value);
        setHitAtFor(c.file, v);
        marker.style.left = (v * 100) + '%';   // live timeline marker
    });

    /* auto-play on release so the user *sees* the new timing */
    tInput.addEventListener('pointerup', () => previewAttack(i));

    /* Speed slider */
    const sInput = document.createElement('input');
    sInput.type  = 'range';
    sInput.min   = MIN_NPC_SPEED;
    sInput.max   = MAX_NPC_SPEED;
    sInput.step  = 0.05;
    sInput.value = npcSpd;

    sInput.addEventListener('input', () => {
        const v = parseFloat(sInput.value);
        setNpcSpeedFor(c.file, v);
    });
    sInput.addEventListener('pointerup', () => {
        const r = reactionsFor(c.file);
        if (!r.length) return;
        const pick = r[Math.floor(Math.random() * r.length)];
        previewNpc(pick, npcSpeedFor(c.file));
    });
}

The auto-preview on release is the single biggest usability win. No “apply” button, no separate “test” button — you drag, let go, and see the result.


Feature 6 — Hit Timing in the Attack Loop

When the player actually attacks (not a preview), the hit timer uses the attack’s hitAt:

js

function triggerAttack(){
    if (!playerClips.length || attacking) return;
    attacking = true; previewing = false;
    btnFight.disabled = true;

    const entry    = playerClips[currentAttackIndex];
    const clip     = entry.clip;
    const duration = clip.duration || 1;
    const hitAt    = hitAtFor(entry.file);   // ← per-attack

    if (playerRoot) playerRoot.position.x = playerRoot.position.z = 0;

    playerMixer.stopAllAction();
    playerAction = playerMixer.clipAction(clip);
    playerAction.reset();
    playerAction.setEffectiveTimeScale(speed);
    playerAction.setLoop(THREE.LoopOnce, 1);
    playerAction.clampWhenFinished = true;
    playerAction.play();

    clearTimeout(hitTimer);
    hitTimer = setTimeout(
        () => fireNpcReaction(entry.file),
        duration * hitAt * 1000 / speed
    );

    clearTimeout(attackEndTimer);
    attackEndTimer = setTimeout(() => {
        attacking = false;
        btnFight.disabled = false;
        if (playerRoot) playerRoot.position.x = playerRoot.position.z = 0;
        setPlayingIndicator(-1);
    }, duration * 1000 / speed + 200);
}

The reaction picks a random NPC from the attack’s pool and plays it at the tuned speed:

js

function fireNpcReaction(attackFile){
    if (!npcMixer || !npcReactionClips.length) return;

    const pool   = reactionsFor(attackFile);
    const npcSpd = npcSpeedFor(attackFile);

    let chosen = null;
    if (pool.length){
        const file = pool[Math.floor(Math.random() * pool.length)];
        chosen = npcReactionClips.find(r => r.file === file);
    }
    if (!chosen){
        /* fallback: any reaction */
        chosen = npcReactionClips[Math.floor(Math.random() * npcReactionClips.length)];
    }
    if (!chosen) return;

    if (npcIdleAction) npcIdleAction.fadeOut(0.12);

    npcReactionAction = npcMixer.clipAction(chosen.clip);
    npcReactionAction.reset();
    npcReactionAction.setEffectiveTimeScale(speed * npcSpd);
    npcReactionAction.setLoop(THREE.LoopOnce, 1);
    npcReactionAction.clampWhenFinished = true;
    npcReactionAction.fadeIn(0.1).play();

    const dur = (chosen.clip.duration || 1) * 1000 / (speed * npcSpd);
    reactionEndTimer = setTimeout(() => {
        /* fade back to idle */
        npcReactionAction.fadeOut(0.2);
        npcIdleAction.reset().setEffectiveTimeScale(speed).fadeIn(0.2).play();
        npcReactionAction = null;
    }, dur);
}

Feature 7 — Persistence with Schema Migration

The mapping format changed four times during development. Every version bump ships with a migration function so old localStorage data never breaks.

js

const STORAGE_KEY = 'fight_viewer_mappings_v4';

function normalizeMappings(raw){
    const out = {};
    if (!raw || typeof raw !== 'object') return out;

    for (const k of Object.keys(raw)){
        const v = raw[k];

        if (Array.isArray(v)){
            /* v1 — bare array */
            out[k] = {
                reactions: v.slice(),
                range:     DEFAULT_RANGE,
                npcSpeed:  DEFAULT_NPC_SPEED,
                hitAt:     DEFAULT_HIT_AT
            };
        } else if (v && typeof v === 'object'){
            out[k] = {
                reactions: Array.isArray(v.reactions) ? v.reactions.slice() : [],
                range:     v.range    ?? v.npcRange  ?? DEFAULT_RANGE,
                npcSpeed:  v.npcSpeed ?? v.speed     ?? DEFAULT_NPC_SPEED,
                hitAt:     v.hitAt    ?? v.hitTiming ?? DEFAULT_HIT_AT
            };
        }
    }
    return out;
}

function loadMappings(){
    try {
        let raw = localStorage.getItem(STORAGE_KEY);
        if (!raw) raw = localStorage.getItem('fight_viewer_mappings_v3');
        if (!raw) raw = localStorage.getItem('fight_viewer_mappings_v2');
        if (!raw) raw = localStorage.getItem('fight_viewer_mappings_v1');
        if (!raw) return;

        Object.assign(mappings, normalizeMappings(JSON.parse(raw)));
        saveMappings();   // persist forward to v4
    } catch (e){ console.warn('Bad mappings:', e); }
}

function saveMappings(){
    try { localStorage.setItem(STORAGE_KEY, JSON.stringify(mappings)); }
    catch (e){ console.warn('Save failed:', e); }
}

Mappings save on every change — every +, every ×, every slider tick:

js

function addToAttack(attackFile, npcFile){
    const m = mappingFor(attackFile);
    if (!m.reactions.includes(npcFile)) m.reactions.push(npcFile);
    saveMappings();
    refreshAfterChange(attackFile);
}

function setRangeFor(f, v){    mappingFor(f).range    = v; saveMappings(); }
function setNpcSpeedFor(f, v){ mappingFor(f).npcSpeed = v; saveMappings(); }
function setHitAtFor(f, v){    mappingFor(f).hitAt    = v; saveMappings(); }

Feature 8 — JSON Export / Import

The exported JSON is the entire contract between tool and game.

Export — cleans up defaults, so only meaningful entries appear:

js

function openIoModal(mode){
    if (mode === 'export'){
        const clean = {};
        for (const k of Object.keys(mappings).sort()){
            const m = mappings[k];
            const isDefault = !m.reactions.length
                && m.range    === DEFAULT_RANGE
                && m.npcSpeed === DEFAULT_NPC_SPEED
                && m.hitAt    === DEFAULT_HIT_AT;
            if (isDefault) continue;

            clean[k] = {
                reactions: m.reactions,
                npcRange: +m.range.toFixed(3),
                hitAt:    +m.hitAt.toFixed(3),
                npcSpeed: +m.npcSpeed.toFixed(3)
            };
        }
        ioText.value = JSON.stringify(clean, null, 2);
    }
}

Import — validates, migrates, replaces:

js

function doApply(){
    try {
        const parsed = JSON.parse(ioText.value);
        if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)){
            throw new Error('Not an object');
        }
        const next = normalizeMappings(parsed);

        for (const k of Object.keys(mappings)) delete mappings[k];
        Object.assign(mappings, next);
        saveMappings();

        refreshAfterChange(playerClips[pairSelectedIndex]?.file || '');
        if (playerClips[currentAttackIndex]) moveNpcTo(playerClips[currentAttackIndex].file);

        closeIoModal();
    } catch (e){
        ioStatus.textContent = '⚠ Invalid JSON: ' + e.message;
    }
}

Example output:

json

{
  "flying_kick_clean.glb": {
    "reactions": ["NPC_knocked_out_clean.glb"],
    "npcRange": 1.85,
    "hitAt": 0.60,
    "npcSpeed": 0.90
  },
  "Headbutt_clean.glb": {
    "reactions": [
      "NPC_receive_punch_to_the_face_clean.glb",
      "NPC_taking_punch_clean.glb"
    ],
    "npcRange": 0.70,
    "hitAt": 0.55,
    "npcSpeed": 1.30
  }
}

Feature 9 — Safe Range (No Clipping)

The minimum range for the slider is computed from the two models’ bounding boxes, using depth (Z) — the axis they face along — not width:

js

/* In loadNpc(), after both characters are loaded: */
if (playerSize && npcSize){
    const pr = playerSize.z / 2;
    const nr = npcSize.z / 2;

    /* 60% safety factor — bbox is measured at bind pose */
    minSeparation = (pr + nr) * 0.6;

    /* hard floor so the slider can't go absurdly low */
    minSeparation = Math.max(minSeparation, 0.35);

    console.log('[range] minSeparation =', minSeparation.toFixed(3));
}

Then the range slider binds its min to that:

js

const safeMin = Math.max(MIN_RANGE, minSeparation);
rInput.min   = safeMin;
rInput.value = Math.max(range, safeMin);

What was tried and rejected: a dynamic hip-bone collision solver that pushed the NPC when the player lunged forward. It oscillated with the smooth-slide system and pushed the NPC back on attacks where the models didn’t visually overlap. Replaced with the static slider bound above — simpler, more predictable, no shake.


Full Source

The complete single-file implementation is one HTML file with four sections:

  1. CSS — sidebar, viewport, pairing editor, sliders, export modal
  2. HTML skeleton — the DOM structure
  3. importmap — three.js from unpkg
  4. Script — everything above, plus the scene setup, loaders, and render loop

The full file is ~1200 lines. Key extras beyond the features above:

  • Three.js scene with RoomEnvironment for IBL lighting
  • GLTFLoader + DRACOLoader for compressed meshes
  • AnimationMixer per character with fade-based blending
  • OrbitControls for camera
  • animate() render loop driving both mixers and the NPC’s smooth slide
  • Distance guide (a dashed line from player to NPC) shown while dragging the range slider

The critical wiring between them is minimal:

js

/* Smooth NPC slide */
if (npcRoot){
    const diff = npcTargetZ - npcCurrentZ;
    if (Math.abs(diff) > 0.0005){
        npcCurrentZ += diff * Math.min(1, dt * 10);
        npcRoot.position.z = npcCurrentZ;
    } else if (npcCurrentZ !== npcTargetZ){
        npcCurrentZ = npcTargetZ;
        npcRoot.position.z = npcTargetZ;
    }
}

That single lerp is what makes all per-attack distance changes feel animated instead of snappy.


Integration with Game Runtime

The tool’s output is a plain JSON file. In your game:

js

const pairings = await fetch('fight_pairings.json').then(r => r.json());

function onPlayerAttack(attackFile, attackClip){
    const entry = pairings[attackFile];
    if (!entry) return;

    /* 1. Position the NPC at the tuned distance */
    npc.position.z = entry.npcRange;

    /* 2. Schedule the reaction at the tuned hit frame */
    const hitTimeMs = attackClip.duration * entry.hitAt * 1000;

    setTimeout(() => {
        /* 3. Pick a random reaction from the pool */
        const reactionFile = entry.reactions[
            Math.floor(Math.random() * entry.reactions.length)
        ];
        const reactionClip = reactionClipMap[reactionFile];
        if (!reactionClip) return;

        /* 4. Play it at the tuned speed */
        const action = npcMixer.clipAction(reactionClip);
        action.setEffectiveTimeScale(entry.npcSpeed);
        action.setLoop(THREE.LoopOnce, 1);
        action.clampWhenFinished = true;
        action.play();

        /* 5. Fade back to idle when it ends */
        const dur = reactionClip.duration * 1000 / entry.npcSpeed;
        setTimeout(() => {
            action.fadeOut(0.2);
            npcIdleAction.reset().setEffectiveTimeScale(1).fadeIn(0.2).play();
        }, dur);
    }, hitTimeMs);
}

The game doesn’t need to know about sliders, previews, or localStorage. It just reads numbers and applies them.


Key Takeaways

1. The viewport is sacred. Never cover it. Bottom pane > modal.

2. Auto-preview on every slider release. Sub-100 ms feedback loops turn a config tool into a toy.

3. Bump storage schemas with migration functions. Never break saved data. Ship v4, support v1 through v4.

4. Don’t over-solve for correctness. A static minimum on a slider beats a dynamic collision solver — simpler, more predictable, designer-friendly.

5. Measure the right axis. size.x for side-by-side, size.z for face-to-face. Bounding boxes at bind pose lie.

6. JSON is the contract. The tool and the game should communicate through data, not code.

7. Ship with fallbacks. Server behavior is a variable you don’t control. A hardcoded file list costs 20 lines and saves hours.


The tool exists to answer one question per attack: “When the player does X, what does the NPC do, where, and how fast?” Everything else is scaffolding.

Tags: three.js, game dev, animation, tooling, web, combat systems, JSON, localStorage.