__Limn Engine — Level 4: 10x Developer Guide (Topics 37–48)
A complete guide to mastering the Limn Engine's most advanced features
📖 Introduction
You've made it. You're ready to become a 10x Limn Developer.
This guide covers the engine's most powerful internals: the dual-canvas rendering pipeline, performance optimizations, angular movement, and professional workflows. By the end, you'll understand the engine at a level that most developers never reach.
Whether you're building an open-world RPG, a multiplayer game, or optimizing for mobile, this guide will give you the tools you need.
📚 Table of Contents
- display.perform() — The 60fps Loop
- The fake Canvas — Limn Engine's Secret Weapon
- angularMovement & moveAngle — Angular Motion
- deltaTime Movement — Frame-Rate Independence
- clearMargin Optimisation
- Viewport Culling with TCJSgameGameArea
- Custom Particle Systems from Scratch
- Extending the Engine with Prototypes
- SoundManager Deep-Dive
- AnimatedSprite Advanced Techniques
- Performance Checklist for Shipping
- Open-World Demo — Complete Walkthrough
- display.perform() — The 60fps Loop
⏱️ 20 min | Level: 10x
display.perform() is the single most impactful call you can make in Limn Engine. It replaces the default setInterval loop with requestAnimationFrame, giving you accurate deltaTime and enabling the dual-canvas pipeline.
What Actually Happens
When you call display.perform(), the engine:
- Patches Display.prototype.start — It overwrites the start method so that when you call display.start() immediately after, it launches the ani() function driven by requestAnimationFrame instead of the old setInterval loop.
- Activates the dual-canvas pipeline — It creates a hidden fake Display instance and sets it up as an offscreen buffer.
- Enables accurate deltaTime — The ani() function receives a high-precision DOMHighResTimeStamp from the browser and uses it to calculate display.fps and display.deltaTime from live frame timestamps.
Understanding the Code
const display = new Display(); // 1. Create the main game object
// 2. ⚡ CRITICAL: perform() MUST come before start()
display.perform();
// 3. start() now launches ani() → requestAnimationFrame instead of setInterval
display.start(800, 600);
// 4. Static content → fake canvas (only drawn once, then cached)
fake.add(new Component(800, 600, "#0d0d1a", 0, 0, "rect"));
// 5. Dynamic content → main display (redrawn every frame)
const player = new Component(40, 40, "cyan", 400, 300, "rect");
display.add(player);
// 6. update() now receives accurate deltaTime from live frame timestamps
function update(dt) {
// dt is the time elapsed since the last frame in seconds
// At 60fps, dt ≈ 0.0167; at 30fps, dt ≈ 0.033
if (display.keys[68]) {
// Move right at 250 pixels per second (frame-rate independent)
player.speedX = 250 * dt;
}
if (display.keys[65]) {
// Move left at 250 pixels per second
player.speedX = -250 * dt;
}
else if (!display.keys[68]) {
player.speedX = 0;
}
}
Why requestAnimationFrame Matters
Aspect Without perform() With perform()
Loop setInterval at 20ms — ~50fps max requestAnimationFrame — 60–144fps
Sync Out of sync with screen paint Synced to monitor refresh rate
deltaTime From a separate interval — stale From live frame timestamp — accurate
Rendering Single canvas — all objects redrawn Dual canvas — static cached, dynamic on top
Fake canvas Not available Auto-initialised by perform()
💡 Pro Tip: Why requestAnimationFrame Matters
The browser's refresh rate syncs with the monitor's vertical blanking interval. requestAnimationFrame schedules your game loop to run exactly when the browser is about to paint the next frame, eliminating screen tearing and jitter. This is why perform() makes your game feel smoother even at the same framerate.
⚠️ Critical: TileMap Requires perform()
This is one of the most important things to understand about Limn Engine:
The TileMap system will NOT work unless display.perform() is called first!
The TileMap relies on the fake canvas to cache tiles. Without perform(), the fake canvas doesn't exist, and your tiles will never be drawn — even if you set up your level correctly.
// ❌ WRONG: TileMap won't work!
const display = new Display();
display.start(800, 600); // No perform() — fake canvas doesn't exist
display.tileMap(); // TileMap has nowhere to render
display.tileFace.show(); // Nothing appears!
// ✅ CORRECT: TileMap works!
const display = new Display();
display.perform(); // Creates the fake canvas
display.start(800, 600); // Now the fake canvas exists
display.tileMap(); // TileMap renders to fake canvas
display.tileFace.show(); // Tiles appear!
Always remember: display.perform() is the gateway to the TileMap system. Without it, your levels are invisible.
- The Fake Canvas — Limn Engine's Secret Weapon
⏱️ 25 min | Level: 10x
The fake canvas is one of Limn Engine's most powerful and unique features. It's a hidden, offscreen canvas that acts as a cache for static content, created automatically when you call display.perform().
What Is the Fake Canvas?
Think of the fake canvas like a "screenshot" of your static world:
Without Fake Canvas With Fake Canvas
Every frame: draw 1,000 tiles individually Every frame: draw 1 cached image
1,000 draw calls per frame 1 draw call per frame
Slow Fast
That's a 99.9% reduction in draw calls!
How It Works — Step by Step
Step 1: Creation (Internal)
// This happens inside display.perform()
let fake = new Display(); // Create a second Display instance
fake.canvas.style.display = "none"; // Hide it — it's just an offscreen buffer
Step 2: Rendering to the Fake Canvas
When you add static content, it's drawn once into the offscreen buffer:
// 1. Set a background — this will be drawn behind everything
fake.bgComm = new Component(2000, 2000, "#0a0a10", 0, 0);
// .bgComm is a special property — it renders at the very bottom
// 2. Add a tilemap — this renders on top of the background
display.tileMap();
display.tileFace.show(); // Draws all tiles to the fake canvas
// 3. Add static decorations — these render on top of tiles
const tree = new Component(64, 64, null, 400, 300, "image");
tree.setImage("tree.png");
fake.add(tree); // fake.add() sends objects to the offscreen buffer
Step 3: Displaying the Fake Canvas
Every frame, the main canvas draws the fake canvas as a single image:
// Inside ani() — the main game loop
// This ONE call draws EVERYTHING that was cached on the fake canvas
display.context.drawImage(fake.canvas, 0, 0);
Step 4: Dynamic Content on Top
The main canvas then draws dynamic content on top of the cached image:
// Dynamic objects — these move every frame, so they stay on the main canvas
display.add(player); // Moves every frame — redrawn every frame
display.add(enemy); // Moves every frame — redrawn every frame
display.add(bullet); // Moves every frame — redrawn every frame
The display.once Flag — How Caching Works
The display.once flag controls when the fake canvas is redrawn:
// Inside ani() — the main game loop (simplified)
if (display.once) {
// This block runs ONLY on the first 2 frames and after fake.refresh()
// 1. Clear everything from the fake canvas
fake.context.clearRect(0, 0, fake.canvas.width, fake.canvas.height);
// 2. Redraw the background
fake.context.drawImage(bgImage, 0, 0);
// 3. Redraw all tiles
// ... iterate through tileComm and draw each one
// 4. Redraw all fake.add() objects
// ... iterate through commp and draw each one
// 5. Stop redrawing — the cache is now built
display.once = false;
}
// After the cache is built, this ONE call draws everything
display.context.drawImage(fake.canvas, 0, 0); // FAST!
When Does display.once Get Set?
Event What Happens
First 2 frames display.once = true — builds the cache
After frame 2 display.once = false — cache is built
fake.refresh() called display.once = true — rebuild cache
TileMap change fake.refresh() called automatically
💡 Pro Tip: Forcing a Refresh
// After changing static content
fake.bgComm.setImage("new_sky.png");
fake.refresh(); // Forces the fake canvas to redraw
// Or manually
display.once = true; // Same effect — rebuild cache on next frame
The Correct Pattern: Resize, Don't Sync
This is the key insight for 10x developers:
Instead of syncing the fake canvas camera with the main camera every frame (which forces the fake canvas to move and defeats caching), resize the fake canvas to match your world size:
// ✅ CORRECT: Resize fake canvas to world size
// The fake canvas contains the ENTIRE world
fake.canvas.width = display.camera.worldWidth; // 2000
fake.canvas.height = display.camera.worldHeight; // 2000
// Render static content once — it covers the whole world
display.tileMap();
display.tileFace.show(0);
// Now the fake canvas NEVER moves — it's a complete image of the world
function update(dt) {
// The main camera moves, showing a window into the cached world
display.camera.follow(player, true);
// No fake.camera sync needed!
}
// ❌ WRONG: Syncing cameras defeats caching
// This forces the fake canvas to move every frame
fake.camera.x = display.camera.x; // Every frame — defeats caching
fake.camera.y = display.camera.y;
Approach What Happens Performance
Syncing cameras Fake canvas moves every frame ❌ Poor — defeats caching
Resize fake canvas Fake canvas is world-sized, never moves ✅ Excellent — true caching
The fake.bgComm Special Property
fake.bgComm is a special property for background images:
// Set a background that renders behind EVERYTHING ELSE
fake.bgComm = new Component(2000, 2000, null, 0, 0, "image");
fake.bgComm.setImage("sky.png");
// The background is drawn first, then tiles, then decorations
// This creates a proper layer stack
Render Order — From Bottom to Top
1. fake.bgComm ← Lowest layer (sky, distant scenery)
2. tileComm (layers) ← Tilemaps (ground, walls)
3. commp (fake.add()) ← Static decorations (trees, rocks)
4. comm (display.add()) ← Dynamic content (player, enemies)
5. UI with .fixed() ← Top layer (HUD, score)
🎯 Important: TileMap Layers Are Switchable, Not Stackable
This is a critical architectural decision in Limn Engine:
TileMap layers are SWITCHABLE, not STACKABLE. Only one layer is visible at a time.
// Layer 0: Ground
display.tileFace.show(0); // Only this layer is visible
// Layer 1: Decorations
display.tileFace.addMap(decorationMap);
display.tileFace.show(1); // This REPLACES layer 0, not adds to it
What this means:
Aspect Explanation
Stackable layers Layer 0 is bottom, Layer 1 is on top, both visible (❌ Not how Limn works)
Switchable layers Only one layer visible at a time (✅ How Limn works)
Use case Level switching, room transitions
Alternative for stacking Use fake.add() for decorations on top of tiles
Why this design?
· Performance: Only one layer is rendered, keeping draw calls minimal
· Simplicity: Level switching is just one function call
· Memory: Only one layer's data is stored in tileComm[] at a time
Common Mistakes — And How to Fix Them
Mistake Why It's Wrong Fix
Adding moving objects to fake They never update — they're cached Use display.add() for dynamic objects
Forgetting to refresh Changes don't appear on screen Call fake.refresh() after changes
Syncing cameras Defeats the caching benefit Resize fake canvas to world size instead
Not using perform() The fake canvas doesn't exist Always call display.perform() first
Treating layers as stackable Layers replace each other Use fake.add() for stacking decorations
💡 Pro Tip: Multi-Layer Worlds
The fake canvas supports multiple layers for complex worlds, but they are switchable:
// Layer 0: Grassland
display.tileFace.show(0);
// Layer 1: Dungeon
display.tileFace.addMap(dungeonMap);
display.tileFace.show(1); // Switches to dungeon
// Layer 2: Snow
display.tileFace.addMap(snowMap);
display.tileFace.show(2); // Switches to snow
All layers are composited onto the fake canvas as a single cached image. This means a 3-layer world with 500 tiles costs the same as a 1-layer world with 1 tile — one draw call per frame.
- angularMovement & moveAngle — Angular Motion
⏱️ 20 min | Level: 10x
angularMovement is a powerful but often overlooked property. When set to true, the engine uses moveAngle() instead of move() — meaning the component moves in the direction of its current angle.
Understanding the Difference
Property Effect
move() Moves using speedX and speedY independently of angle
moveAngle() Moves using speedX and speedY relative to angle
How moveAngle() Works Internally
// What moveAngle() does internally (simplified)
// This is the actual code inside the Component class:
moveAngle() {
// Gravity still applies
this.gravitySpeed += this.gravity;
// The key difference: speed is multiplied by cos(angle) and sin(angle)
// This means movement is RELATIVE to the component's facing direction
this.x += this.speedX * Math.cos(this.angle);
this.y += this.speedY * Math.sin(this.angle) + this.gravitySpeed;
}
// Compare with move():
move() {
if (this.physics) {
this.gravitySpeed += this.gravity;
this.x += this.speedX;
this.y += this.speedY + this.gravitySpeed;
} else {
this.x += this.speedX;
this.y += this.speedY;
}
}
Enabling Angular Movement
// 1. Create the component
const orbiter = new Component(20, 20, "cyan", 400, 300, "rect");
// 2. Enable angular movement — this tells the engine to use moveAngle()
orbiter.angularMovement = true;
// 3. Add it to the display
display.add(orbiter);
// 4. In the update loop:
function update(dt) {
// Rotate the orbiter (2 degrees per frame)
orbiter.angle += 0.02;
// Set speed — this will be applied RELATIVE to the angle
// The orbiter will move in the direction it's facing
orbiter.speedX = 2;
orbiter.speedY = 2;
}
Why This Matters — Practical Use Cases
Use Case How angularMovement Helps
Orbiting objects Planets circle their star automatically
Bullets fired at angles Shoot in any direction without trig
Smooth turns Vehicles turn gradually
Circular motion Particles orbit without manual math
Example: Orbiting Enemy
const enemy = new Component(40, 40, "red", 200, 200, "rect");
enemy.angularMovement = true;
display.add(enemy);
function update(dt) {
// 1. Rotate the enemy's facing direction
enemy.angle += dt * 1.5; // 1.5 radians per second
// 2. Set speed — the enemy moves in the direction it's facing
// The enemy will trace a circle because the angle is constantly changing
enemy.speedX = 80; // Orbital speed
enemy.speedY = 80;
}
Example: 360-Degree Shooting
function shoot() {
// 1. Create the bullet
const bullet = new Component(8, 8, "yellow", player.x, player.y, "rect");
// 2. Enable angular movement
bullet.angularMovement = true;
// 3. Set the bullet's facing direction to match the player
bullet.angle = player.angle;
// 4. Set speed — the bullet will move in the direction it's facing
bullet.speedX = 300;
bullet.speedY = 300;
// 5. Add to display
display.add(bullet);
}
function update(dt) {
// Point the player toward the mouse
move.pointTo(player, mouse.x, mouse.y);
// Shoot on spacebar
if (display.keys[32]) {
shoot();
display.keys[32] = false; // Prevent holding
}
}
💡 Pro Tip: Combining with move.pointTo()
move.pointTo() sets the angle to face a target. Combine it with angularMovement to create enemies that turn toward the player and move in that direction:
// This enemy turns toward the player and moves forward
move.pointTo(enemy, player.x, player.y);
enemy.speedX = 80; // Moves toward player
enemy.speedY = 80;
Comparison: move() vs moveAngle()
// move() — movement independent of angle
// The component moves exactly where speedX and speedY point
component.move(); // x += speedX, y += speedY
// moveAngle() — movement DEPENDENT on angle
// The component moves in the direction of angle
component.moveAngle(); // x += speedX * cos(angle), y += speedY * sin(angle)
Common Pitfalls — And How to Fix Them
Issue Why It Happens Solution
Component doesn't rotate changeAngle is false Set changeAngle = true (it's true by default)
Movement seems wrong angularMovement is false Set angularMovement = true
Speed is too fast speedX and speedY values are too high Reduce them — try values between 1 and 5
Gravity doesn't work gravity is not set Set component.gravity = 0.5
- deltaTime Movement — Frame-Rate Independence
⏱️ 20 min | Level: 10x
Using dt (deltaTime) as a multiplier on all speed values makes your game frame-rate independent — objects move N pixels per second, not N pixels per frame.
What Is Delta Time?
Delta time is the time elapsed since the last frame, measured in seconds. At 60fps, dt ≈ 0.0167; at 30fps, dt ≈ 0.033.
const PLAYER_SPEED = 280; // Pixels per second
const BULLET_SPEED = 600; // Pixels per second
function update(dt) {
// Without dt: speed would be in pixels per frame
// player.speedX = 4; // 240px/s at 60fps, 120px/s at 30fps ❌
// With dt: speed is in pixels per second
// player.speedX = PLAYER_SPEED * dt; // Always 280px/s ✅
player.speedX = 0;
player.speedY = 0;
if (display.keys[87]) player.speedY = -PLAYER_SPEED * dt; // Up
if (display.keys[83]) player.speedY = PLAYER_SPEED * dt; // Down
if (display.keys[65]) player.speedX = -PLAYER_SPEED * dt; // Left
if (display.keys[68]) player.speedX = PLAYER_SPEED * dt; // Right
}
Why This Matters — A Real Example
dt at 60fps dt at 30fps 280 * dt at 60fps 280 * dt at 30fps
~0.0167 ~0.033 ~4.67 px/frame ~9.24 px/frame
Both cases travel exactly 280 pixels per second! The game runs at the same speed regardless of frame rate.
💡 Pro Tip: dt Comes from Live FPS
In perform() mode, display.deltaTime is calculated as 1 / display.fps where display.fps is measured from live browser timestamps. This means dt is accurate even when the browser drops frames.
Using Delta Time for Timers
let spawnTimer = 0;
function update(dt) {
// 1. Accumulate time
spawnTimer += dt;
// 2. Check if 2 seconds have passed
if (spawnTimer >= 2.0) {
// 3. Spawn an enemy
spawnEnemy();
// 4. Reset the timer
spawnTimer = 0;
}
}
💡 Pro Tip: Never Use Frame Counters
Never use frame counters (e.g., if (frame++ > 60)) for timing — they break on high-refresh monitors. Always use dt:
// ❌ WRONG: Frame counter — breaks on high-refresh monitors
// At 60fps: spawns every 1 second
// At 144fps: spawns every 0.42 seconds ❌
if (frame++ > 60) {
spawnEnemy();
frame = 0;
}
// ✅ CORRECT: Delta time — works at any framerate
// At 60fps: spawns every 1 second
// At 144fps: spawns every 1 second ✅
spawnTimer += dt;
if (spawnTimer >= 1.0) {
spawnEnemy();
spawnTimer = 0;
}
- clearMargin Optimisation
⏱️ 15 min | Level: 10x
display.clearMargin tells the engine how large an area to clear with clearRect at the start of each frame.
What It Does — The Internals
// Inside display.clear()
clear() {
// clearRect clears a rectangle area of the canvas
// The area is defined by [x, y, width, height]
// display.clearMargin defines the width and height to clear
this.context.clearRect(0, 0, this.clearMargin[0], this.clearMargin[1]);
}
Understanding the Default
const display = new Display();
display.perform();
display.start(800, 600);
// Default after start() — conservatively large for rotation safety
// display.clearMargin = [800*800, 600*600] = [640000, 360000]
// This clears a 640,000 x 360,000 pixel area! 😱
// ✅ Optimised for a fixed-camera game (no rotation)
display.clearMargin = [800, 600]; // Clears exactly the visible area
// ✅ Optimised for a scrolling world
display.clearMargin = [2000, 2000]; // Match your world dimensions
💡 Pro Tip: When to Keep the Default
Keep the large default only if you use shakeRotation() heavily. Otherwise, reducing clearMargin to match your canvas or world size is a simple one-line optimisation.
- Viewport Culling with TCJSgameGameArea
⏱️ 15 min | Level: 10x
In perform() mode, Limn Engine automatically skips drawing any Component that is outside the visible viewport using a collision check against TCJSgameGameArea.
How It Works — The Internals
// Inside ani() — the main game loop
// TCJSgameGameArea is a giant invisible Component that covers the canvas
// It's positioned at the camera's current origin
comm.forEach(component => {
if (component.scene == display.scene) {
component.x.move();
// ONLY draw the component if it overlaps with TCJSgameGameArea
// This means: only draw if the component is ON SCREEN
if (TCJSgameGameArea.crashWith(component.x)) {
try {
component.x.update(display.context);
} catch (e) {
// pass
}
}
}
});
What This Means for You
// Culling is automatic in perform() mode — nothing to configure!
// You can have hundreds of enemies spread across a large world:
const enemies = [];
for (let i = 0; i < 200; i++) {
const e = new Component(32, 32, "red",
Math.random() * 3000, // Random X across the world
Math.random() * 2000, // Random Y across the world
"rect");
display.add(e);
enemies.push(e);
}
// Only enemies on-screen are drawn — off-screen enemies cost zero draw calls
// This is why the fake canvas + culling = high performance!
💡 Pro Tip: Large Backgrounds on Fake Canvas
Very large Components (like a 2000px background rectangle) should go on the fake canvas via fake.add() rather than the main canvas, so they are cached rather than culled.
- Custom Particle Systems from Scratch
⏱️ 25 min | Level: 10x
When the built-in ParticleSystem is not specialised enough, draw particles directly onto display.context for maximum performance and pixel-level control.
Why Custom Particles?
Adding hundreds of Particles to comm[] means the engine iterates that array each frame — a pool-based custom system that draws directly to the context skips the array overhead entirely.
Building a Custom Particle System — Step by Step
Step 1: Define the Particle Class
class TrailSystem {
constructor() {
// The pool stores all active particles
// Each particle is a plain object (not a Component)
this.pool = [];
}
Step 2: Create the emit() Method
emit(x, y, color = "#7fffb2") {
// Add a new particle to the pool
// Each particle has:
// - position (x, y)
// - visual properties (color, alpha, size)
// - physics properties (vx, vy for velocity)
this.pool.push({
x: x,
y: y,
color: color,
alpha: 1, // Start fully opaque
size: 5, // Start at size 5
vx: (Math.random() - 0.5) * 2, // Random horizontal velocity
vy: -1 // Slight upward velocity
});
}
Step 3: Create the update() Method
update(ctx) {
// 1. Iterate backwards through the pool
// Why backwards? We're removing items — forward iteration skips elements
for (let i = this.pool.length - 1; i >= 0; i--) {
const p = this.pool[i];
// 2. Update particle properties
p.alpha -= 0.035; // Fade out
p.size -= 0.1; // Shrink
p.x += p.vx; // Move horizontally
p.y += p.vy; // Move vertically
// 3. Check if particle is dead
if (p.alpha <= 0 || p.size <= 0) {
this.pool.splice(i, 1); // Remove from pool
continue; // Skip drawing this particle
}
// 4. Draw the particle
ctx.save(); // Save context state
ctx.globalAlpha = p.alpha; // Apply transparency
ctx.fillStyle = p.color; // Set color
ctx.beginPath(); // Start drawing a circle
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); // Draw circle
ctx.fill(); // Fill the circle
ctx.restore(); // Restore context state
}
}
}
Using the Custom Particle System
// 1. Create the particle system
const trail = new TrailSystem();
// 2. In the update loop:
function update(dt) {
// 3. Emit a particle at the player's position
trail.emit(player.x + 20, player.y + 20);
// 4. Update all particles (draw them to the canvas)
trail.update(display.context); // Draws into the same context Limn uses
}
💡 Pro Tip: Reverse Iteration for Splicing
Always iterate arrays in reverse when removing elements mid-loop — forward iteration + splice skips elements:
// ✅ CORRECT: Reverse iteration
// This correctly removes dead particles without skipping any
for (let i = particles.length - 1; i >= 0; i--) {
if (particles[i].alpha <= 0) {
particles.splice(i, 1); // Removes the current element safely
}
}
// ❌ WRONG: Forward iteration + splice skips elements
// After splice, the array shifts left, skipping the next element
for (let i = 0; i < particles.length; i++) {
if (particles[i].alpha <= 0) {
particles.splice(i, 1); // Skips the next element! ❌
}
}
- Extending the Engine with Prototypes
⏱️ 20 min | Level: 10x
Limn Engine is plain unminified JavaScript, which means you can add methods to any class via prototype extension.
What Is Prototype Extension?
// Every Component instance shares methods from Component.prototype
// You can add new methods that become available to ALL components
Component.prototype.myNewMethod = function() {
// This method is now available on every Component
console.log("This works on any component: " + this.x);
};
// Usage:
player.myNewMethod(); // Works!
enemy.myNewMethod(); // Works!
Why Prototype Extension?
Advantage Explanation
Clean Reads like a built-in engine method
Safe Survives engine version updates — you never touch epic.js
Separate Keeps your game logic in a separate file
Adding a Full Health System — Step by Step
Step 1: Set Up Health
// Add setHealth() to every Component
Component.prototype.setHealth = function(max) {
this.hp = max; // Current health
this.maxHp = max; // Maximum health
this.invincible = 0; // Invincibility frames counter
};
Step 2: Add Damage Handling
// Add damage() to every Component
Component.prototype.damage = function(amount) {
// 1. Check if invincible
if (this.invincible > 0) return false; // Immune!
// 2. Apply damage
this.hp = Math.max(0, this.hp - amount);
// 3. Activate invincibility frames
this.invincible = 30; // 30 frames of invincibility
// 4. Return true if dead
return this.hp === 0;
};
Step 3: Update Invincibility
// Add tickInvincible() to every Component
Component.prototype.tickInvincible = function() {
if (this.invincible > 0) {
this.invincible--; // Count down one frame
}
};
Step 4: Draw Health Bar
// Add drawHealthBar() to every Component
Component.prototype.drawHealthBar = function(ctx) {
// 1. Calculate health percentage (0 to 1)
const pct = this.hp / this.maxHp;
// 2. Draw background (dark grey)
ctx.fillStyle = "#333";
ctx.fillRect(this.x, this.y - 10, this.width, 5);
// 3. Choose color based on health
let color;
if (pct > 0.5) color = "#00cc44"; // Green — healthy
else if (pct > 0.25) color = "#ffaa00"; // Yellow — wounded
else color = "#cc0000"; // Red — critical
// 4. Draw health fill
ctx.fillStyle = color;
ctx.fillRect(this.x, this.y - 10, this.width * pct, 5);
};
Using the Health System
// 1. Set up health for game objects
player.setHealth(100);
enemy.setHealth(40);
// 2. In the update loop:
function update() {
// 3. Count down invincibility
player.tickInvincible();
enemy.tickInvincible();
// 4. Check collision
if (player.crashWith(enemy)) {
// 5. Apply damage
const died = enemy.damage(25);
// 6. If enemy died, remove it
if (died) {
enemy.destroy(); // Remove from engine
}
}
// 7. Draw health bars
player.drawHealthBar(display.context);
enemy.drawHealthBar(display.context);
}
💡 Pro Tip: Method Chaining via Prototype
You can also patch existing engine methods by saving the original and calling it inside a wrapper:
// 1. Save the original update method
const originalUpdate = Component.prototype.update;
// 2. Wrap it with custom logic
Component.prototype.update = function(ctx) {
// Custom logic before
if (this.hp <= 0) return; // Don't draw dead objects
// Call the original
originalUpdate.call(this, ctx);
// Custom logic after
this.drawHealthBar(ctx);
};
- SoundManager Deep-Dive
⏱️ 20 min | Level: 10x
SoundManager is Limn's full audio pipeline — named sounds, separate volume levels, and mute/unmute toggling.
Setup — Step by Step
// 1. Create the SoundManager instance
window.soundManager = new SoundManager();
// 2. Load sounds with names
soundManager.load("jump", "audio/jump.wav");
soundManager.load("shoot", "audio/shoot.wav", { volume: 0.7 });
soundManager.load("explode", "audio/explode.wav");
soundManager.load("music_bg", "audio/background.mp3", { loop: true });
// 3. Set volume levels
soundManager.masterVolume = 0.8; // Global volume (0–1)
soundManager.sfxVolume = 1.0; // Sound effects volume (0–1)
soundManager.musicVolume = 0.5; // Music volume (0–1)
// 4. Play background music
soundManager.playMusic("music_bg");
The move.sound Shorthand
// The move.sound object is a thin wrapper that checks if SoundManager exists
// This lets you use sound in gameplay without null reference errors
function update() {
if (display.keys[32]) {
display.keys[32] = false;
move.sound.play("shoot"); // Plays "shoot" sound
}
}
💡 Pro Tip: Preload Before display.start()
Create and load all sounds before calling display.start() so they preload during any loading screen or intro. This prevents mid-game stutter on the first play of an unloaded sound.
Volume Control
Property Purpose
masterVolume Global multiplier (0–1) applied to all sounds
sfxVolume Volume for sound effects (0–1)
musicVolume Volume for background music (0–1)
- AnimatedSprite Advanced Techniques
⏱️ 20 min | Level: 10x
AnimatedSprite extends Sprite with a named animation clip system — each clip stores its own start frame, end frame, speed, and loop flag.
Setting Up an AnimatedSprite — Step by Step
// 1. Create the AnimatedSprite
// Parameters: (image path, frameWidth, frameHeight, x, y)
const hero = new AnimatedSprite("hero_sheet.png", 64, 64, 200, 300);
// 2. Add animation clips
// addAnimation(name, startFrame, endFrame, speed, loop)
hero.addAnimation("idle", 0, 3, 10, true); // Loops forever
hero.addAnimation("run", 4, 11, 5, true); // Loops forever
hero.addAnimation("jump", 12, 15, 4, false); // Plays once, stops
hero.addAnimation("attack",16, 19, 3, false); // Plays once, stops
// 3. Add to display
display.add(hero);
The playAnimation Guard — Why It's Important
// Inside AnimatedSprite.playAnimation():
playAnimation(name) {
// ⚡ CRITICAL GUARD: This prevents resetting the animation if it's already playing
if (this.currentAnim === name) return;
// Only reset if the animation name changed
this.currentAnim = name;
const anim = this.animations[name];
this.currentFrame = anim.start;
this.frameSpeed = anim.speed;
this.loop = anim.loop;
}
// This guard means you can call playAnimation() every frame safely:
function update() {
// This is called 60 times per second
// Without the guard, it would reset to frame 0 every frame!
hero.playAnimation("run"); // OK — guard prevents reset
}
Using AnimatedSprite in a Game
let state = "idle";
function update() {
// 1. Determine the current state
const moving = display.keys[68] || display.keys[65];
if (moving) {
state = "run";
} else {
state = "idle";
}
if (display.keys[87]) {
state = "jump";
}
if (display.keys[90]) { // Z key for attack
state = "attack";
}
// 2. Play the appropriate animation
hero.playAnimation(state);
// 3. Advance the frame counter (required every frame)
hero.updateAnimation();
}
💡 Pro Tip: The paused Flag
When a non-looping animation finishes, it sets this.paused = true. Check hero.paused to detect completion and transition to the next state:
if (hero.paused && state === "attack") {
hero.paused = false; // Reset the flag
state = "idle"; // Return to idle
}
Flip Direction Helpers
// Flip the sprite horizontally without changing the frames
hero.faceLeft(); // flips X — faces left
hero.faceRight(); // resets flip — faces right
- Performance Checklist for Shipping
⏱️ 15 min | Level: 10x
Everything a 10x Limn developer verifies before shipping:
Check Why It Matters
✅ display.perform() before start() requestAnimationFrame loop, accurate deltaTime, fake canvas activated
✅ Static objects on fake, dynamic on display Static world cached in one bitmap — zero per-frame re-draw cost
✅ All speeds multiplied by dt Identical game speed at 30fps, 60fps, and 144fps
✅ display.clearMargin set to canvas or world size Avoid clearing a 640,000px area when 800px is enough
✅ ps.update() called every frame Dead particles removed — prevents unbounded memory growth
✅ destroy() on off-screen bullets and killed enemies Removes from comm[] — reduces loop iteration count
✅ hide() for temporarily invisible objects Skips draw call without destroying — use for respawning objects
✅ Scenes used for menus and game-over No create/destroy cost on state change — just flip display.scene
✅ All sounds preloaded before display.start() No mid-game stutter on first play of an unloaded sound
✅ Reverse iteration when splicing arrays mid-loop Forward iteration + splice skips elements — reverse is safe
💡 Pro Tip: The destroy() Method
destroy() permanently removes a Component from the engine's rendering pipeline by splicing it out of both comm[] and commp[] arrays and setting its update method to null. Use it for objects that will never be needed again — bullets that exit the screen, enemies that are killed, collected items.
- Open-World Demo — Complete Walkthrough
⏱️ 30 min | Level: 10x
This is a complete open-world demo (test15.html) combining everything we've covered: perform(), layered tilemap on fake canvas, deltaTime movement, particle trail, and fixed HUD.
Full Code with Line-by-Line Explanation
// ── SECTION 1: SETUP ──
// 1. Create the display
const display = new Display();
// 2. ⚡ CRITICAL: Activate dual-canvas mode
// This MUST come before start() — it creates the fake canvas
display.perform();
// 3. Create the canvas and start the game loop
display.start(800, 600);
// 4. Set a dark background
display.backgroundColor("#0a0a0a");
// 5. Define the world size — 2000x2000 pixel world
display.camera.worldWidth = 2000;
display.camera.worldHeight = 2000;
// 6. Optimise clear area to match world size
display.clearMargin = [2000, 2000];
// 7. Set culling area to match world size
TCJSgameGameArea.width = 2000;
TCJSgameGameArea.height = 2000;
// ── SECTION 2: FAKE CANVAS — STATIC WORLD ──
// 8. Resize the fake canvas to match the world size
// This is the CORRECT approach — the fake canvas never moves
fake.mapWidth = 2000;
fake.mapHeight = 2000;
fake.canvas.width = 2000;
fake.canvas.height = 2000;
// 9. Define tile types
// Each tile is a Component with a size and colour
fake.tile = [
// Tile 0: Empty (placeholder)
new Component(64, 64, "#1a3a1a", 0, 0), // 1 = grass (green)
new Component(64, 64, "#2244aa", 0, 0), // 2 = water (blue)
new Component(64, 64, "#555", 0, 0), // 3 = stone (gray)
];
// 10. Define the level as a 2D array (4 rows × 30 columns)
// Each number refers to a tile type defined above
// 0 = empty, 1 = grass, 2 = water, 3 = stone
fake.map = [
[1,1,1,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,2,2,1,1,1,1,3,3,3,1,1,1,1,1,1,1,3,3,3,1,1,1,1,1,1,1,1],
[1,1,1,2,2,2,2,2,1,3,3,3,1,1,1,1,1,1,1,3,3,3,1,1,1,1,1,1,1,1],
[1,1,1,1,1,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
];
// 11. Initialise and render the tilemap to the fake canvas
fake.tileMap(); // Create the TileMap object
fake.tileFace.show(); // Render all tiles to the fake canvas (cached!)
// ── SECTION 3: DISPLAY CANVAS — DYNAMIC OBJECTS ──
// 12. Create the player — a 36x36 blue square
const player = new Component(36, 36, "blue", 400, 200, "rect");
display.add(player); // Add to main canvas (dynamic — redrawn every frame)
// 13. Create a HUD for displaying coordinates
const hud = new Tctxt("16px","Arial","white",12,20,
"left",false,"alphabetic","rgba(0,0,0,0.6)",8,4);
hud.setText("WASD to move");
display.add(hud);
// 14. Create the particle system for the sparkle trail
const ps = new ParticleSystem(display);
// 15. Player speed in pixels per second (frame-rate independent)
const SPEED = 220;
// ── SECTION 4: GAME LOOP ──
function update(dt) {
// 16. Update particles (required every frame)
// This advances all particles, fades them, and removes dead ones
ps.update();
// 17. Player movement using deltaTime (frame-rate independent)
// Reset speed first
player.speedX = 0; player.speedY = 0;
// WASD controls — each key applies speed in pixels per second
if (display.keys[87]) player.speedY = -SPEED * dt; // W = Up
if (display.keys[83]) player.speedY = SPEED * dt; // S = Down
if (display.keys[65]) player.speedX = -SPEED * dt; // A = Left
if (display.keys[68]) player.speedX = SPEED * dt; // D = Right
// 18. Particle trail — sparkles follow the player
// Emits a sparkle at the player's centre (x+18, y+18)
move.particles.sparkle(ps, player.x+18, player.y+18);
// 19. Camera follows the player smoothly
// true = smooth interpolation (lerp), false = instant snap
display.camera.follow(player, true);
// ❌ No fake.camera sync needed!
// The fake canvas is already the full world size (2000×2000),
// so the main camera just shows the right portion.
// This is the CORRECT approach — the fake canvas never moves.
// 20. Update HUD with player coordinates
hud.setText("X:" + Math.floor(player.x) + " Y:" + Math.floor(player.y));
// 21. Lock HUD to screen (doesn't scroll with camera)
// This is essential for UI elements
hud.fixed();
}
Key Takeaways from This Demo
Concept How It's Implemented Why It Matters
perform() required Called before start() Without it, the tilemap would be invisible
Fake canvas resizing Set to world size (2000×2000) No camera sync needed — true caching
Tilemap 4×30 grid with 3 tile types Rendered once and cached — one draw call per frame
DeltaTime SPEED * dt Consistent movement at any framerate
Particle trail move.particles.sparkle() Simple visual feedback for player movement
Camera follow Smooth follow with true Professional, polished feel
Fixed HUD hud.fixed() UI stays visible when camera moves
💡 Pro Tip: Why perform() Is Critical
The TileMap system does not exist without display.perform(). The fake canvas is created by perform(), and the TileMap relies on the fake canvas to cache tiles. If you forget perform(), your tiles will never be drawn — even if your level data is correct. This is the #1 mistake new Limn developers make.
🔗 Resources
| Resource | Link |
|---|---|
| Live Demo (test15) | limn-engine-doc.vercel.app/test15.html |
| GitHub Repository | ://github.com |
| Direct Download (epic.js) | ://github.com/blob/main/asset/epic.js |
| Complete API Reference | limn-engine-doc.vercel.app/reference.html |
| Discord Community | discord.gg/ZqnUtTQb8 |
✅ Conclusion
You've reached the top of the Limn Engine — the 10x Developer Level.
You now understand:
· ✅ The dual-canvas rendering pipeline and display.perform()
· ✅ How to correctly use the fake canvas (resize, don't sync)
· ✅ That TileMap requires perform() to work
· ✅ That TileMap layers are switchable, not stackable
· ✅ Angular movement with angularMovement and moveAngle
· ✅ Frame-rate independent movement with deltaTime
· ✅ Performance optimisations with clearMargin and culling
· ✅ Custom particle systems and engine extension
· ✅ Advanced audio with SoundManager
· ✅ Professional animation with AnimatedSprite
You are now a 10x Limn Developer. 🎉
If anything is still unclear, just ask — I'm happy to explain any part of the code in more detail! 🚀
Top comments (0)