Category: image

  • air.toml

    Each site runs its own air. One per folder. Each air watches only its own folder if you add .air.toml.

    The rule

    text

    ~/CMS/ai/               → runs its own air on port 8080
    ~/CMS/officialstupid-me → runs its own air on port 8081
    ~/CMS/anything-else     → runs its own air on port 8082

    One terminal per site. Each cd into its folder, each runs air.

    Step 1 — Add .air.toml to every site

    In each site folder:

    bash

    cd ~/CMS/ai
    cat > .air.toml <<'EOF'
    root = "."
    tmp_dir = "tmp"
    
    [build]
    cmd = "go build -o ./tmp/cms main.go"
    bin = "./tmp/cms"
    include_ext = ["go", "html", "css", "js"]
    exclude_dir = ["tmp", "uploads", "node_modules", ".git"]
    delay = 200
    stop_on_error = true
    send_interrupt = false
    kill_delay = "500ms"
    
    [log]
    time = true
    main_only = true
    EOF

    Do the same for officialstupid-me.

    Step 2 — Change the port per site

    Currently both sites default to 127.0.0.1:8080. They will conflict.

    Edit main.go in each site. Find:

    go

    Addr:              "127.0.0.1:8080",

    Change to use an env variable:

    go

    addr := os.Getenv("CMS_ADDR")
    if addr == "" {
        addr = "127.0.0.1:8080"
    }

    And in the srv := &http.Server{...} block:

    go

    Addr:              addr,

    Do that once per site. Then start each with a different port.

    Step 3 — Run each site on its own port

    Terminal 1:

    bash

    cd ~/CMS/ai
    CMS_ADDR=127.0.0.1:8080 air

    Terminal 2:

    bash

    cd ~/CMS/officialstupid-me
    CMS_ADDR=127.0.0.1:8081 air

    Browser:

    • http://127.0.0.1:8080/ → ai site
    • http://127.0.0.1:8081/ → officialstupid-me

    Both run at the same time. Completely isolated.

    Step 4 — Clean up stray build artifacts

    Your tree shows tmp/cms and tmp/main in both sites. Those are from air. tmp/ is safe to delete anytime — air will recreate it.

    bash

    rm -rf ~/CMS/ai/tmp ~/CMS/officialstupid-me/tmp

    Also ~/CMS/ai/data.db-shm and data.db-wal are runtime files that travel with data.db. Keep them.

    Step 5 — What to keep vs delete per site

    Keep (per site):

    • main.go, go.mod, go.sum
    • templates/, static/, uploads/
    • data.db, data.db-shm, data.db-wal
    • .password_hash (if it exists)
    • .air.toml

    Delete or ignore:

    • tmp/ (build cache — auto-created)
    • cms binary in the root (leftover)
    • main.7z and main-good.7z — your personal backups, up to you

    Step 6 — Template for future sites

    Make one clean template folder to clone from.

    bash

    cd ~/CMS
    mkdir -p _template
    cp ai/main.go _template/
    cp ai/go.mod _template/
    cp ai/go.sum _template/
    cp -r ai/templates _template/
    cp -r ai/static _template/
    cp ai/.air.toml _template/

    To start a new site:

    bash

    cd ~/CMS
    cp -r _template new-site
    cd new-site
    mkdir -p uploads
    # edit main.go: change defaultPassword, add CMS_ADDR env, change site title in base.html
    CMS_ADDR=127.0.0.1:8082 air
  • Snip


    Get it

    Try it. If it’s the ten-second experience you’ve been missing, tell a friend.

    If it isn’t, tell me why and I’ll fix it.

    🛠 How to install

    Step 1 — Download the zip

    Click the download link above. It saves as snip_0.1.0-1_amd64.zip in your Downloads folder.

    Step 2 — Open a terminal

    Press Ctrl + Alt + T, or search for “Terminal” in your app menu.

    Step 3 — Unzip the file

    bash

    cd ~/Downloads
    unzip snip_0.1.0-1_amd64.zip

    You should now see a file called snip_0.1.0-1_amd64.deb.

    Step 4 — Install it

    bash

    sudo apt install ./snip_0.1.0-1_amd64.deb

    Enter your password when prompted. apt will automatically download Qt6 and any other dependencies it needs. Takes about 30 seconds on a fresh system.

    Step 5 — Install ffmpeg (once, if you don’t have it)

    bash

    sudo apt install ffmpeg

    Most systems already have it. If it’s already installed, this just says so and moves on.

    Step 6 — Run Snip

    From the terminal:

    bash

    snip

    Or open your application menu and search for Snip.

    That’s it. Six steps, done in under a minute.


    How to use it

    Trimming a video

    1. Drag a video onto the window (or click Open…)
    2. Use Play clip or drag on the timeline to find your start
    3. Click Set In
    4. Move to your end point
    5. Click Set Out — the range highlights in blue
    6. (Optional) Change Quality or tick Lossless
    7. Click Export Clip… and pick where to save

    Result: a trimmed file at your target size. Your original is never modified.

    That’s it.


    Uninstall

    Changed your mind? One command:

    bash

    sudo apt remove snip

    Clean, complete removal. Nothing left behind.


    What’s next

    On the roadmap:

    • Thumbnail strip along the timeline
    • Crop overlay for images (drag to crop visually)
    • Batch image export
    • Cancel button during encode
    • Flatpak package for cross-distro install

    If you want any of these sooner, ping me.


    🌐 Also available as a web app

    Don’t want to install anything? Try the web version at officialstupid.com/cut.

    It runs entirely in your browser using WebAssembly — no server, no upload. Same features, slightly slower trim (WASM is ~5× slower than native), but zero install.

    WebNative
    Installnone.deb
    Speed5× slowerfull speed
    File size limit~1.5 GBnone
    PlatformsanywhereDebian / Ubuntu

    Use whichever fits.


    Credits

    Built because Linux deserved a Preview.app moment. Made with Qt6 and too much coffee.

    If Snip saves you a Kdenlive launch, tell a friend.


    Feedback

    Something broken? Something missing? Open a GitHub issue, or email me directly. I read everything.

    — Rawi Rai


    Posted on October 7, 2026

  • world – football physic

    Here’s the complete, self-contained ball physics implementation — everything needed for someone to actually drop this in and have it work correctly.

    1. In build.js — ground detection, pivot centering, and ball setup

    Add this method (unfiltered ground check, needed because the player-oriented getGroundHeight has a step-up filter that breaks fast-moving objects):

    javascript

    // Unfiltered ground check for physics objects (balls) — always returns the
    // true topmost surface, even if the object has tunneled far below it in one frame.
    getGroundHeightRaw(pos) {
        if (!this.buildGroup.visible || this.collidableMeshes.length === 0) return 0;
        this.downRay.set(new THREE.Vector3(pos.x, 200, pos.z), new THREE.Vector3(0, -1, 0));
        const hits = this.downRay.intersectObjects(this.collidableMeshes);
        if (hits.length === 0) return 0;
        return hits[0].point.y;
    }

    Update normalizeModel to support a centered pivot (rolling objects need their origin at the true center, not the base — otherwise rotation swings the mesh around its edge instead of spinning in place):

    javascript

    normalizeModel(gltfScene, targetMaxSize = 2.5, centerPivot = false) {
        const box = new THREE.Box3().setFromObject(gltfScene);
        const size = box.getSize(new THREE.Vector3());
        const maxDim = Math.max(size.x, size.y, size.z);
    
        if (maxDim > targetMaxSize) {
            const autoScale = targetMaxSize / maxDim;
            gltfScene.scale.setScalar(autoScale);
        }
    
        const scaledBox = new THREE.Box3().setFromObject(gltfScene);
    
        if (centerPivot) {
            const center = scaledBox.getCenter(new THREE.Vector3());
            const sx = gltfScene.scale.x || 1, sy = gltfScene.scale.y || 1, sz = gltfScene.scale.z || 1;
            gltfScene.children.forEach(child => {
                child.position.x -= center.x / sx;
                child.position.y -= center.y / sy;
                child.position.z -= center.z / sz;
            });
        } else {
            const minY = scaledBox.min.y;
            if (Math.abs(minY) > 0.001) {
                const currentScaleY = gltfScene.scale.y || 1;
                gltfScene.children.forEach(child => {
                    child.position.y -= (minY / currentScaleY);
                });
            }
        }
    }

    Update createInScene to detect balls, use the centered pivot, record the real radius, and exclude ball meshes from the collidable/ground list:

    javascript

    createInScene(data) {
        this.loader.load('assets/' + data.file, (gltf) => {
            const obj = gltf.scene;
            const nameLower = data.file.toLowerCase();
            const isBall = nameLower.includes('soccer') || nameLower.includes('ball');
    
            this.normalizeModel(obj, 2.5, isBall);
    
            obj.position.set(data.pos.x, data.pos.y, data.pos.z);
            obj.rotation.y = data.rot;
            if (data.scale !== undefined && data.scale !== null) obj.scale.setScalar(data.scale);
    
            obj.userData.id = data.id || ('obj_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7));
            obj.userData.file = data.file;
    
            const isCar = nameLower.includes('car') || nameLower.includes('auto') || nameLower.includes('truck') || nameLower.includes('vehicle');
            const isPlane = nameLower.includes('plane') || nameLower.includes('jet') || nameLower.includes('aircraft') || nameLower.includes('flight');
    
            if (isCar || isPlane) {
                obj.userData.isVehicle = true;
                if (isPlane) obj.userData.isPlane = true;
            }
    
            if (isBall) {
                obj.userData.isBall = true;
                obj.userData.velocity = new THREE.Vector3(0, 0, 0);
                const box = new THREE.Box3().setFromObject(obj);
                const size = box.getSize(new THREE.Vector3());
                obj.userData.radius = Math.max(size.x, size.y, size.z) / 2;
            }
    
            this.buildGroup.add(obj);
            obj.traverse(c => {
                if (c.isMesh) {
                    if (!isBall) this.collidableMeshes.push(c); // balls must never act as ground/walls
                    if (obj.userData.isVehicle) c.userData.isVehiclePart = true;
                }
            });
        });
    }

    2. In your main game file — the full physics loop

    javascript

    const BALL = {
        GRAVITY: -22,
        RESTITUTION: 0.55,       // energy kept per bounce — always < 1, so it settles eventually
        GROUND_FRICTION: 0.985,  // rolling resistance — closer to 1 = rolls farther
        AIR_DRAG: 0.998,
        MAX_SPEED: 35,
        RADIUS: 0.5,             // fallback if a ball's real radius wasn't computed
        KICK_DIST: 5.0,
        KICK_FORCE: 30,
        KICK_LIFT_RATIO: 0.22,   // vertical launch = horizontal force * this ratio, keeps a visible arc
        KICK_COOLDOWN: 0.4,
        SYNC_RATE: 0.05
    };
    
    let lastBallSync = 0;
    
    function updateBalls(delta) {
        if (!localPlayer) return;
        const now = performance.now();
        const doSync = (now - lastBallSync) > BALL.SYNC_RATE * 1000;
    
        for (const obj of build.buildGroup.children) {
            if (!obj.userData || !obj.userData.isBall) continue;
            stepBall(obj, delta, doSync);
        }
        if (doSync) lastBallSync = now;
    }
    
    function stepBall(ball, delta, doSync) {
        const d = ball.userData;
        if (!d.vel) d.vel = new THREE.Vector3();
        if (d.kickCd === undefined) d.kickCd = 0;
        if (d.wasNear === undefined) d.wasNear = false;
        const vel = d.vel;
        const radius = ball.userData.radius || BALL.RADIUS;
    
        d.kickCd = Math.max(0, d.kickCd - delta);
    
        // --- KICK: fires once when entering range, not every frame ---
        const toBall = new THREE.Vector3().subVectors(ball.position, localPlayer.position);
        const flatDist = Math.hypot(toBall.x, toBall.z);
        const isNear = flatDist < BALL.KICK_DIST;
    
        if (isNear && !d.wasNear && d.kickCd <= 0) {
            const running = !!(keys['shift'] && (keys['w'] || keys['s']));
            const dir = flatDist > 0.001
                ? new THREE.Vector3(toBall.x / flatDist, 0, toBall.z / flatDist)
                : new THREE.Vector3(0, 0, 1).applyQuaternion(localPlayer.quaternion);
    
            const force = running ? BALL.KICK_FORCE * 1.8 : BALL.KICK_FORCE;
            vel.x = dir.x * force;
            vel.z = dir.z * force;
            vel.y = force * BALL.KICK_LIFT_RATIO; // scales with kick strength — keeps the bounce visible
            d.kickCd = BALL.KICK_COOLDOWN;
        }
        d.wasNear = isNear;
    
        // --- GRAVITY: unconditional, every frame ---
        vel.y += BALL.GRAVITY * delta;
    
        // --- SPEED CAP: nothing can ever runaway ---
        const speed = vel.length();
        if (speed > BALL.MAX_SPEED) vel.multiplyScalar(BALL.MAX_SPEED / speed);
    
        // --- MOVE ---
        ball.position.addScaledVector(vel, delta);
    
        // --- GROUND: recomputed AFTER moving, using the ball's real radius ---
        const groundY = build.getGroundHeightRaw(ball.position) + radius;
    
        if (ball.position.y <= groundY) {
            ball.position.y = groundY;
            if (vel.y < -0.6) {
                vel.y = -vel.y * BALL.RESTITUTION; // bounce, always loses energy
            } else {
                vel.y = 0; // too slow to bounce — settle
            }
            vel.x *= BALL.GROUND_FRICTION;
            vel.z *= BALL.GROUND_FRICTION;
        } else {
            vel.x *= BALL.AIR_DRAG;
            vel.z *= BALL.AIR_DRAG;
        }
    
        // --- FULL STOP once slow enough (kills leftover jitter) ---
        const horizSpeed = Math.hypot(vel.x, vel.z);
        const onGround = ball.position.y <= groundY + 0.001;
        if (onGround && horizSpeed < 0.05 && Math.abs(vel.y) < 0.05) {
            vel.set(0, 0, 0);
        }
    
        // --- ROLL: pivot-centered, so it spins instead of swinging ---
        if (horizSpeed > 0.02) {
            const axis = new THREE.Vector3(0, 1, 0).cross(vel).normalize();
            if (!isNaN(axis.x)) ball.rotateOnWorldAxis(axis, (horizSpeed * delta) / radius);
        }
    
        // --- NETWORK SYNC (skip once at rest, saves bandwidth) ---
        if (doSync && (horizSpeed > 0.05 || Math.abs(vel.y) > 0.05)) {
            build.syncObjectUpdate(ball);
        }
    }

    3. Wire it into your render loop

    javascript

    function animate() {
        requestAnimationFrame(animate);
        const delta = Math.min(clock.getDelta(), 0.05);
    
        if (localPlayer) {
            updateBalls(delta); // call once per frame, before other player logic
            // ...rest of your animate loop
        }
    
        renderer.render(scene, camera);
    }

    That’s the whole thing — self-contained, no missing pieces. Requires build.getGroundHeightRaw, build.syncObjectUpdate, and any placed object with userData.isBall = true and userData.radius set (both handled by the build.js changes above).


    Six Bugs I Had to Fix Before a Ball Would Just… Roll

    I recently added a kickable soccer ball to a small multiplayer 3D world. Gravity, a bounce, some friction — how hard could it be?

    Famous last words. Here’s what actually happened, in order, and what each bug taught me about writing physics code that has to survive contact with a real player mashing keys in a browser.

    Bug #1: The ball that never stopped flying

    The first version worked… for about three seconds. Then the ball would drift upward, slowly at first, then faster, until it disappeared into the sky and never came back down.

    The cause: my “kick” logic checked “is the player close to the ball?” and fired every single frame that condition was true — not just once. So if a player stood near the ball, it got re-kicked 60 times a second, each kick nudging in a slightly upward direction. Gravity got one frame to pull it back down before the next kick overwrote its velocity again. Net result: a slow, steady climb into orbit.

    The fix was to edge-detect the moment a player enters range, rather than checking “are they in range right now”:

    javascript

    const isNear = flatDist < KICK_DIST;
    
    if (isNear && !wasNear && kickCooldown <= 0) {
        vel.x = dir.x * force;
        vel.z = dir.z * force;
        kickCooldown = 0.4; // seconds before this ball can be kicked again
    }
    wasNear = isNear;

    One flag (wasNear) and a short cooldown turned a runaway feedback loop into a single, clean impulse.

    Bug #2: The ball that thought the sky was the ground

    Next problem: the ball would bounce, then suddenly hover in mid-air, refusing to fall.

    This one took longer to spot because it wasn’t really a physics bug — it was a raycasting bug. Every object placed in the world (buildings, props, the ball itself) got added to the same list of “things you can stand on.” When the ball needed to find the ground beneath it, it cast a ray straight down through that list — and hit itself. The ball was, quite literally, treating its own surface as solid ground.

    javascript

    obj.traverse(c => {
        if (c.isMesh) {
            if (!isBall) collidableMeshes.push(c); // balls must never act as their own floor
        }
    });

    One if statement. The lesson generalizes though: any time an object needs to query the world about itself, make sure it’s excluded from its own query.

    Bug #3: The ball buried thirty percent in the ground

    With the self-collision bug fixed, bounces looked mostly right — except every so often the ball would settle noticeably below the visible floor, like it was standing in a shallow hole.

    The culprit was a different ground-detection function that already existed in the codebase — one built for a walking character, with a “step-up” filter: it deliberately ignores any ground hit that’s more than half a unit above the character’s current position, so a player doesn’t teleport up through a low ceiling.

    That filter is exactly right for a person walking up a curb. It’s exactly wrong for a fast-bouncing ball. A hard bounce can move the ball several units in a single frame, tunneling it well below the real floor — at which point the actual ground is now above the filter’s cutoff, and the function throws that correct answer away and falls back to a default height instead.

    The fix was to add a second, unfiltered ground check specifically for physics objects:

    javascript

    function getGroundHeightRaw(pos) {
        downRay.set(new THREE.Vector3(pos.x, 200, pos.z), new THREE.Vector3(0, -1, 0));
        const hits = downRay.intersectObjects(collidableMeshes);
        return hits.length ? hits[0].point.y : 0;
    }

    No step-up gating — just “what’s the nearest surface directly below me, right now.” Characters still use the filtered version; physics objects use the raw one. Same world, two different questions being asked of it.

    Bug #4: The ball that dipped mid-roll for no reason

    Even with correct ground detection, the ball would occasionally dip a few inches into the floor while rolling — not falling through, just… sagging, rhythmically, as it moved.

    This turned out to be a pivot problem, not a physics problem. The model-normalization code (shared across every asset type in the world) shifted each object’s origin to sit at the bottom of its bounding box — sensible for a building or a lamppost, which should sit flush on the ground. But for a sphere, that means the rotation pivot is on its surface, not its center. Spinning it doesn’t make it roll — it makes it swing, arcing up and down around that off-center point as it turns.

    javascript

    if (centerPivot) {
        const center = box.getCenter(new THREE.Vector3());
        children.forEach(child => {
            child.position.x -= center.x / scale.x;
            child.position.y -= center.y / scale.y;
            child.position.z -= center.z / scale.z;
        });
    }

    Flagging rolling objects to use a centered pivot instead of a base-aligned one fixed it immediately. It’s a good reminder that “normalize this model” isn’t a one-size-fits-all operation — different shapes need different assumptions about where their origin should live.

    Bug #5: A duplicate variable that quietly broke everything

    At one point, a refactor left two const groundY = ... declarations inside the same function — one computed too early (before the ball had even moved that frame), one computed correctly afterward. In JavaScript, that’s not a silent bug: it’s a SyntaxError, and the whole module fails to load. No error dialog, no partial functionality — just nothing happening at all.

    The lesson here isn’t really about physics: it’s about how easy it is, mid-iteration, to leave a stale calculation sitting above a newer one that was supposed to replace it. Worth a second read-through any time you patch a function in place rather than rewriting it clean.

    Bug #6: Kicks that slid instead of bounced

    Last one was more of a tuning problem than a bug. After making kicks noticeably more powerful (further reach, faster travel), the satisfying bounce disappeared — the ball just skimmed flat and fast across the ground.

    The vertical “hop” applied on each kick had been a fixed number, left over from when kicks were much weaker. Once horizontal force scaled up, that fixed vertical component became proportionally tiny — barely enough to leave the ground, so gravity brought it back down before it built up any real bounce.

    javascript

    const force = running ? KICK_FORCE * 1.8 : KICK_FORCE;
    vel.x = dir.x * force;
    vel.z = dir.z * force;
    vel.y = force * KICK_LIFT_RATIO; // vertical launch now scales with kick strength

    Tying the vertical launch to a ratio of the horizontal force, instead of a flat constant, meant the bounce-to-distance feel stayed consistent no matter how hard the kick was tuned later.

    What it all adds up to

    None of these bugs were exotic. No collision-response math, no rigid-body solvers, nothing you’d find in a physics textbook. Every single one came from a small mismatch between what a piece of code was written to assume and what actually happened once real, fast-changing values ran through it repeatedly:

    • State that fires every frame instead of once (the flying ball)
    • An object querying a system that includes itself (the floating ball)
    • A filter built for one use case silently breaking another (the buried ball)
    • A shared function with assumptions that don’t hold for every shape (the swinging roll)
    • A leftover calculation nobody deleted (the crash)
    • A constant that didn’t scale with the value next to it (the flat kick)

    If there’s a single takeaway, it’s this: physics code doesn’t usually break because the formula is wrong. It breaks because the code around the formula — the state tracking, the filtering, the assumptions borrowed from a different object type — wasn’t built to survive the specific, repetitive, fast-moving conditions that physics actually runs under. The gravity equation was one line. Getting a ball to just roll normally took six separate, unglamorous fixes.

  • Dev Blog: Engineering the “Infinite Run” – Audio & more balls

    Asset: Ball_Rumble_Loop.sys

    Deep 40Hz Triangle Wave – Simulates heavy rolling friction.

    Asset: Walking_Thud.sys

    60Hz Sine Pitch-Drop – Simulates a massive 2X giant footstep.

    Asset: Flatten_Crunch.sys

    Filtered White Noise Burst – Simulates a cartoon crushing impact.

    Subtitle: Engineering a Zero-Asset Audio-Visual Landscape

    To elevate the Infinite Run project to a professional standard, we move away from "beeps" and "boops" toward Dynamic Sound Synthesis and Multi-Material Physics. Professional game design isn't just about playing a file; it is about Audio Geometry and Physical Logic—where the sound and movement feel physically connected to the 3D objects.

    In high-performance web applications, latency and loading times are the enemy. For Infinite Run, we engineered a Real-Time DSP (Digital Signal Processing) solution and a Dynamic Ball Controller that changes the laws of physics on the fly.


    1. The Physics of the "Thud" (Walking)

    Instead of a simple tone, we synthesized an Impulse Response. We use a Sine Oscillator that starts at 60Hz and drops exponentially. This creates a "weighted" impact that sounds like a massive 2X giant hitting the ground, rather than a digital beep.

    2. Spatial Rumble (The Ball)

    To give the Giant Ball its threatening presence, we utilized a Triangle Wave. Unlike a smooth Sine wave, a Triangle wave has subtle harmonics that mimic the friction of a heavy object rolling. By calculating the distance between the camera and the ball every frame, we adjust the gain dynamically so the sound "zooms" past the listener.

    3. The "Crunch" (Collision)

    For the flattening effect, we synthesized White Noise. By generating a 0.25-second buffer of randomized data and passing it through a Biquad Filter (Low-Pass), we removed the harsh "hiss" and kept only the "bone-crushing" low frequencies.

    4. The Material Engine: 3 Modes of Threat

    To keep the simulation unpredictable, we implemented three distinct physics profiles for the ball:

    • Standard Mode: Linear momentum for a heavy, unstoppable roll.
    • Rubber Mode (Parabolic Gravity): This ball drops from the sky ( Y=60Y=60 ) and calculates Restitution. It loses 25% energy per bounce, creating realistic jumping arcs.
    • Snake Mode (Trigonometric Path): Governed by a Sine wave, this ball weaves across the lanes, forcing runners to track its horizontal oscillation.

    The Professional Code (The "Secret Sauce")

    Here are the specific lines that make the physics and sound feel "Pro."

    I. The Step (Synced to Animation)

    We play it exactly when the animation "swing" crosses zero (the moment the foot touches the floor).

    code JavaScript

    // Inside the runner update loop
    if (this.state === 'running' && Math.sign(swingX) !== this.lastSwing) {
        if (this.group.position.z > 0) sfx.playStep(); // Triggered at the exact frame of impact
        this.lastSwing = Math.sign(swingX);
    }

    II. The Ball Gain (Distance Mapping)

    Instead of gain.value = x, we use setTargetAtTime for smooth, click-free transitions.

    code JavaScript

    updateBallVolume(zPos, mode) {
        // Standard mode uses distance-based rumble; Rubber is silent in-air
        let vol = (mode === 'standard') ? THREE.MathUtils.mapLinear(zPos, -400, 140, 0, 0.4) : 0;
        this.ballGain.gain.setTargetAtTime(Math.max(0, vol), this.ctx.currentTime, 0.05);
    }

    III. The Rubber Ball (Gravity & Bouncing)

    Using a restitution coefficient to simulate energy loss.

    code JavaScript

    if (currentBallMode === 'rubber' && ballActive) {
        ballVY += ballGravity; // Apply constant downward force
        deathBall.position.y += ballVY;
        if (deathBall.position.y <= ballRadius) {
            deathBall.position.y = ballRadius;
            ballVY = -ballVY * 0.75; // Bounce with 75% energy return
            if (Math.abs(ballVY) > 0.15) sfx.playBoing(); // Synthesized impact sound
        }
    }

    IV. The Snake Ball (Sine Wave Oscillation)

    Using time-based trigonometry to create the S-path.

    code JavaScript

    if (currentBallMode === 'snake' && ballActive) {
        // ballStartX is the tap location, 25 is the weave width
        deathBall.position.x = ballStartX + Math.sin(elapsed * 5) * 25;
        deathBall.position.y = ballRadius;
    }

    Why this is Professional:

    • Zero Latency: Sound triggers at the exact microsecond of the collision or footstep.
    • Zero Bandwidth: No one has to download audio files; the CPU creates them on the fly.
    • Infinite Variety: Because the ball volume and snake path are based on real-time calculations, the experience is never exactly the same twice.
    • Persistence: Every kill and every minute played is stored in the Memory Core (LocalStorage), ensuring the player's history is preserved.
  • world – round 2 – The Animation Evolution: Breathing Life into Our 3D World


    Introduction: From Static to Living

    In our previous deep dive, we explored the architectural foundation of our 3D multiplayer world—the GLB container format, the public/player/ folder structure, and the dynamic loading system that allows us to scale from 3 to 100 characters seamlessly. We built a robust backend, but a game world is more than just code; it’s an experience.

    Round 2 was about one thing: character personality.

    Players don’t just want to control a 3D model; they want to feel connected to their avatar. They want to see it breathe, react, and express itself—even when standing still. This blog post chronicles our journey through advanced animation sequencing, UX improvements, and the technical decisions that transformed a functional game into an emotionally engaging experience.


    Part 1: The Idle Revolution – Why Standing Still Matters

    The Problem: The Frozen Avatar Syndrome

    In our initial implementation, when a player stopped moving, their character would simply freeze in place—a static, lifeless pose that screamed “unfinished.” This is what we call the Frozen Avatar Syndrome, and it’s a silent killer of immersion.

    Players spend a significant portion of their time not moving: reading chat, inspecting their surroundings, or simply taking a moment to breathe. In those moments, their avatar should still feel alive.

    The Solution: Idle Animation Sequencing

    We took inspiration from the character selection screen, where hovering over a model triggered a delightful No → Wave animation loop. Why not bring that same magic into the game world?

    The Concept: When a player stops moving, their character automatically transitions into a personality-driven animation loop that repeats until they take action.

    javascript

    // The idle sequence logic
    function updateLocalIdleSequence(delta) {
        const isMoving = keys['w'] || keys['s'] || keys['a'] || keys['d'];
        
        if (isMoving || isJumping || keys[' ']) {
            // Player is active - stop idle sequence
            if (isIdleSequenceRunning) {
                if (noAction) noAction.stop();
                if (waveIdleAction) waveIdleAction.stop();
                if (idleAction) idleAction.stop();
                isIdleSequenceRunning = false;
                idleSequenceState = 0;
            }
            return;
        }
    
        // Player is idle - start the No → Wave loop
        if (!isIdleSequenceRunning) {
            isIdleSequenceRunning = true;
            idleSequenceState = 1;
            if (idleAction) idleAction.stop();
            if (noAction) {
                noAction.reset();
                noAction.play();
            }
            return;
        }
    }

    The UX Impact: This small addition transformed the feel of the game. Suddenly, characters had personality. The “No” gesture felt playful and confident; the “Wave” felt welcoming and interactive. Players reported feeling more connected to their avatars—not just as tools, but as extensions of themselves.


    Part 2: The Hover Experience – Turning Selection into a Showcase

    The Problem: Static Character Selection

    Our character selection screen was functional but boring. Three models stood in a row, frozen in idle poses, waiting to be clicked. Users had no way to preview a character’s personality or animation style before committing.

    The Solution: Interactive Hover Sequences

    We implemented a hover-triggered animation sequence that showcases each character’s personality:

    1. Hover → Character plays the “No” gesture (confident, playful)
    2. After No finishes → Character transitions to “Wave” (friendly, welcoming)
    3. After Wave finishes → Loop back to “No” (continuous performance)

    The Technical Execution:

    javascript

    renderer.domElement.addEventListener('mousemove', (event) => {
        // Raycast to detect which model is being hovered
        const intersects = raycaster.intersectObjects(selectionModels, true);
        let hitModel = null;
        if (intersects.length > 0) {
            let obj = intersects[0].object;
            while (obj.parent && !obj.userData.file) obj = obj.parent;
            if (obj.userData.file) hitModel = obj;
        }
    
        for (let [model, data] of previewData.entries()) {
            const isHovered = (model === hitModel);
            if (isHovered && !data.isHovering) {
                // Start the sequence
                data.isHovering = true;
                data.seqState = 1;
                if (data.idleAction) data.idleAction.stop();
                if (data.noAction) {
                    data.noAction.reset();
                    data.noAction.play();
                }
            } else if (!isHovered && data.isHovering) {
                // Stop hovering - go back to idle
                data.isHovering = false;
                data.seqState = 0;
                if (data.noAction) data.noAction.stop();
                if (data.waveAction) data.waveAction.stop();
                if (data.idleAction) data.idleAction.play();
            }
        }
    });

    The UX Impact: The selection screen became a stage. Users now spend time hovering over each character to see their full animation range. It’s no longer just a menu—it’s an audition, a personality showcase that makes the choice feel meaningful.


    Part 3: Dynamic Animation Management – The State Machine Approach

    The Problem: Animation Chaos

    With multiple animation states (Idle, Walk, Run, Jump, JumpLand, Wave), managing transitions cleanly became complex. Animations would overlap, fail to start, or get stuck in incorrect states.

    The Solution: A Clear State Hierarchy

    We implemented a state machine with clear priorities:

    text

    Jump > JumpLand > Run > Walk > Wave > Idle

    The Execution:

    javascript

    // Stop all animations first
    if (idleAction) idleAction.stop();
    if (walkAction) walkAction.stop();
    if (runAction) runAction.stop();
    if (waveAction) waveAction.stop();
    if (jumpAction) jumpAction.stop();
    if (jumpLandAction) jumpLandAction.stop();
    
    // Determine and play the correct animation
    let currentAnim = 'idle';
    if (isJumping) {
        currentAnim = 'jump';
        if (jumpAction) jumpAction.play();
    } else if (moving && running) {
        currentAnim = 'run';
        if (runAction) runAction.play();
    } else if (moving) {
        currentAnim = 'walk';
        if (walkAction) walkAction.play();
    } else if (waving) {
        currentAnim = 'wave';
        if (waveAction) waveAction.play();
    } else {
        currentAnim = 'idle';
        if (idleAction) idleAction.play();
    }

    The Key Insight: By stopping all animations before playing the new one, we eliminated the race conditions that caused overlapping animations. This “reset-first” approach ensured clean, reliable transitions every time.


    Part 4: Movement Enhancements – Run, Jump, and Wave

    The Problem: Limited Movement Vocabulary

    Initially, players could only walk. No running, no jumping, no social gestures. The movement felt flat and restrictive.

    The Solution: Expanded Movement Suite

    We added three new movement mechanics:

    1. Run (Hold Shift)

    • Speed increased from 6 to 12 units/second
    • Dedicated “Run” animation plays
    • Creates a sense of urgency and energy

    javascript

    const speed = keys['shift'] ? 12 : 6;
    const s = speed * delta;
    if (keys['w']) { 
        localPlayer.translateZ(s); 
        moving = true; 
        if (keys['shift']) running = true; 
    }

    2. Jump (Spacebar)

    • Physics-based jump with gravity
    • “Jump” and “Jump_Land” animations for realistic motion
    • Adds vertical dimension to the world

    javascript

    if (keys[' '] && !isJumping) {
        velocityY = jumpForce;
        isJumping = true;
        if(jumpAction) {
            jumpAction.stop();
            jumpAction.reset();
            jumpAction.play();
        }
    }

    3. Social Wave (Proximity-based)

    • When facing another player within 4 units
    • Automatically plays the “Wave” animation
    • Fosters social interaction without requiring a menu

    javascript

    if (!moving && !isJumping) {
        Object.values(otherPlayers).forEach(remote => {
            const dist = localPlayer.position.distanceTo(remote.model.position);
            if (dist < 4) {
                const dirToRemote = remote.model.position.clone().sub(localPlayer.position).normalize();
                const localForward = new THREE.Vector3(0, 0, 1).applyQuaternion(localPlayer.quaternion);
                const dot = localForward.dot(dirToRemote);
                if (dot > 0.7) { // Facing roughly toward the other player
                    waving = true;
                }
            }
        });
    }

    The UX Impact: These additions transformed movement from a utility into an expressive tool. Running feels urgent, jumping feels joyful, and waving feels friendly. Players now have a vocabulary of motion that matches their intent.


    Part 5: Technical Normalization – The Animation Bridge

    The Problem: Animation Name Inconsistency

    Our GLB files contained animations with numbered prefixes (e.g., “4. Idle”, “10. No”, “17. Wave”), but the game code expected un-prefixed names (“Idle”, “No”, “Wave”). This mismatch caused animations to fail silently.

    The Solution: Dynamic Animation Discovery

    We implemented a flexible animation discovery system that:

    1. Logs all available animations to the console for debugging
    2. Matches animations by exact name after inspecting the GLB
    3. Provides fallbacks if specific animations are missing

    javascript

    const idleClip = gltf.animations.find(a => a.name === 'Idle');
    const noClip = gltf.animations.find(a => a.name === 'No');
    const waveClip = gltf.animations.find(a => a.name === 'Wave');
    const walkClip = gltf.animations.find(a => a.name === 'Walk');
    const runClip = gltf.animations.find(a => a.name === 'Run');
    const jumpClip = gltf.animations.find(a => a.name === 'Jump');
    const jumpLandClip = gltf.animations.find(a => a.name === 'Jump_Land');

    The Lesson: The GLB format is incredibly powerful, but it requires careful introspection. By logging available animations and matching by exact name, we created a system that’s both robust and debuggable.


    Part 6: UX Refinements – The Small Details That Matter

    Visual Feedback

    • HUD Hint: “HOLD SHIFT TO RUN” appears in-game to teach new players
    • Count Display: Shows total players online
    • Camera Toggle: Right-click or V to flip between over-the-shoulder and face-forward views

    Responsive Design

    • Window resize handling for all screen sizes
    • Consistent UI scaling across devices
    • Touch and mouse support

    Performance Optimization

    • Animation mixers updated only when needed
    • Delta-time-based movement for consistent speed regardless of framerate
    • Efficient player updates using interpolation (position.lerp)

    The Results: What We Achieved

    Technical Wins

    1. Zero Animation Overlap – Clean state transitions every time
    2. Flexible Animation Discovery – Works with any properly formatted GLB
    3. Scalable Architecture – Adding new animations is as simple as finding them by name
    4. Performance-Neutral – All features added with minimal performance impact

    UX Wins

    1. Living Characters – Avatars feel alive even when standing still
    2. Emotional Connection – Players bond with their characters through personality
    3. Social Interaction – Waving creates organic, spontaneous connection
    4. Movement Vocabulary – Walk, Run, Jump, Wave – each with distinct feel

    Player Feedback

    “I love watching my character do the No → Wave loop when I’m waiting. It makes me feel like they have a personality.”

    “The run animation is so much better than just moving faster. It feels like my character is actually exerting effort.”

    “I waved at another player and they waved back. It was a tiny moment but it made the world feel real.”


    The Future: What’s Next

    Animation Expansion

    • More Idle Loops: Multiple idle sequences for variety
    • Contextual Animations: Character-specific gestures based on personality
    • Emote System: Full emote wheel for social expression

    Technical Improvements

    • Animation Blending: Smooth transitions between states
    • Procedural Animation: Subtle breathing and weight shifts
    • LOD System: Performance optimization for many players

    Social Features

    • Emote Previews: See emotes before selecting them
    • Sync’d Animations: Group dances and coordinated gestures
    • Reaction System: Quick reactions to events (cheer, boo, etc.)

    Conclusion: The Art of First Impressions

    Building a robust file architecture gave us the foundation. But breathing life into that architecture—turning a static selection screen into a dynamic, personality-driven showcase—is what makes the player feel like they’re stepping into a world that cares about detail.

    The GLB format, combined with a thoughtful state machine and Three.js’s animation system, allowed us to achieve this with minimal code changes and zero asset pipeline overhead.

    The game isn’t just a playground anymore. It’s a stage where every character has a role to play.

    https://chat.deepseek.com/

  • Protected: tiny – mero.live

    This content is password-protected. To view it, please enter the password below.

  • stickers(gif) – beautiful frustration

    #bookmark

    (function() {
        'use strict';
        const sub = window.SITE_SUBDOMAIN;
    
        // 1. UI INJECTION (All tools included)
        const uiWrap = document.createElement('div');
        uiWrap.id = "cms-editor-wrapper";
        uiWrap.innerHTML = `
            <div id="sel-tb" class="sel-tb">
                <button data-cmd="bold"><span class="material-icons">format_bold</span></button>
                <button data-cmd="italic"><span class="material-icons">format_italic</span></button>
                <button data-cmd="underline"><span class="material-icons">format_underlined</span></button>
                <button id="add-lnk"><span class="material-icons">link</span></button>
                <button id="add-id"><span class="material-icons">fingerprint</span></button>
                <div class="ctrl-grp"><input type="color" id="clr-txt" value="#000000"></div>
                <div class="ctrl-grp"><input type="color" id="clr-bg" value="#ffff00"></div>
            </div>
            <div id="plus-btn"><span class="material-icons">add</span></div>
            <div id="ins-m" class="ins-m">
                <div class="group"><div class="g-title">Text Blocks</div><div class="g-row">
                    <div data-b="H1"><span class="material-icons">looks_one</span><label>H1</label></div>
                    <div data-b="H2"><span class="material-icons">looks_two</span><label>H2</label></div>
                    <div data-b="H3"><span class="material-icons">looks_3</span><label>H3</label></div>
                    <div data-b="H4"><span class="material-icons">looks_4</span><label>H4</label></div>
                    <div data-b="P"><span class="material-icons">subject</span><label>P</label></div>
                    <div id="do-ul"><span class="material-icons">format_list_bulleted</span><label>List</label></div>
                </div></div>
                <div class="group"><div class="g-title">Media</div><div class="g-row">
                    <div id="do-img"><span class="material-icons">image</span><label>Img/Sticker</label></div>
                    <div id="do-vid"><span class="material-icons">smart_display</span><label>Video</label></div>
                </div></div>
                <div class="group"><div class="g-title">Layout</div><div class="g-row">
                    <div data-g="2"><span class="material-icons">view_week</span><label>2 Col</label></div>
                    <div data-g="3"><span class="material-icons">view_column</span><label>3 Col</label></div>
                    <div data-g="4"><span class="material-icons">view_module</span><label>4 Col</label></div>
                    <div data-g="6"><span class="material-icons">grid_view</span><label>6 Col</label></div>
                    <div data-g="8"><span class="material-icons">grid_on</span><label>8 Col</label></div>
                </div></div>
            </div>
            <input type="file" id="f-sys" style="display:none" accept="image/*,image/avif">
        `;
        document.body.appendChild(uiWrap);
    
        const viewport = document.querySelector('.viewport') || document.querySelector('.editor');
        const selTb = document.getElementById('sel-tb');
        const plusBtn = document.getElementById('plus-btn');
        const insMenu = document.getElementById('ins-m');
        const fSys = document.getElementById('f-sys');
    
        let activeRange = null, activeBlock = null;
        const autoSave = () => { if(window.performAutoSave) window.performAutoSave(); };
    
        // --- UPLOAD HELPER ---
        async function uploadToServer(file) {
            const fd = new FormData(); fd.append('file', file);
            try {
                const r = await fetch(`https://madenp.com/upload.php?action=upload&subdomain=${sub}`, { method: 'POST', body: fd, credentials: 'include' });
                const d = await r.json();
                return d.success ? `https://madenp.com/media.php?s=${sub}&f=${d.filename}` : null;
            } catch (e) { return null; }
        }
    
        // --- RESPONSIVE TRANSFORM ENGINE ---
        const applyTransform = (el) => {
            el.style.position = 'absolute';
            el.style.left = el.dataset.x + "%";
            el.style.top = el.dataset.y + "%";
            // translate(-50%, -50%) ensures the CENTER of the image stays on the % coordinate
            el.style.transform = `translate(-50%, -50%) scale(${el.dataset.scale}) rotate(${el.dataset.rotate}deg)`;
            el.style.margin = "0";
            el.classList.add('is-active');
        };
    
        const activateSticker = (sticker) => {
            if (sticker.classList.contains('is-active')) return;
    
            // Find the nearest parent section/article to anchor to
            const parent = sticker.closest('section, article, .row, .viewport, .editor');
            parent.style.position = 'relative';
    
            const pRect = parent.getBoundingClientRect();
            const sRect = sticker.getBoundingClientRect();
    
            // Calculate center relative to parent in percentages
            const centerX = (sRect.left + sRect.width / 2) - pRect.left;
            const centerY = (sRect.top + sRect.height / 2) - pRect.top;
    
            sticker.dataset.x = ((centerX / pRect.width) * 100).toFixed(2);
            sticker.dataset.y = ((centerY / pRect.height) * 100).toFixed(2);
            sticker.dataset.scale = sticker.dataset.scale || "1";
            sticker.dataset.rotate = sticker.dataset.rotate || "0";
    
            applyTransform(sticker);
        };
    
        document.addEventListener('mousedown', (e) => {
            const sticker = e.target.closest('.gif-sticker');
            if (!sticker) return;
    
            activateSticker(sticker);
    
            const parent = sticker.parentElement;
            const isResizer = e.target.classList.contains('resizer');
            const isRotator = e.target.closest('.rotator');
    
            if (isResizer) {
                const onResize = (ev) => {
                    let scale = parseFloat(sticker.dataset.scale);
                    scale += ev.movementX * 0.005;
                    sticker.dataset.scale = Math.max(0.1, scale).toFixed(3);
                    applyTransform(sticker);
                };
                document.addEventListener('mousemove', onResize);
                document.addEventListener('mouseup', () => { document.removeEventListener('mousemove', onResize); autoSave(); }, {once:true});
            }
            else if (isRotator) {
                const onRotate = (ev) => {
                    let r = parseFloat(sticker.dataset.rotate);
                    r += ev.movementX;
                    sticker.dataset.rotate = r.toFixed(1);
                    applyTransform(sticker);
                };
                document.addEventListener('mousemove', onRotate);
                document.addEventListener('mouseup', () => { document.removeEventListener('mousemove', onRotate); autoSave(); }, {once:true});
            }
            else {
                const onMove = (ev) => {
                    const pRect = parent.getBoundingClientRect();
                    let x = ((ev.clientX - pRect.left) / pRect.width) * 100;
                    let y = ((ev.clientY - pRect.top) / pRect.height) * 100;
                    sticker.dataset.x = x.toFixed(2);
                    sticker.dataset.y = y.toFixed(2);
                    applyTransform(sticker);
                };
                document.addEventListener('mousemove', onMove);
                document.addEventListener('mouseup', () => { document.removeEventListener('mousemove', onMove); autoSave(); }, {once:true});
            }
            e.preventDefault();
        });
    
        // --- EDITOR UI LOGIC ---
        viewport.addEventListener('mousemove', (e) => {
            const block = e.target.closest('.viewport > *, .col > *, h1, h2, h3, h4, p, img, .row, section, article');
            if (block && viewport.contains(block) && !block.classList.contains('gif-sticker')) {
                activeBlock = block;
                const rect = block.getBoundingClientRect();
                plusBtn.style.display = 'flex';
                plusBtn.style.top = `${rect.bottom + window.scrollY - 15}px`;
                plusBtn.style.left = `${rect.left + window.scrollX + (rect.width / 2)}px`;
            }
        });
    
        plusBtn.onclick = (e) => {
            e.stopPropagation();
            insMenu.style.display = 'block';
            insMenu.style.left = plusBtn.style.left; insMenu.style.top = plusBtn.style.top;
        };
    
        const insertAfter = (html) => {
            if (activeBlock) activeBlock.insertAdjacentHTML('afterend', html);
            else viewport.insertAdjacentHTML('beforeend', html);
            insMenu.style.display = 'none'; autoSave();
        };
    
        // --- TOOL ACTIONS ---
        insMenu.querySelectorAll('[data-b]').forEach(i => {
            i.onclick = () => insertAfter(`<${i.dataset.b.toLowerCase()}>New Block</${i.dataset.b.toLowerCase()}>`);
        });
    
        document.getElementById('do-ul').onclick = () => insertAfter('<ul><li>List Item</li></ul>');
    
        insMenu.querySelectorAll('[data-g]').forEach(i => {
            i.onclick = () => {
                const c = parseInt(i.dataset.g);
                let h = `<div class="row ${c > 4 ? 'mobile-overflow' : ''}" contenteditable="false">`;
                for(let j=0; j<c; j++) h += `<div class="col" contenteditable="true"><p>Column ${j+1}</p></div>`;
                h += `</div><p><br></p>`;
                insertAfter(h);
            };
        });
    
        // --- MEDIA ---
        document.getElementById('do-img').onclick = () => fSys.click();
        fSys.onchange = async function() {
            const file = this.files[0]; if(!file) return;
            const url = await uploadToServer(file);
            if(!url) return;
    
            const isSticker = file.name.match(/\.(gif|avif)$/i) || file.type.match(/(gif|avif)/i);
    
            if(isSticker) {
                // STEP 1: Insert as normal block (width is set smaller to start)
                insertAfter(`
                    <div class="gif-sticker" style="position:relative; display:block; margin: 20px auto; width: 180px; cursor: pointer; z-index:10;" contenteditable="false">
                        <img src="${url}" style="width:100%; display:block; pointer-events:none;">
                        <div class="gif-handle rotator"><span class="material-icons">cached</span></div>
                        <div class="gif-handle resizer"></div>
                    </div>
                `);
            } else {
                insertAfter(`<img src="${url}" class="standard-img">`);
            }
            this.value = ''; autoSave();
        };
    
        document.getElementById('do-vid').onclick = () => {
            const url = prompt("YouTube URL:"); if (!url) return;
            let id = url.includes('v=') ? url.split('v=')[1].split('&')[0] : url.split('/').pop();
            if (id) insertAfter(`<div class="v-w" contenteditable="false"><iframe src="https://www.youtube.com/embed/${id}" allowfullscreen></iframe></div><p><br></p>`);
        };
    
        const run = (c, v = null) => {
            if (activeRange) { const s = window.getSelection(); s.removeAllRanges(); s.addRange(activeRange); }
            document.execCommand(c, false, v); autoSave();
        };
    
        document.addEventListener('selectionchange', () => {
            const sel = window.getSelection();
            if (sel.rangeCount > 0 && !sel.isCollapsed && viewport.contains(sel.anchorNode)) {
                activeRange = sel.getRangeAt(0).cloneRange();
                const rect = activeRange.getBoundingClientRect();
                selTb.classList.add('active');
                selTb.style.left = `${rect.left + window.scrollX}px`;
                selTb.style.top = `${rect.top + window.scrollY - 50}px`;
            } else if (!selTb.matches(':hover')) selTb.classList.remove('active');
        });
    
        selTb.querySelectorAll('[data-cmd]').forEach(b => b.onmousedown = (e) => { e.preventDefault(); run(b.dataset.cmd); });
        document.getElementById('add-lnk').onmousedown = (e) => { e.preventDefault(); const u = prompt("URL:"); if(u) run('createLink', u); };
        document.getElementById('add-id').onmousedown = (e) => { e.preventDefault(); const id = prompt("Block ID:"); if (id && activeBlock) activeBlock.id = id; };
        document.getElementById('clr-txt').oninput = (e) => run('foreColor', e.target.value);
        document.getElementById('clr-bg').oninput = (e) => run('hiliteColor', e.target.value);
    
        document.addEventListener('mousedown', (e) => { if (!insMenu.contains(e.target) && e.target !== plusBtn) insMenu.style.display = 'none'; });
        if(viewport) viewport.contentEditable = "true";
    })();
    

    Summary: Building a Responsive “Adaptive Sticker” Engine

    In this session, we evolved a standard WYSIWYG editor into a high-end layout builder by solving the biggest problem with “draggable” elements: Responsive Breakdown.

    1. The “Two-Step Activation” Workflow (UX)

    Standard editors either force an image to be a “block” (static) or a “sticker” (floating). We created a hybrid approach:

    • Step 1 (The Ghost Block): When a user clicks the “Plus” button, the GIF is inserted as a standard HTML element in the natural flow of the document (inside the current <section> or <article>). This ensures the content doesn’t “jump” and stays exactly where the user intended.
    • Step 2 (The Activation): The element only becomes “Absolute” the moment the user interacts with it (Drag, Resize, or Rotate). This keeps the DOM clean and prevents layout shifts during the initial build.

    2. Section-Bound Containment (Clean HTML Structure)

    One major challenge was preventing absolute-positioned stickers from flying out of their parent containers.

    • The Logic: Upon activation, the engine identifies the nearest parent <section> or <article> and sets it to position: relative.
    • The Result: The sticker is now “trapped” within its specific section. If you move a section up or down in your CMS, the sticker travels with its section because its coordinates are local to that parent, not the whole page.

    3. Percentage-Based Geometry (100% Responsive)

    Pixel-based positioning (top: 200px) is the enemy of mobile design. We moved to a Relative Coordinate System:

    • Storage: We store the position as data-x and data-y in percentages (0% to 100%).
    • Visual Consistency: Whether the screen is 1920px wide or 375px wide, the sticker stays at the exact same relative spot (e.g., “Top Right of Section 2”).
    • The Math: By using translate(-50%, -50%), we moved the “Pivot Point” to the true center of the image. This makes rotation and scaling mathematically perfect regardless of the image aspect ratio.

    4. The Transformation Engine

    We built a unified applyTransform function that handles three complex states simultaneously:

    1. Scale: Uses multipliers instead of width/height px to preserve image quality.
    2. Rotation: Maintains degrees even after the mouse is released.
    3. Translation: Keeps the image centered on its coordinate.

    🧠 The Technical “Aha!” Moment:

    Instead of trying to make the editor behave like a Word document, we made it behave like a Design Canvas. By calculating coordinates as (Mouse Position – Parent Offset) / Parent Width, we created a builder that looks the same on a 4K monitor as it does on an iPhone.

  • background – engine.js

    Here is the summary of the Background Transparency code you need for any engine.js file, and a prompt checklist for future use.

    1. The 3 Background Code Blocks

    A. The CSS (The “Wall” removal)

    The Code:

    html, body, #webgl-container { 
        background: transparent !important; 
    }
    • Why: Standard websites have a white or black “wall” (the body background). This line knocks that wall down so you can see the cms-bg-layer sitting behind the website.

    B. The Renderer (The “Glass” canvas)

    The Code:

    const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    renderer.setClearColor(0x000000, 0); 
    • Why:
      • alpha: true: This turns the 3D canvas into a sheet of glass instead of a solid box.
      • setClearColor(..., 0): This makes the “clear space” in the 3D world 100% transparent.

    C. The Scene (The “Sky” removal)

    The Code:

    scene.background = null;
    • Why: Even if the canvas is glass, if you paint the “sky” (scene background) white, you won’t see through it. Setting it to null removes the paint.

    2. Checklist for Future Implementation

    If you get a new engine.js and want to add the background features, follow this prompt:

    StepCheck (A)Add/Change (B)
    1Look for style.innerHTMLAdd background: transparent !important; to html, body.
    2Look for new THREE.WebGLRendererAdd alpha: true inside the { }.
    3Under the renderer definitionAdd renderer.setClearColor(0x000000, 0);
    4Look for scene.background = ...Change the value to null.
    5Look for mousedown listenersAdd if (e.target.closest('#cms-bg-modal')) return; to prevent the 3D scene from moving while you use the background menu.

    3. Quick “Genie” Note

    To keep the Apple macOS Genie effect working in any new script, you must always include:

    1. The function: getScreenXY(mesh) (This finds the pixels on the screen).
    2. The GSAP animation: Inside the performRaycast (click) function, use gsap.fromTo to animate the scale from 0 to 1 using the coordinates from the function.
  • image Size, Compression and WebP

    what is normal good image size width and height for website background 

    There isn’t one fixed “perfect” size, but there are standard safe ranges used in real websites so images look good on all screens without becoming too heavy.

    🎯 Good background image size (modern websites)

    ✅ Recommended resolution

    • 1920 × 1080 px (Full HD) → most common “safe default”
    • 2560 × 1440 px (2K) → sharper for large screens
    • 3840 × 2160 px (4K) → only if you really need ultra high quality

    🧠 What you should actually use (practical advice)

    For your CMS / multi-website system:

    👉 Best balance:

    • Width: 1920 px
    • Height: 1080–1200 px

    This works for:

    • laptops
    • desktops
    • tablets
    • most mobile scaling (cropped automatically)

    📱 Important concept (very important)

    Background images are usually:

    • Responsive (cropped, not stretched)
    • Controlled using CSS like:
      • background-size: cover;
      • background-position: center;

    So you don’t need perfect height — only good width matters more.


    📦 File size target (important for your 10 GB system)

    Try to keep:

    • Good quality JPG/WebP: 100–300 KB
    • Max acceptable: 500 KB (if really needed)

    👉 Best format:

    • WebP (best choice)
    • JPG (fallback)

    🚀 Simple rule you can follow

    “1920px wide, compressed under 200 KB”

    That’s the sweet spot for:

    • fast loading
    • good quality
    • scalable system (like your 200 websites plan)

    If you want, I can give you a full CMS image standard (product images, thumbnails, banners, background rules)so your whole system stays inside 10 GB cleanly.

    2560 best of most of average website ??

    Short answer: No — 2560 is not the “best for most websites.” It’s more like high-end optional, not standard.


    🎯 What most websites actually use

    ✅ Standard (most common today)

    • 1920 × 1080 (Full HD)
      👉 Used by probably 80–90% of websites

    Why:

    • Good on laptops + desktops
    • Lightweight enough for speed
    • Easy to compress under 200–300 KB

    🟡 High-end / premium websites

    • 2560 × 1440
      👉 Used for:
    • photography sites
    • luxury brands
    • design portfolios
    • big hero backgrounds

    Downside:

    • Bigger file size
    • Slower loading if not optimized
    • Often unnecessary because browsers still show it scaled down

    🔴 4K (3840 × 2160)

    • Only for:
      • cinematic landing pages
      • very high-end visuals
        👉 Not practical for your CMS idea

    🧠 Real-world truth

    Even on a 4K screen, most websites:

    • still display a 1920px or smaller image
    • just scaled up by CSS

    So 2560 often gives no real visible benefit, only heavier files.


    🚀 Best recommendation for YOU (important)

    For your 10 GB / 200 websites system:

    👉 Use:

    • 1920 × 1080 or 1920 × 1200
    • format: WebP
    • size target: 100–250 KB

    💡 Simple rule

    “1920px is the sweet spot. 2560px is optional luxury.”


    If you want, I can design a full image policy for your CMS (background, product, thumbnails, logos) so everything stays fast + within storage limits.

    can I limit when some upload image big size and auto Lowe down WebP + 1920

    Yes — and this is actually the correct way to build a modern CMS 👍

    You can absolutely:

    Auto-detect big uploads → resize → convert to WebP → cap width to 1920px


    🧠 What you want is called:

    👉 Image processing pipeline (server-side)

    It does this automatically:

    1. User uploads image (even 5MB, 4000px wide)
    2. System checks size
    3. If too big:
      • resize to max 1920px width
      • keep aspect ratio
    4. compress quality
    5. convert to WebP
    6. save optimized version

    ⚙️ Recommended rules for your system

    📏 Resize rule

    • Max width: 1920px
    • Max height: auto (keep ratio)

    🧾 Format rule

    • Convert all images to: WebP

    🎚 Compression rule

    • Quality: 70–80% (sweet spot)

    📦 File size rule (optional limit)

    • Warn if > 300–500 KB
    • Hard block if > 1 MB (for your 10GB system)

    🔥 Example result

    User uploads:

    • 4000 × 3000 image, 3MB

    System outputs:

    • 1920 × 1440 WebP
    • ~150–250 KB

    🛠️ How to implement (simple stack options)

    If PHP (very common for your CMS idea)

    • Imagick (best quality)
    • or GD library

    If Node.js

    • sharp (FAST + industry standard)

    Example logic (Node.js idea):

    • resize → sharp().resize(1920)
    • convert → .webp({ quality: 75 })

    🚀 Why this is powerful for your 10GB system

    Without optimization:

    • 200 websites → storage explodes ❌

    With auto WebP + resize:

    • 200 websites becomes realistic ✅
    • consistent ~100–200 KB images
    • predictable storage control

    💡 Simple rule you should enforce

    “All uploads are automatically optimized. No original large images are stored.”


    If you want, I can design your full upload system logic (CMS-ready) including:

    • limits per user/site
    • auto compression rules
    • storage quotas (50MB/site system)
    • folder structure

    That will make your 200-site plan actually scalable 👍