🟠 Level 3 β€” Advanced L3

1. Scene Management

Limn Engine's scene system lets you build every screen of your game β€” menu, gameplay, pause, game over β€” as separate Component collections that all exist in memory simultaneously. Switching between them happens instantly by changing a single integer, without creating or destroying any objects.
πŸ“– Full explanation & examples

When you register a Component with display.add(obj, 2), the engine stores the number 2 alongside that Component in the comm[] array. Every frame, the render loop checks if (component.scene == display.scene) before drawing anything. Components in scenes 1 and 2 are completely invisible and receive no move() or update() calls when display.scene is 0.

Changing display.scene = 1 takes effect on the very next frame β€” the menu Components stop rendering and the gameplay Components instantly become active, with zero allocation or garbage collection overhead. This is the recommended pattern for any game state transition β€” never call destroy() on UI elements that will be needed again, and never re-create objects on every state change; just assign them to their scene at startup and flip display.scene as needed.

// Build all scenes at startup
const menuBtn = new Component(160, 50, "#7fffb2", 320, 275, "rect");
display.add(menuBtn, 0); // scene 0 = menu

const player = new Component(40, 40, "cyan", 100, 100, "rect");
const gameUI = new Tctxt("18px","Arial","white",14,28,"left",false,"alphabetic","rgba(0,0,0,0.5)",8,4);
gameUI.setText("Score: 0");
display.add(player, 1);  // scene 1 = gameplay
display.add(gameUI, 1);

const gameOverMsg = new Tctxt("40px","Arial","red",220,260,"center");
gameOverMsg.setText("GAME OVER");
display.add(gameOverMsg, 2); // scene 2 = game over

display.scene = 0; // start on menu

function update() {
    if (display.scene === 0 && display.x && menuBtn.clicked()) {
        display.scene = 1;
    }
    if (display.scene === 1) {
        // all gameplay logic here
        gameUI.fixed();
    }
}
πŸ’‘ Scenes are just integers β€” you can have as many as you need. Use constants like const SCENE_MENU = 0, SCENE_GAME = 1, SCENE_GAMEOVER = 2 for readability.

2. Particle System Basics

ParticleSystem manages a pool of short-lived Particle Components β€” spawning them with emit(), updating their physics and fade every frame via ps.update(), and automatically removing them from comm[] when their life or alpha reaches zero.
πŸ“– Full explanation & examples

Each Particle is a real Component that gets added to comm[] via display.add() when spawned, so the engine draws it automatically alongside all other Components β€” no manual draw calls needed. The Particle's update() method applies gravity, friction, rotation, alpha fade, and scale fade on every frame, decrementing life each time and returning false when exhausted.

ps.update() must be called inside your update() function every frame β€” it loops through the particle pool in reverse, finds any particle whose life or alpha has reached zero, removes it from both ps.particles[] and comm[], and also calls every active emitter's own update to spawn new particles at the correct rate.

⚠️ If you ever stop calling ps.update(), dead particles accumulate in comm[] indefinitely and the engine will slow down as it tries to draw thousands of invisible zero-alpha rectangles every frame.
const ps = new ParticleSystem(display);

function update() {
    ps.update(); // REQUIRED every frame β€” advances physics, removes dead particles

    if (display.x) {
        ps.emit(display.x, display.y, {
            width: 6, height: 6,
            color: "#ffaa00",
            life: 45,
            speedX: (Math.random() - 0.5) * 4,
            speedY: -2,
            gravity: 0.1,
            friction: 0.97,
            alphaFade: 0.022,
            type: "circle"
        });
    }
}
OptionDefaultPurpose
life60Frames until the particle is removed
alphaFade0.02Alpha subtracted per frame β€” 0.02 = 50-frame fade
friction0.98Speed multiplied per frame β€” values below 1 create drag
gravity0Added to speedY per frame
type"rect""rect", "circle", or "image"

3. Built-in Particle Presets

Limn Engine ships six ready-made particle effects accessible via move.particles.* β€” each one is a single function call that bursts or emits a carefully configured set of particles to produce a specific visual: explosion, smoke, sparkle, rain, blood, or magic.
πŸ“– Full explanation & examples

The presets are attached to the move.particles object and each one calls particleSystem.burst() or particleSystem.emit() with hard-coded options tuned for that visual β€” colours, speed spread, gravity, friction, alpha fade, and scale fade are all configured internally.

