π 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
}
}
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) ββ
β βββββββββββββββ βββββββββββββββββββββββββββ ββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
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 = 2for 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"
});
}
}
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 incomm[]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
}
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();
}
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");
}
}
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);
}
}
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.cameraandfake.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
});
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
}
});
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
}
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();
}
β οΈ 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);
}
}
}
π‘ 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>
β 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
| Resource | Link |
|---|---|
| GitHub Repository | github.com/terracodes004/limn-engine-doc |
| Direct Download (epic.js) | github.com/terracodes004/limn-engine-doc/blob/main/asset/epic.js |
| Complete API Reference | limn-engine-doc.vercel.app/reference.html |
| Discord Community | discord.gg/ZqnUtTQb8 |
Draw your game into existence β one particle at a time. π¨
Top comments (0)