DEV Community

Cover image for 🟠 Level 3 Tutorial β€” Limn Engine Advanced Guide
Kehinde Owolabi
Kehinde Owolabi

Posted on

🟠 Level 3 Tutorial β€” Limn Engine Advanced Guide

🟠 Level 3 Tutorial β€” Limn Engine Advanced Guide

Particles, Circle Collision, Screen Shake & Dynamic Tilemaps

Welcome to Level 3: Advanced of Limn Engine! You've mastered physics, tilemaps, and camera work. Now it's time to add particle effects, circle collision, screen shake, dynamic tilemap editing, and professional polish that makes your games feel alive.

A special thank you to **GyaanSetu Javascript* for featuring our work and helping us share Limn Engine with the JavaScript community. Your support means the world to us. πŸ™*


What You'll Learn

Topic What You'll Build
1. Scene Management Menu β†’ Gameplay β†’ Game Over
2. Particle System Basics Sparks, fire, magic effects
3. Built-in Particle Presets Explosion, smoke, sparkle, blood, magic
4. Continuous Emitters Engine exhaust, rain, fire
5. Circle Collision Perfect collision for round objects
6. Camera Shake Impact feedback
7. Camera Rotation Shake Dramatic screen twist
8. Dynamic TileMap Editing Destructible terrain
9. move.pointTo & move.circle Turret aiming, orbiting
10. fixed() β€” HUD Anchoring UI that stays on screen
11. destroy() Memory management
12. Top-down Shooter Tutorial Complete advanced game

Prerequisites

Before starting Level 3, you should be comfortable with:

  • βœ… Physics and gravity (physics, gravity, bounce)
  • βœ… Tilemaps (display.map, display.tile, display.tileFace)
  • βœ… Camera follow and zoom
  • βœ… Sprite animation
  • βœ… Tctxt for UI

If you're not confident with these, complete Level 2: Intermediate first.


1. Scene Management

Scenes let 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.

How It Works

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.

Implementation

// Build all scenes at startup
const playBtn = new Component(160, 50, "#7fffb2", 320, 275, "rect");
display.add(playBtn, 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 && playBtn.clicked()) {
        display.scene = 1;
    }
    if (display.scene === 1) {
        // all gameplay logic here
        gameUI.fixed();
    }
    if (display.scene === 2) {
        // game over logic
        if (display.keys[32]) display.scene = 0; // restart
    }
}
Enter fullscreen mode Exit fullscreen mode

Visual Explanation

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                         ALL SCENES EXIST                        β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚   Scene 0   β”‚  β”‚   Scene 1   β”‚  β”‚       Scene 2           β”‚ β”‚
β”‚  β”‚   Menu      β”‚  β”‚  Gameplay   β”‚  β”‚     Game Over           β”‚ β”‚
β”‚  β”‚  (visible)  β”‚  β”‚  (hidden)   β”‚  β”‚      (hidden)           β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                    β”‚
                    β–Ό
          display.scene = 1
                    β”‚
                    β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”‚
β”‚  β”‚   Scene 0   β”‚  β”‚       Scene 1           β”‚  β”‚   Scene 2   β”‚β”‚
β”‚  β”‚   Menu      β”‚  β”‚      Gameplay           β”‚  β”‚ Game Over   β”‚β”‚
β”‚  β”‚  (hidden)   β”‚  β”‚     (visible)           β”‚  β”‚  (hidden)   β”‚β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

Best Practices

Do Don't
Build all scenes at startup Create/destroy objects on state change
Use display.add(obj, scene) Forget to specify scene number
Use display.scene = n to switch Use complex state machines
Use hide()/show() for temporary elements Destroy UI elements that will be needed again

πŸ’‘ Tip: 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.

How It Works

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.

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"
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Particle Options

Option Default Purpose
life 60 Frames until the particle is removed
alphaFade 0.02 Alpha subtracted per frame β€” 0.02 = 50-frame fade
friction 0.98 Speed multiplied per frame β€” values below 1 create drag
gravity 0 Added to speedY per frame
type "rect" "rect", "circle", or "image"
randomColor false If true, picks randomly from a colors[] array

⚠️ Warning: 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.


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.

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
}
Enter fullscreen mode Exit fullscreen mode

Preset Reference

Preset Type Extra param Effect
explosion(ps,x,y,n) burst intensity=30 Orange-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Γ—n intensity=1 Thin blue vertical rects falling fast
blood(ps,x,y,n) burst amount=15 Red 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.

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();
}
Enter fullscreen mode Exit fullscreen mode

Emitter Methods

Method Purpose
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.

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

How It Works

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

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. If the other Component does not have circle collision enabled, the method falls back to the standard AABB crashWith() check automatically.

πŸ’‘ Tip: 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.


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.

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Intensity Guide

Event Shake Intensity
Coin collection 2-3 px
Player hit 5-8 px
Explosion 10-15 px
Boss death 15-20 px

⚠️ Warning: In perform() mode always shake both display.camera and fake.camera β€” they are independent cameras on separate canvases.


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.

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
});
Enter fullscreen mode Exit fullscreen mode

Rotation Guide

Radians Degrees Feel
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.

// 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
    }
});
Enter fullscreen mode Exit fullscreen mode

How It Works

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.

πŸ’‘ Tip: tileFace.rTile(tx, ty) returns the actual Tile Component object at a grid position, giving you direct access to its world coordinates, colour, and other Component properties.


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().

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
}
Enter fullscreen mode Exit fullscreen mode

How It Works

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.

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.


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.

// 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();
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Warning: 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.


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.

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Tip: 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.

<!DOCTYPE html>
<html>
<head>
    <title>Top-down Shooter</title>
    <script src="asset/epic.js"></script>
</head>
<body>
<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>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

βœ… What You Get:

  • WASD to move
  • Player faces mouse
  • Enemies chase the player
  • On hit: explosion, blood, camera shake, +10 score
  • New enemy spawns automatically

Quick Reference

Feature Code
Scenes display.add(obj,n); display.scene=n;
Particles const ps=new ParticleSystem(display); ps.emit(x,y,opts); ps.update();
Preset move.particles.explosion(ps,x,y,40)
Emitter const e=ps.createEmitter(x,y,opts); e.start(); e.setPosition(x,y);
Circle collision obj.enableCircleCollision(); obj.crashWithCircle(other)
Shake display.camera.shake(10,8); fake.camera.shake(-10,-8);
Rotation shake display.camera.shakeRotation(0.08)
Edit tile tileFace.add(id,tx,ty,layer); tileFace.remove(tx,ty,layer);
pointTo move.pointTo(obj,targetX,targetY)
Fixed HUD obj.fixed(); // every frame
destroy obj.destroy() // removes from comm[] and commp[]

What's Next?

You've completed Level 3: Advanced! πŸŽ‰

Level Description
🟒 Level 1 β€” Beginner βœ… Basics complete
🟑 Level 2 β€” Intermediate βœ… Physics, tilemaps, camera
🟠 Level 3 β€” Advanced βœ… You are here
πŸ”΄ Level 4 β€” 10x Dual‑renderer, performance optimization, engine extension

Special Thanks

"A heartfelt thank you to **GyaanSetu Javascript* for featuring Limn Engine and helping us share this tutorial with the JavaScript community. Your support means the world to us."*


Useful Links


Draw your game into existence β€” one particle at a time. 🎨

Top comments (0)