explosion() bursts a set of orange-red circles with random radial speed and mild downward gravity, fading out over about 30–50 frames. smoke() emits a single upward-drifting grey circle per call β€” meant to be called every frame from a fixed position to produce a continuous smoke column. rain() emits intensity thin vertical blue-tinted rectangles per call across a random X spread, falling at 5–8px/frame with no fade and no gravity.

const ps = new ParticleSystem(display);

function update() {
    ps.update();

    if (enemyDied) {
        move.particles.explosion(ps, enemy.x, enemy.y, 40); // 40 particles
        move.particles.blood(ps, enemy.x, enemy.y, 20);     // 20 particles
    }
    if (playerPowerUp) {
        move.particles.magic(ps, player.x, player.y);
        move.particles.sparkle(ps, player.x, player.y);
    }
    // Continuous weather β€” call every frame
    move.particles.rain(ps, 0, 0, 3);   // 3 raindrops per frame
    move.particles.smoke(ps, 200, 300); // one smoke puff per frame
}
PresetTypeExtra paramEffect
explosion(ps,x,y,n)burstintensity=30Orange-red radial circles with gravity
smoke(ps,x,y)emitβ€”Single upward grey circle β€” call per frame
sparkle(ps,x,y)burstβ€”5 yellow circles with random radial spread
rain(ps,x,y,n)emitΓ—nintensity=1Thin blue vertical rects falling fast
blood(ps,x,y,n)burstamount=15Red circles with downward gravity
magic(ps,x,y)burstβ€”20 multicolour rotating rects drifting up

4. Continuous Emitters

ps.createEmitter() returns an emitter object that fires particles automatically at a set rate every frame without you calling emit() manually β€” you can start it, stop it, and move it at any time, and ps.update() drives it automatically.
πŸ“– Full explanation & examples

Internally, the emitter stores a frameCounter that accumulates rate / 60 on each call to its own update() method (which ps.update() invokes) β€” when frameCounter reaches 1 it emits a particle and decrements by 1, so a rate of 60 emits exactly one particle per frame and a rate of 30 emits one every two frames.

When randomSpread: true is set in the options, each emitted particle gets a random angle and a speed derived from the speed option, producing a circular burst pattern rather than a directional stream. When randomOffset is set to a pixel value, each particle spawns at a random position within that radius around the emitter's centre, creating area effects like campfires or rain zones.

Emitters are the correct tool for anything that needs to spray particles continuously β€” engine exhausts, fire, waterfalls, and weather β€” while burst() is better for one-shot events like explosions and impacts.

const ps = new ParticleSystem(display);

const fire = ps.createEmitter(400, 300, {
    rate: 30,              // 30 particles per second (0.5 per frame at 60fps)
    randomSpread: true,    // random direction each particle
    speed: 2,              // speed magnitude for random spread
    color: "#ff4400",
    life: 35,
    alphaFade: 0.03,
    gravity: -0.06,        // negative = particles float upward
    type: "circle",
    width: 5, height: 5
});

function update() {
    ps.update(); // drives emitter.update() internally

    // Move the emitter to follow the player
    fire.setPosition(player.x + 20, player.y + 30);

    // Toggle on a key
    if (display.keys[70]) fire.stop();  // F key
    else fire.start();
}
MethodPurpose
emitter.start()Begin emitting β€” active by default on creation
emitter.stop()Pause emission β€” already-live particles continue
emitter.setPosition(x,y)Move the emitter origin

5. Circle Collision

enableCircleCollision() switches a Component from rectangle-based to radius-based collision detection β€” instead of comparing edges, crashWithCircle() measures the distance between the two centres and returns true if that distance is less than the sum of their radii, giving perfectly accurate collision for round objects.
πŸ“– Full explanation & examples

Calling enableCircleCollision() sets this.isCircle = true and stores a radius β€” if you pass a number it uses that value directly, and if you leave it empty it defaults to half the largest dimension of the Component (Math.max(width, height) / 2), which fits a circle tightly around a square Component.

When you call a.crashWithCircle(b), the engine first checks if b.isCircle is true β€” if it is, it calculates the Euclidean distance between the two centre points using Math.sqrt(dx*dx + dy*dy) and compares it to this.radius + other.radius, returning true if the circles overlap. If the other Component does not have circle collision enabled, the method falls back to the standard AABB crashWith() check automatically, so you can safely call crashWithCircle() even in mixed scenarios.

πŸ’‘ Circle collision is noticeably more accurate than AABB for any Component that looks round β€” bullets, balls, coins, and enemies with circular sprites will register hits that look correct to the player rather than hitting invisible corners of the bounding box.
const ball   = new Component(40, 40, "blue", 200, 200, "rect");
const target = new Component(40, 40, "red",  350, 200, "rect");
display.add(ball);
display.add(target);

