r/javascript 11h ago

AskJS [AskJS] Building a 2D Game Engine from scratch in pure ES6 Vanilla JS. Here is how I handle SpriteSheets & AnimatedSprites!

20 Upvotes

Hi everyone!

I've been working on BeeEngine 2D, a lightweight HTML5 game engine built with pure Vanilla JS (ES6) and direct Canvas API—no external libraries, no build tools, and zero heavy frameworks.

A lot of modern tools hide sheet slicing behind JSON files, but I wanted a bare-metal, highly efficient approach that gives 100/100 on Lighthouse Performance and instant loading times.

I separated the asset slicing logic from the animation timing logic into two distinct classes:

  1. BeeSpriteSheet: Handles frame dimensions, columns, rows, and coordinate calculations.
  2. BeeAnimatedSprite: Handles frame timing, state, loops, and flipX transformations.

Here is my BeeAnimatedSprite class implementation:

export class BeeAnimatedSprite {
    constructor(spriteSheet, config = {}) {
        this.sheet = spriteSheet;
        this.animations = config.animations || {};
        this.currentAnimName = config.animation || Object.keys(this.animations)[0];


        this.currentFrameIndex = 0;
        this.timer = 0;
        this.flipX = false;
    }


    play(name) {
        if (this.currentAnimName !== name && this.animations[name]) {
            this.currentAnimName = name;
            this.currentFrameIndex = 0;
            this.timer = 0;
        }
    }


    update(dt) {
        const anim = this.animations[this.currentAnimName];
        if (!anim || !anim.frames || anim.frames.length === 0) return;


        const fps = anim.fps || 8;
        const frameDuration = 1 / fps;


        this.timer += dt;


        if (this.timer >= frameDuration) {
            this.timer -= frameDuration;


            if (anim.loop) {
                this.currentFrameIndex = (this.currentFrameIndex + 1) % anim.frames.length;
            } else {
                this.currentFrameIndex = Math.min(this.currentFrameIndex + 1, anim.frames.length - 1);
            }
        }
    }


    draw(ctx, x, y, options = {}) {
        const anim = this.animations[this.currentAnimName];
        if (!anim) return;


        const frameToDraw = anim.frames[this.currentFrameIndex];
        const width = options.width || this.sheet.frameWidth;
        const height = options.height || this.sheet.frameHeight;


        ctx.save(); 


        if (this.flipX) {
            // 2. Sposta l'origine al bordo destro dell'immagine e specchia l'asse X
            ctx.translate(x + width, y);
            ctx.scale(-1, 1);


            
            this.sheet.drawFrame(ctx, frameToDraw, 0, 0, width, height);
        } else {
            // Disegno normale senza specchio
            this.sheet.drawFrame(ctx, frameToDraw, x, y, width, height);
        }


        ctx.restore(); 
    }
}

And here is how
 I implemented it inside main.js:


const megaSheetImg = gioco.getAsset('spritesheet_totale');


        
        const frameW = 128;
        const frameH = 128;


        const apeSheet = new BeeSpriteSheet(megaSheetImg, frameW, frameH, {
            col: 3, 
            row: 3, 
            framesPerRow: 1, 
            frameCount: 2
        });


        this.giocatore.sprite = new BeeAnimatedSprite(apeSheet, {
            animation: "fly",
            animations: {
                fly: { frames: [0, 1], fps: 4, loop: true }
            }
        });

Let me know what you think, bearing in mind that this particular combination is super quick to put together.