Category: Attack-NPC

  • 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.