// Enable circle collision β€” auto radius = max(width,height)/2 = 20
ball.enableCircleCollision();
target.enableCircleCollision(22); // explicit radius

function update(dt) {
    if (display.keys[39]) ball.speedX =  3;
    if (display.keys[37]) ball.speedX = -3;

    if (ball.crashWithCircle(target)) {
        target.setColor("yellow");
    } else {
        target.setColor("red");
    }
}

6. Camera Shake

display.camera.shake(x, y) applies an immediate positional offset to the camera and automatically reverses it after approximately 42 milliseconds β€” one call produces the classic screen-jolt effect used for explosions, hits, and impactful events without any manual timer management.
πŸ“– Full explanation & examples

Internally, shake(x, y) adds the X and Y values directly to camera.x and camera.y and immediately sets a setTimeout for 1000/24 milliseconds (~42ms) that subtracts those same values back, returning the camera to its original position. The effect is felt as a sudden lurch in whichever direction you specify β€” passing positive values lurches right and down, passing negative values lurches left and up β€” so a typical impact shake uses something like shake(8, 6) for a strong hit or shake(3, 2) for a lighter rumble.

When using display.perform() with a fake canvas, you must shake both cameras β€” display.camera.shake() and fake.camera.shake() β€” because the two canvases have independent transforms, and shaking only one causes the foreground to lurch while the background stays still, which looks broken.

⚠️ In perform() mode always shake both display.camera and fake.camera β€” they are independent cameras on separate canvases.
function update() {
    if (bigExplosion) {
        display.camera.shake(12, 9);   // main canvas shake
        fake.camera.shake(-12, -9);    // fake canvas shake β€” opposite for realism

        // Add rotation shake for extra drama
        display.camera.shakeRotation(0.08);
    }

    // Small rumble on bullet fire
    if (playerShot) {
        display.camera.shake(3, 2);
        fake.camera.shake(-3, -2);
    }
}

7. Camera Rotation Shake

display.camera.shakeRotation(angle) rotates the entire canvas around its centre point by the given radian value and resets automatically after 42 milliseconds β€” producing a dramatic screen-twist effect that conveys disorientation, powerful impacts, and boss hits far more forcefully than positional shake alone.
πŸ“– Full explanation & examples

The implementation stores the angle in camera.rotationShake and the main render loop checks this value at the start of every frame β€” if it is non-zero it translates to the canvas centre, rotates by that amount, then translates back before rendering everything else, so the rotation affects the entire scene uniformly.

A setTimeout for 1000/24 ms resets rotationShake to zero automatically, so the twist lasts exactly one paint cycle and returns to normal without any cleanup code from you. Angles are in radians β€” 0.1 radians is approximately 5.7 degrees, which is visible and dramatic without being nauseating; values like 0.05–0.1 work well for game hits and anything above 0.2 should be reserved for very large events like world-shaking boss attacks.

window.addEventListener("dblclick", () => {
    // Rotation shake alone β€” like a camera twist
    display.camera.shakeRotation(0.08); // ~4.6 degrees

    // Combined position + rotation for maximum impact
    display.camera.shake(14, 10);
    display.camera.shakeRotation(0.12);
    fake.camera.shake(-14, -10); // shake fake canvas too in perform() mode
});
RadiansDegreesFeel
0.03~1.7Β°Subtle rumble
0.08~4.6Β°Strong hit
0.12~6.9Β°Explosion
0.20+11Β°+Boss / world event

8. Dynamic TileMap Editing

The TileMap's add() and remove() methods let you place and delete individual tiles at runtime on any layer β€” enabling destructible terrain, growing maps, and puzzle mechanics β€” and both automatically trigger a fake canvas refresh so the visual update appears on the very next frame.
πŸ“– Full explanation & examples

tileFace.add(tileId, tx, ty, layer) writes the tile ID number into this.map[layer][ty][tx] and then calls this.show() followed by fake.refresh() β€” show() rebuilds the tileList array with the new tile included, and fake.refresh() sets display.once = true so the ani() loop redraws the fake canvas on the next frame, making the new tile visible immediately.

tileFace.remove(tx, ty, layer) does the inverse β€” writes 0 into that grid position and triggers the same refresh chain. The layer parameter defaults to 0 in both methods, so single-layer games do not need to specify it.

// Runtime tile editing
display.tileFace.add(1, 5, 3);        // place grass at grid(5,3) on layer 0
display.tileFace.add(3, 2, 1, 1);     // place tree at grid(2,1) on layer 1
display.tileFace.remove(5, 3);        // remove tile at grid(5,3) on layer 0
display.tileFace.remove(2, 0, 2);     // remove tile at grid(2,0) on layer 2

