🎮 Building a Top‑Down Action Game with the Limn Engine
A complete, line‑by‑line tutorial
Live demo: limn-engine-doc.vercel.app/test11.html
📖 Introduction
Every great game developer remembers their first "real" project – the moment when code transformed from abstract syntax into something interactive, something alive on the screen. Yet for many beginners, that milestone remains frustratingly out of reach, buried under complex frameworks, opaque build tools, and thousands of lines of boilerplate.
The Limn Engine changes that. Designed from the ground up for accessibility and clarity, it strips away unnecessary complexity and lets you focus on what matters: building your game. With just a single JavaScript file and a few lines of code, you can create a fully functional 2D game that runs in any browser.
This tutorial will guide you through every line of test11.html – a complete top‑down action game built with the Limn Engine. You'll learn not just what the code does, but why it works that way. By the end, you'll have a solid understanding of:
- Setting up the engine and game world
- Designing tile‑based levels
- Implementing player movement, collisions, and AI
- Adding visual effects, UI, and game state management
- Using the engine's powerful features like the camera system, particles, and off‑screen rendering
Whether you're a complete beginner taking your first steps or an experienced developer looking for a refreshingly simple tool, this guide will give you the confidence to build your own games with the Limn Engine.
Let's begin.
📁 1. Project Setup & HTML Structure
<!doctype html>
<html>
<head>
<!-- Include the Limn Engine -->
<script src="epic.js"></script>
</head>
<body>
<script>
// All game code goes here
What this does:
-
<!doctype html>– Declares this as an HTML5 document. -
<html>– The root element of the page. -
<head>– Contains meta‑information about the page. -
<script src="epic.js">– Loads the entire Limn Engine into your page. This gives you access to all the classes likeDisplay,Component,Tctxt,ParticleSystem, etc. -
<body>– The visible part of the webpage. -
<script>– This is where we write our game code.
🚀 2. Engine Initialization
// Create the main game display and start it
const display = new Display();
display.perform();
display.start(800, 600);
What this does:
-
new Display()– Creates the main game window object – your game's "screen". -
display.perform()– A special setup function that prepares the engine to use a more advanced rendering loop. -
display.start(800, 600)– Actually creates the<canvas>element on your webpage and sets its size to 800×600 pixels. The game loop starts running immediately.
// Set up background image for the fake canvas
fake.bgComm = new Component(2000,2000, "#1a1a2e", 0 ,0);
fake.bgComm.setImage("limn-doc/img/alien.png");
What this does:
-
fake.bgComm– Thefakeobject is an off‑screen canvas (a hidden canvas used for rendering static things)..bgCommis a special property for a background component. -
new Component(2000,2000, "#1a1a2e", 0, 0)– Creates a 2000×2000 pixel component at position (0,0). The color"#1a1a2e"is a dark blue‑gray. -
setImage("limn‑doc/img/alien.png")– Changes the component from a coloured rectangle to an image. This loads and displays the alien image as the background.
// Set the game area size (for culling/performance)
TCJSgameGameArea.width = 1600;
TCJSgameGameArea.height = 1200;
// Set the camera's world boundaries
display.camera.worldWidth = 1600;
display.camera.worldHeight = 1200;
What this does:
-
TCJSgameGameArea.width/height– Sets the size of the "game world". The engine uses this to decide what to draw (culling). If something is outside this area, it might not be rendered, which saves performance. -
display.camera.worldWidth/Height– Tells the camera how big the entire game world is. The camera will never scroll beyond these bounds, keeping the player within the world.
// Create the particle system
const particles = new ParticleSystem(display);
What this does:
-
new ParticleSystem(display)– Creates a new particle manager linked to our main display. We'll use this to create cool effects like explosions and sparkles.
🗺️ 3. Creating the Level (TileMap)
// Define the level layout as a 2D array (10 rows x 10 columns)
fake.map = [
[1,1,1,1,1,1,1,1,1,1], // Row 0
[1,0,0,0,0,0,0,0,0,1], // Row 1
[1,0,2,0,0,0,0,2,0,1], // Row 2
[1,0,0,1,1,0,0,0,0,1], // Row 3
[1,0,0,0,0,0,0,0,0,1], // Row 4
[1,0,0,0,0,1,1,0,0,1], // Row 5
[1,0,2,0,0,0,0,2,0,1], // Row 6
[1,0,0,0,0,0,0,0,0,1], // Row 7
[1,0,0,0,0,0,0,0,0,1], // Row 8
[1,1,1,1,1,1,1,1,1,1] // Row 9
];
What this does:
- This is a 10×10 grid. Each number represents a tile type:
-
0– empty space (floor) -
1– wall -
2– coin
-
- This array is the blueprint for your level. The walls form a border and some interior structures, and there are coins placed in the corridors.
// Define tile size and create tile components
const tileW = 40;
const tileH = 40;
fake.tile = [
new Component(tileW, tileH, "#654321", 0, 0, "rect"), // Index 0: Wall tile
new Component(25, 25, "gold", 0, 0, "rect") // Index 1: Coin tile
];
What this does:
-
tileWandtileH– Set the size of each tile to 40×40 pixels. Since our world is 1600×1200, we have exactly 40 tiles across and 30 tiles down. -
fake.tile– This array holds the visual representation of each tile type.-
fake.tile[0](index 0) is a brown rectangle (#654321) – our wall. -
fake.tile[1](index 1) is a small gold rectangle – our coin.
-
-
Important: The engine automatically adds an empty placeholder at index 0, so our actual tiles start at index 1. This is why we map:
- Tile ID
1→fake.tile[1](wall) - Tile ID
2→fake.tile[2](coin)
- Tile ID
// Configure and render the tilemap
fake.mapWidth = 1600;
fake.mapHeight = 1200;
fake.canvas.width = 1600;
fake.canvas.height = 1200;
fake.tileMap();
const tilemap = fake.tileFace;
fake.tileFace.show();
What this does:
-
fake.mapWidth/Height– Set the size of the tilemap world. -
fake.canvas.width/Height– Resize the off‑screen canvas to match the world size. -
fake.tileMap()– This function readsfake.mapandfake.tileand creates the internal tilemap object. -
const tilemap = fake.tileFace– We store a reference to the tilemap so we can use it later for collision detection and coin collection. -
fake.tileFace.show()– This renders all the tiles onto the off‑screenfakecanvas. It calculates the position of each tile based on its row/column and draws it. This happens only once, which is very efficient!
🎮 4. Creating Game Objects
Creating the Player
const player = new Component(40, 40, "#5b8cff", 700, 500, "rect");
display.add(player);
What this does:
-
new Component(40, 40, "#5b8cff", 700, 500, "rect")– Creates a new component (game object).-
40, 40– width and height in pixels. -
"#5b8cff"– the colour (bright blue). -
700, 500– starting X and Y position. -
"rect"– the type is a rectangle.
-
-
display.add(player)– Adds the player to the main display, so it will be drawn and updated every frame.
Creating Enemies
let enemies = [];
for (let i = 0; i < 4; i++) {
const enemy = new Component(35, 35, "#ff6b6b",
Math.random() * 1400 + 100,
Math.random() * 1000 + 100, "rect");
enemy.speed = 60;
display.add(enemy);
enemies.push(enemy);
}
What this does:
-
let enemies = []– Creates an empty array to store all our enemy objects. -
for (let i = 0; i < 4; i++)– Loops 4 times to create 4 enemies. -
new Component(35, 35, "#ff6b6b", ..., ..., "rect")– Creates a 35×35 pixel red enemy.-
Math.random() * 1400 + 100– random X position between 100 and 1500. -
Math.random() * 1000 + 100– random Y position between 100 and 1100.
-
-
enemy.speed = 60– Adds a customspeedproperty to the enemy. The engine doesn't use this automatically; we'll use it in ourupdatefunction. -
display.add(enemy)– Adds the enemy to the display. -
enemies.push(enemy)– Stores the enemy in our array so we can update all of them easily.
Creating the UI (Health Bar & Score)
// Health bar background
const healthBarBg = new Component(220, 25, "#440000", 20, 50, "rect");
const healthBarFill = new Component(200, 20, "#00ff44", 30, 52, "rect");
display.add(healthBarBg);
display.add(healthBarFill);
// Score text with styling
const scoreText = new Tctxt("24px", "Arial", "#ffffff", 20, 100,
"left", false, "hanging", "rgba(0,0,0,0.5)", 10, 5);
scoreText.setText("Score: 0");
display.add(scoreText);
What this does:
-
Health Bar Background (
healthBarBg):-
220, 25– 220px wide, 25px tall. -
"#440000"– dark red. -
20, 50– position at (20, 50) from the top‑left corner of the screen. - This creates the "container" for the health bar.
-
-
Health Bar Fill (
healthBarFill):-
200, 20– slightly smaller to fit inside the background. -
"#00ff44"– bright green. -
30, 52– positioned slightly inside the background bar. - This bar will change width to represent health.
-
-
Score Text (
scoreText):-
new Tctxt(...)–Tctxtis a special text component with more features. -
"24px", "Arial", "#ffffff"– font size, family, colour. -
20, 100– position. -
"left"– alignment. -
false– no stroke/outline. -
"hanging"– text baseline. -
"rgba(0,0,0,0.5)"– semi‑transparent black background. -
10, 5– padding around the text. -
scoreText.setText("Score: 0")– sets the initial text. -
display.add(scoreText)– adds it to the display.
-
🎯 5. Game State & Helper Functions
let score = 0;
let health = 100;
let invincibleTimer = 0;
let gameActive = true;
What this does:
-
score– tracks the player's points. -
health– starts at 100. When it reaches 0, the game is over. -
invincibleTimer– counts down to make the player briefly invincible after being hit. -
gameActive– a boolean that stops the game logic whenfalse.
function applyKnockback(target, fromX, fromY, strength = 150) {
const angle = Math.atan2(target.y - fromY, target.x - fromX);
target.speedX = Math.cos(angle) * strength;
target.speedY = Math.sin(angle) * strength;
target.physics = true;
setTimeout(() => {
target.speedX = 0;
target.speedY = 0;
target.physics = false;
}, 200);
}
What this does:
- This function is called when the player is hit.
-
Math.atan2(target.y - fromY, target.x - fromX)– calculates the angle from the enemy to the player. -
target.speedX/Y = Math.cos/Sin(angle) * strength– sets the player's speed in the direction away from the enemy (knockback). -
target.physics = true– enables physics for the player so the speed will actually move them. -
setTimeout(...)– after 200 milliseconds, stops the player's movement by setting speed back to 0 and disabling physics.
🔄 6. The Main Game Loop (update function)
This function runs every frame (≈60 times per second). It's the heart of your game.
📌 Section 1 – Initial Checks
function update(dt) {
if (!gameActive) return;
What this does:
-
function update(dt)– the engine calls this every frame and passesdt(delta time in seconds). Usingdtmakes movement consistent regardless of framerate. -
if (!gameActive) return– if the game is over, it exits immediately, freezing all game logic.
// Make camera follow the player
display.camera.follow(player, true);
What this does:
- Centers the camera on the player with smooth (interpolated) movement.
// Keep UI elements fixed on screen
healthBarBg.fixed();
healthBarFill.fixed();
scoreText.fixed();
What this does:
-
.fixed()– a special method for UI components. Normally components move with the camera;.fixed()pins them to the screen so they stay in the corner even when the camera moves.
// Update particle effects
particles.update();
What this does:
- Updates all particles (moves them, fades them, removes dead ones). Must be called every frame.
📌 Section 2 – Player Invincibility
if (invincibleTimer > 0) {
invincibleTimer -= dt;
player.alpha = 0.5; // Make player semi-transparent (blink)
} else {
player.alpha = 1; // Fully opaque
}
What this does:
- Counts down
invincibleTimerusingdt. -
player.alpha = 0.5– makes the player 50% transparent (blink effect). When timer reaches 0, the player becomes fully opaque.
📌 Section 3 – Player Movement
// Read keyboard input
let mx = 0, my = 0;
if (display.keys[37]) mx = -1; // Left arrow
if (display.keys[39]) mx = 1; // Right arrow
if (display.keys[38]) my = -1; // Up arrow
if (display.keys[40]) my = 1; // Down arrow
What this does:
-
display.keys– an array where the engine stores which keys are currently pressed (keycodes 37‑40 for arrow keys). - If a key is pressed, we set
mxormyto 1 or -1 (direction).
// Normalize diagonal movement
if (mx !== 0 && my !== 0) {
mx *= 0.707;
my *= 0.707;
}
What this does:
- Prevents faster diagonal movement by multiplying both components by
0.707(≈1/√2). This keeps the movement vector length at 1, so the player moves at the same speed in all directions.
// Apply movement
player.x += mx * 200 * dt;
player.y += my * 200 * dt;
What this does:
- Moves the player by
mx(ormy) times speed (200 px/s) timesdt. This makes movement framerate‑independent.
📌 Section 4 – Wall Collision
if (tilemap.crashWith(player, 1)) {
if (mx > 0) player.x -= 10;
if (mx < 0) player.x += 10;
if (my > 0) player.y -= 10;
if (my < 0) player.y += 10;
}
What this does:
-
tilemap.crashWith(player, 1)– checks if the player collides with any tile that has ID1(walls). - If collision occurs, we "push" the player back by 10 pixels in the opposite direction of movement – a simple way to prevent walking through walls.
📌 Section 5 – Coin Collection
const coins = tilemap.tiles(2);
for (let coin of coins) {
if (player.crashWith(coin)) {
tilemap.remove(coin.tx, coin.ty);
score += 10;
scoreText.setText("Score: " + score);
move.particles.sparkle(particles, coin.x + coin.width/2, coin.y + coin.height/2);
display.camera.shake(2, 2);
}
}
What this does:
-
tilemap.tiles(2)– gets all tile objects with ID2(coins). - Loops through all coins, checks overlap with player.
-
tilemap.remove(coin.tx, coin.ty)– removes the coin. -
score += 10– adds 10 points. -
scoreText.setText(...)– updates the display. -
move.particles.sparkle(...)– creates a sparkle effect at the coin's center. -
display.camera.shake(2, 2)– shakes the camera slightly.
📌 Section 6 – Enemy AI & Collisions
for (let i = 0; i < enemies.length; i++) {
const enemy = enemies[i];
What this does:
- Uses a standard
forloop (notfor...of) because we may need to remove enemies from the array while iterating.
// Enemy chases player using normalized direction
let dx = player.x - enemy.x;
let dy = player.y - enemy.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 5) {
enemy.x += (dx / distance) * enemy.speed * dt;
enemy.y += (dy / distance) * enemy.speed * dt;
}
What this does:
-
dxanddy– distance vector from enemy to player. -
distance– straight‑line distance (Pythagoras). - The
if (distance > 5)check prevents jitter when the enemy is already on top of the player. -
(dx / distance)and(dy / distance)– normalised direction vector (length = 1). Multiplying byenemy.speed * dtgives consistent speed in any direction – this solves the "diagonal speed boost" problem.
// Enemy hits player (and player is not invincible)
if (player.crashWith(enemy) && invincibleTimer <= 0) {
health -= 15;
const healthPercent = Math.max(0, health / 100);
healthBarFill.width = 200 * healthPercent;
applyKnockback(player, enemy.x, enemy.y, 200);
display.camera.shake(6, 6);
move.particles.blood(particles, player.x, player.y, 10);
invincibleTimer = 1.0;
if (health <= 0) {
gameActive = false;
const gameOverText = new Tctxt("48px", "Arial", "#ff0000",
400 + display.camera.x, 300 + display.camera.y, "center");
gameOverText.setText("GAME OVER - Score: " + score);
display.add(gameOverText);
}
}
What this does:
- If player touches an enemy and is not invincible:
-
health -= 15– reduces health. -
healthPercent– health as a decimal (0.0‑1.0). -
healthBarFill.width = 200 * healthPercent– shrinks the green fill. -
applyKnockback(...)– pushes player away. -
display.camera.shake(6, 6)– bigger shake. -
move.particles.blood(...)– blood particle effect. -
invincibleTimer = 1.0– 1 second of invincibility. - If health ≤ 0:
-
gameActive = false– stops the loop. - Creates a new
Tctxtfor the game‑over message, centered on screen, and adds it.
-
// Click to defeat enemy
if (mouse.down && enemy.clicked()) {
move.particles.explosion(particles, enemy.x, enemy.y, 20);
score += 50;
scoreText.setText("Score: " + score);
display.camera.shake(4, 4);
enemy.hide();
enemies.splice(i, 1);
i--;
}
What this does:
-
mouse.down– is the mouse button pressed? -
enemy.clicked()– checks if the click occurred inside the enemy's rectangle. -
move.particles.explosion(...)– creates an explosion effect. -
score += 50– bonus points. -
scoreText.setText(...)– updates score. -
display.camera.shake(4, 4)– moderate shake. -
enemy.hide()– makes the enemy disappear (setsupdatetonull). -
enemies.splice(i, 1)– removes the enemy from the array. -
i--– adjusts the loop counter so the next enemy is processed correctly.
📌 Section 7 – Player Boundaries
// Keep player inside world boundaries
player.x = Math.max(0, Math.min(1560, player.x));
player.y = Math.max(0, Math.min(1160, player.y));
}
What this does:
- Clamps the player's X position between 0 and 1560 (world width minus player width).
- Clamps Y between 0 and 1160 (world height minus player height).
- This ensures the player never leaves the visible world.
✅ Conclusion
You've now built a complete game using the Limn Engine! You've learned how to:
- Set up the engine and a game world
- Create tile‑based levels
- Add player movement with keyboard controls
- Implement wall collisions
- Add coin collection with particle effects
- Create enemies with AI that chase the player
- Manage player health and invincibility
- Build a UI with health bars and score text
- Handle game‑over conditions
- Use camera shake for game feel
The Limn Engine makes all of this possible with clean, readable code. Happy game development! 🎮
📦 Full Code: test11.html
Here's the complete, ready-to-run code for the game we just built. You can copy this directly into an HTML file and run it in your browser.
<!doctype html>
<html>
<head>
<script src="epic.js"></script>
</head>
<body>
<script>
const display = new Display();
display.perform();
display.start(800, 600);
//display.backgroundColor("lightblue")
fake.bgComm = new Component(2000,2000, "#1a1a2e", 0 ,0)
fake.bgComm.setImage("limn-doc/img/alien.png")
TCJSgameGameArea.width = 1600
TCJSgameGameArea.height = 1200
display.camera.worldWidth = 1600;
display.camera.worldHeight = 1200;
// display.camera.setZoom(0.75,0.75)
// display.camera.x-=100
const particles = new ParticleSystem(display);
// ===== EVENT BUS =====
const EventBus = {
listeners: {},
on(event, callback) {
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event].push(callback);
},
emit(event, data) {
if (this.listeners[event]) {
this.listeners[event].forEach(cb => cb(data));
}
}
};
// ===== PLAYER =====
const player = new Component(40, 40, "#5b8cff", 700, 500, "rect");
display.add(player);
// ===== SIMPLE LEVEL (10x10 grid, no procedural generation to avoid bugs) =====
fake.map = [
[1,1,1,1,1,1,1,1,1,1],
[1,0,0,0,0,0,0,0,0,1],
[1,0,2,0,0,0,0,2,0,1],
[1,0,0,1,1,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,1,1,0,0,1],
[1,0,2,0,0,0,0,2,0,1],
[1,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,1],
[1,1,1,1,1,1,1,1,1,1]
];
const tileSize = 80; // 800/10 = 80, 600/10 = 60 but we need square
// Actually for 1600x1200 world, tiles should be 40x40
tileW = 40;
tileH = 40;
fake.tile = [new Component(tileW, tileH, "#654321", 0, 0, "rect"),new Component(25, 25, "gold", 0, 0, "rect")
]
fake.mapWidth =1600
fake.mapHeight = 1200
fake.canvas.width = 1600
fake.canvas.height = 1200
fake.tileMap()
const tilemap = fake.tileFace
fake.tileFace.show();
// ===== ENEMIES =====
let enemies = [];
for (let i = 0; i < 4; i++) {
const enemy = new Component(35, 35, "#ff6b6b",
Math.random() * 1400 + 100,
Math.random() * 1000 + 100, "rect");
enemy.speed = 60;
display.add(enemy);
enemies.push(enemy);
}
// ===== UI =====
const healthBarBg = new Component(220, 25, "#440000", 20, 50, "rect");
const healthBarFill = new Component(200, 20, "#00ff44", 30, 52, "rect");
display.add(healthBarBg);
display.add(healthBarFill);
const scoreText = new Tctxt("24px", "Arial", "#ffffff", 20, 100, "left", false, "hanging", "rgba(0,0,0,0.5)", 10, 5);
scoreText.setText("Score: 0");
display.add(scoreText);
let hhh = [healthBarBg,healthBarFill, scoreText]
// ===== GAME STATE =====
let score = 0;
let health = 100;
let invincibleTimer = 0;
let gameActive = true;
// ===== CUSTOM KNOCKBACK =====
function applyKnockback(target, fromX, fromY, strength = 150) {
const angle = Math.atan2(target.y - fromY, target.x - fromX);
target.speedX = Math.cos(angle) * strength;
target.speedY = Math.sin(angle) * strength;
target.physics = true;
setTimeout(() => {
target.speedX = 0;
target.speedY = 0;
target.physics = false;
}, 200);
}
// ===== UPDATE FUNCTION =====
function update(dt) {
if (!gameActive) return;
display.camera.follow(player,true);
healthBarBg.fixed()
healthBarFill.fixed()
scoreText.fixed()
particles.update();
// Invincibility frames
if (invincibleTimer > 0) {
invincibleTimer -= dt;
player.alpha = 0.5;
} else {
player.alpha = 1;
}
// Player movement
let mx = 0, my = 0;
if (display.keys[37]) mx = -1;
if (display.keys[39]) mx = 1;
if (display.keys[38]) my = -1;
if (display.keys[40]) my = 1;
if (mx !== 0 && my !== 0) {
mx *= 0.707;
my *= 0.707;
}
player.x += mx * 200 * dt;
player.y += my * 200 * dt;
// Wall collision
if (tilemap.crashWith(player, 1)) {
if (mx > 0) player.x -= 10;
if (mx < 0) player.x += 10;
if (my > 0) player.y -= 10;
if (my < 0) player.y += 10;
}
// Coin collection
const coins = tilemap.tiles(2);
for (let coin of coins) {
if (player.crashWith(coin)) {
tilemap.remove(coin.tx, coin.ty);
//tilemap.show()
//fake.refresh()
score += 10;
scoreText.setText("Score: " + score);
move.particles.sparkle(particles, coin.x + coin.width/2, coin.y + coin.height/2);
display.camera.shake(2, 2);
}
}
// Enemy AI
for (let i = 0; i < enemies.length; i++) {
const enemy = enemies[i];
// Chase player
if (enemy.x < player.x) enemy.x += enemy.speed * dt;
if (enemy.x > player.x) enemy.x -= enemy.speed * dt;
if (enemy.y < player.y) enemy.y += enemy.speed * dt;
if (enemy.y > player.y) enemy.y -= enemy.speed * dt;
// Enemy collision with player
if (player.crashWith(enemy) && invincibleTimer <= 0) {
health -= 15;
const healthPercent = Math.max(0, health / 100);
healthBarFill.width = 200 * healthPercent;
applyKnockback(player, enemy.x, enemy.y, 200);
display.camera.shake(6, 6);
move.particles.blood(particles, player.x, player.y, 10);
invincibleTimer = 1.0;
if (health <= 0) {
gameActive = false;
const gameOverText = new Tctxt("48px", "Arial", "#ff0000", 400+display.camera.x, 300+display.camera.y, "center");
gameOverText.setText("GAME OVER - Score: " + score);
display.add(gameOverText);
}
}
// Click to defeat enemy
if (mouse.down && enemy.clicked()) {
console.log("uygiik")
move.particles.explosion(particles, enemy.x, enemy.y, 20);
score += 50;
scoreText.setText("Score: " + score);
display.camera.shake(4, 4);
enemy.hide();
enemies.splice(i, 1);
i--;
}
}
// Boundaries
player.x = Math.max(0, Math.min(1560, player.x));
player.y = Math.max(0, Math.min(1160, player.y));
}
</script>
</body>
</html>
📝 How to Use This Code
- Save the code as test11.html
- Make sure epic.js (the Limn Engine) is in the same folder
- Open test11.html in your browser
- Play the game! Use arrow keys to move, click on enemies to defeat them
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 |
If anything is still unclear, just ask – I'm happy to explain any part of the code in more detail!
Top comments (0)