// fake.refresh() is called automatically inside add() and remove()

// Get world coordinates of a specific tile
const t = display.tileFace.rTile(5, 3);
if (t) console.log("Tile world position:", t.x, t.y);

// Get all tiles of a specific type
const allGrass = display.tileFace.tiles(1); // all tile-type-1 objects
allGrass.forEach(tile => {
    if (player.crashWith(tile)) {
        // player is standing on grass
    }
});

9. move.pointTo & move.circle

move.pointTo() rotates a Component to face any world coordinate by calculating the angle with Math.atan2 and assigning it to the Component's angle property β€” and move.circle() increments that angle each frame so the Component orbits using moveAngle().
πŸ“– Full explanation & examples

move.pointTo(id, targetX, targetY) computes Math.atan2(targetY - id.y, targetX - id.x) to get the angle from the Component's position to the target and assigns it to id.angle β€” because Limn renders all Components with their angle applied via a context.rotate(), this makes the Component's right side face the target position on every frame it is called.

Calling it every frame with display.x and display.y as the target makes a Component track the mouse cursor in real time, which is the standard technique for top-down shooter turrets and player aim indicators.

move.circle(id, speed) sets angularMovement = true on the Component and then adds speed * Math.PI / 180 to its angle each frame β€” the moveAngle() method uses Math.cos(angle) and Math.sin(angle) to convert that angle into X and Y velocity, so the Component moves in the direction it is currently facing, producing a circular orbit when called continuously.

const turret  = new Component(40, 20, "gray", 400, 300, "rect");
const orbiter = new Component(16, 16, "cyan", 500, 300, "rect");
display.add(turret);
display.add(orbiter);

orbiter.angularMovement = true; // enables moveAngle() instead of move()
orbiter.speedX = 2;
orbiter.speedY = 2;

function update() {
    // Turret always faces the mouse
    if (display.x) {
        move.pointTo(turret, display.x, display.y);
    }

    // Orbiter spins in a circle around its own angle
    move.circle(orbiter, 2);  // 2 degrees per frame
    // orbiter.moveAngle() is called automatically because angularMovement = true
}

10. fixed() β€” HUD Anchoring

component.fixed() keeps a Component locked to a fixed screen position even when the camera is scrolling β€” it works by reading the Component's original anchor coordinates stored in aX and aY at construction time and adding the current camera offset to them every frame.
πŸ“– Full explanation & examples

When you create new Component(w, h, color, 20, 40) the engine stores those starting coordinates in this.aX = 20 and this.aY = 40 at construction β€” those are the desired screen-space pixel positions for the HUD element.

fixed(ctx) then runs this.x = this.aX + ctx.camera.x and this.y = this.aY + ctx.camera.y every frame, which adds the camera's world offset to the anchor position so that when the canvas context is translated by -camera.x the Component ends up drawn at exactly aX, aY screen pixels.

⚠️ fixed() must be called every frame. If you call it only once at startup the element will be fixed at frame 0's camera position and drift away as the camera moves.
// Create at desired screen position β€” aX=16, aY=16 stored automatically
const healthBar = new Component(160, 14, "red", 16, 16, "rect");
const hpLabel   = new Tctxt("16px","Arial","white",16,36,"left",false,"alphabetic","rgba(0,0,0,0.5)",8,3);
hpLabel.setText("HP: 100");
display.add(healthBar);
display.add(hpLabel);

function update() {
    display.camera.follow(player, true);

    // Call every frame β€” recalculates position from aX/aY + camera offset
    healthBar.fixed();
    hpLabel.fixed();
}

11. destroy()

component.destroy() permanently removes a Component from the engine's rendering pipeline by splicing it out of both the comm[] and commp[] arrays and setting its update method to null β€” after this call the Component will never be drawn or moved again.
πŸ“– Full explanation & examples

Limn's render loop iterates comm[] on every frame to draw and move every registered Component β€” a Component that is off-screen, dead, or collected still costs a loop iteration and a draw call unless it is removed.

destroy() uses comm.findIndex(c => c.x === this) to locate the entry and comm.splice(index, 1) to remove it in one operation, then does the same for commp[] which holds fake-canvas Components, ensuring no orphaned references remain in either array.

Setting this.update = null after removal means that even if something holds a stale reference to the destroyed Component and accidentally calls its update, the engine's try-catch around draw calls will silently absorb the error rather than crashing.

πŸ’‘ Use destroy() for objects that will never be needed again β€” bullets that exit the screen, enemies that are killed, collected items. Use hide() / show() instead for objects that need to disappear temporarily and come back β€” respawning enemies, UI panels, or anything you want to reuse.
function update() {
    // Remove bullets that leave the screen β€” prevents comm[] from growing
    for (let i = bullets.length - 1; i >= 0; i--) {
        if (bullets[i].y < -50) {
            bullets[i].destroy(); // removed from comm[] immediately
            bullets.splice(i, 1); // also remove from your own tracking array
        }
    }

    // Remove enemies on collision
    for (let i = enemies.length - 1; i >= 0; i--) {
        if (player.crashWith(enemies[i])) {
            move.particles.explosion(ps, enemies[i].x, enemies[i].y, 20);
            enemies[i].destroy();
            enemies.splice(i, 1);
        }
    }
}
⚠️ Always iterate arrays in reverse (for (let i = arr.length-1; i >= 0; i--)) when removing elements mid-loop β€” otherwise splicing shifts the indices and you skip elements.

12. Top-down Shooter Tutorial

This tutorial builds a complete top-down shooter combining scenes, particles, camera shake, circle collision, pointTo, destroy(), and HUD anchoring into a game where enemies chase the player and explode on contact.
πŸ“– Full game code
<script src="epic.js"></script>
<script>
const display = new Display();
display.start(800, 600);
display.backgroundColor("#0d0d1a");
const ps = new ParticleSystem(display);

const player = new Component(36, 36, "cyan", 400, 300, "rect");
player.enableCircleCollision(18);
display.add(player, 1);

let enemies = [];
function spawnEnemy() {
    const e = new Component(32, 32, "red",
        Math.random() * 760, Math.random() * 560, "rect");
    e.enableCircleCollision(16);
    display.add(e, 1);
    enemies.push(e);
}
for (let i = 0; i < 5; i++) spawnEnemy();

const scoreUI = new Tctxt("18px","Arial","white",14,28,
    "left",false,"alphabetic","rgba(0,0,0,0.5)",8,4);
scoreUI.setText("Score: 0");
display.add(scoreUI, 1);

let score = 0;
display.scene = 1;

function update() {
    ps.update();

    // WASD movement
    player.speedX = 0; player.speedY = 0;
    if (display.keys[87]) player.speedY = -4;
    if (display.keys[83]) player.speedY =  4;
    if (display.keys[65]) player.speedX = -4;
    if (display.keys[68]) player.speedX =  4;
    move.pointTo(player, display.x || 400, display.y || 300);
    move.bound(player);

    // Enemies chase player
    for (let i = enemies.length - 1; i >= 0; i--) {
        const e = enemies[i];
        const dx = player.x - e.x, dy = player.y - e.y;
        const d  = Math.sqrt(dx*dx+dy*dy);
        e.speedX = (dx/d) * 1.8;
        e.speedY = (dy/d) * 1.8;

        if (player.crashWithCircle(e)) {
            move.particles.explosion(ps, e.x, e.y, 25);
            move.particles.blood(ps, e.x, e.y, 12);
            display.camera.shake(10, 8);
            display.camera.shakeRotation(0.07);
            e.destroy();
            enemies.splice(i, 1);
            score += 10;
            spawnEnemy(); // respawn
        }
    }

    display.camera.follow(player, true);
    scoreUI.setText("Score: " + score);
    scoreUI.fixed();
}
</script>
βœ… WASD to move, player faces mouse, enemies chase you β€” on hit: explosion, blood, camera shake, +10 score, new enemy spawns.

13. Quick Reference

πŸ“– See all shortcuts
FeatureCode
Scenesdisplay.add(obj,n); display.scene=n;
Particlesconst ps=new ParticleSystem(display); ps.emit(x,y,opts); ps.update();
Presetmove.particles.explosion(ps,x,y,40)
Emitterconst e=ps.createEmitter(x,y,opts); e.start(); e.setPosition(x,y);
Circle collisionobj.enableCircleCollision(); obj.crashWithCircle(other)
Shakedisplay.camera.shake(10,8); fake.camera.shake(-10,-8);
Rotation shakedisplay.camera.shakeRotation(0.08)
Edit tiletileFace.add(id,tx,ty,layer); tileFace.remove(tx,ty,layer);
pointTomove.pointTo(obj,targetX,targetY)
Fixed HUDobj.fixed(); // every frame
destroyobj.destroy() // removes from comm[] and commp[]

Ready to go deep? β†’ Level 4: 10x