🎮 How I Built a Platformer Game with Limn Engine — And the Arcade That Came After
A step-by-step guide to building a complete platformer, plus an introduction to the Limn Arcade where anyone can publish and play games.
🎯 Live Demo
Play the finished game right now:
👉 limn-engine-doc.vercel.app/arcade/game.html?slug=platform-66ek
Arrow keys or WASD to move. Space, W, or the Up arrow to jump. Collect all 5 coins to win.
Build your own version in Limn Studio:
👉 limn-engine-doc.vercel.app/editor
Browse the arcade and see what others have made:
👉 limn-engine-doc.vercel.app/arcade
📖 Introduction
Platformers are one of the best ways to learn game development. They teach you gravity, jumping, collision detection, camera following, and game state — all in one project.
They're also one of the hardest things to get right when you're starting out. Most tutorials either go too fast (throwing hundreds of lines of code at you) or too slow (spending 20 minutes on "what is a variable").
This tutorial is different.
We're going to build a complete, playable platformer using Limn Engine — a lightweight 2D game engine designed for beginners. The player moves left and right, jumps on platforms, collects coins, and wins when all coins are collected.
By the end, you'll have a game you can publish to the Limn Arcade — a community platform where anyone can share and play games made with Limn Engine.
Here's what we'll cover:
- Setting up the game world and camera
- Creating the player, ground, platforms, and coins
- Adding movement and jumping with deltaTime
- Detecting collisions with rectangles
- Writing the win condition and score display
- Adding on-screen buttons for mobile
- Publishing to the arcade
🚀 Step 1: Download Limn Engine
Before writing any code, we need the engine.
- Go to the Limn Engine documentation website: limn-engine-doc.vercel.app
- Click the download button to get
epic.js— this is the entire engine in a single file - Place
epic.jsin the same folder as your HTML file
That's it. No npm install, no build tools, no configuration.
If you don't want to download anything, you can also use Limn Studio — the browser-based editor built by Desire — which includes the engine already. We'll mention that at the end.
🛠️ Step 2: Create the HTML File
Create a new file called platformer.html and add the following structure:
<!DOCTYPE html>
<html>
<head>
<title>Platformer</title>
<script src="epic.js"></script>
</head>
<body>
<script>
// All the game code will go here
</script>
</body>
</html>
What we just did: We created a basic HTML page that loads epic.js. The <script> block at the bottom is where all our game code will live. Nothing renders yet because we haven't created a Display.
🎨 Step 3: Set Up the Display and Camera
What we're going to do: Create the game window, enable the high-performance rendering mode, and tell the camera how big the world is.
const display = new Display();
display.perform();
display.start(500, 300);
display.backgroundColor('#00ffff');
display.camera.worldWidth = 800;
display.camera.worldHeight = 600;
What we just did:
-
new Display()— creates the engine instance. It must be nameddisplaybecause the engine references that variable internally. -
display.perform()— switches the render loop torequestAnimationFramefor smooth 60fps and accurate deltaTime. Without this, movement will feel inconsistent. -
display.start(500, 300)— creates a 500×300 canvas. This is the viewport — the visible window into the game world. -
display.backgroundColor('#00ffff')— sets the canvas background to a light cyan colour. -
display.camera.worldWidth = 800andworldHeight = 600— tells the camera the game world is 800×600 pixels. The camera can scroll within this space to follow the player, but it will never show anything outside these bounds.
The key thing to understand here is the difference between the canvas (500×300, what you see) and the world (800×600, where everything actually lives). This is what makes platformers feel big even on a small screen.
🧱 Step 4: Create the Player, Ground, and Platforms
What we're going to do: Add every static object in the game — the player, the ground, and three floating platforms.
// Player
const player = new Component(40, 40, '#ff0000', 100, 400, 'rect');
display.add(player);
// Ground
const ground = new Component(800, 60, '#00ff00', 0, 540, 'rect');
display.add(ground);
// Platforms
const platforms = [
new Component(150, 20, '#00ff00', 200, 460, 'rect'),
new Component(150, 20, '#00ff00', 450, 380, 'rect'),
new Component(150, 20, '#00ff00', 650, 300, 'rect')
];
platforms.forEach(function(p) { display.add(p); });
What we just did:
- Created a 40×40 red square as the player, at world position (100, 400).
- Created a wide 800×60 green rectangle as the ground, at (0, 540). Because the ground spans the entire width of the world, the player can never fall past the sides.
- Created three platforms, each 150×20, at different heights and positions. The player will need to jump on these to reach the coins.
- Pushed each platform into the
platformsarray so we can loop through them later during collision checks.
Important: Every component must be added with display.add(). Without it, the component exists in memory but never appears on screen or gets updated.
🪙 Step 5: Add the Coins
What we're going to do: Create 5 coins at specific positions in the world.
const coinPositions = [
{x: 633, y: 380},
{x: 594, y: 300},
{x: 676, y: 220},
{x: 566, y: 380},
{x: 445, y: 300}
];
const coins = coinPositions.map(function(c) {
const coin = new Component(24, 24, '#ffff00', c.x, c.y, 'rect');
display.add(coin);
return coin;
});
What we just did:
- Created an array of 5 positions, each with an x and y coordinate. These positions are placed strategically so that the player has to jump across platforms to reach them.
- Used
.map()to loop through the positions and create a new 24×24 yellow coin at each one. - Stored each coin in the
coinsarray so we can later check collision with them, remove them when collected, and detect when the last coin has been picked up.
Why map() instead of a for loop? Because map() returns a new array automatically — so we get both the creation and the array storage in one step. It's a common pattern in JavaScript game development.
📊 Step 6: Add the Score UI
What we're going to do: Create a score display in the top-left corner of the screen.
const score = new Tctxt(
'20px', 'Arial', 'white',
20, 40,
'left', false, 'top',
'rgba(0,0,0,0.5)', 10, 4
);
score.setText('Score: 0');
display.add(score);
What we just did:
-
Tctxtis Limn Engine's rich text component. The parameters are, in order: size, font, colour, x, y, alignment, stroke mode, baseline, background, paddingX, paddingY. - We set the position to (20, 40) so the text appears near the top-left corner.
- The background is
rgba(0,0,0,0.5)— a semi-transparent black so the text stays readable over the cyan canvas. - The padding of 10 horizontal and 4 vertical pixels gives the background some breathing room around the text.
-
setText('Score: 0')sets the initial text.
We'll update this text later when coins are collected.
🎮 Step 7: Add On-Screen Buttons for Mobile
What we're going to do: Create touch-friendly buttons so the game is playable on phones — not just desktops.
const BTN = 60;
const BTN_Y = 200;
function mkBtn(x, color) {
return new Component(BTN, BTN, color, x, BTN_Y, 'rect');
}
const btnL = mkBtn(20, '#1a1a2e');
const btnR = mkBtn(100, '#1a1a2e');
const btnJ = mkBtn(380, '#2563eb');
const lblL = new Tctxt('36px','Arial','white',50,240,'center',false,'middle');
const lblR = new Tctxt('36px','Arial','white',130,240,'center',false,'middle');
const lblJ = new Tctxt('16px','Arial','white',410,240,'center',false,'middle');
lblL.setText('◀');
lblR.setText('▶');
lblJ.setText('JUMP');
display.add(btnL);
display.add(btnR);
display.add(btnJ);
display.add(lblL);
display.add(lblR);
display.add(lblJ);
const btnList = [btnL, btnR, btnJ, lblL, lblR, lblJ];
What we just did:
- Created a helper function
mkBtn()that returns a new component with the same size and y-position but a different x and colour. This saves us writing the samenew Component()call three times. - Created three buttons: left arrow, right arrow, and a blue jump button.
- Created three text labels on top of the buttons — "◀", "▶", and "JUMP".
- Added all six elements to the display.
- Stored everything in a
btnListarray — but only the buttons (not the labels), because we'll only be testing button hits in the click detection.
Why this matters: Without these, the game would be unplayable on phones. A platformer that works with both keyboard and touch is far more useful than one that only works on desktop.
🏗️ Step 8: Set Up Game State and Helper Functions
What we're going to do: Declare the variables that track the game state, and write a helper function for rectangle collision.
let vy = 0;
let onGround = false;
let points = 0;
let won = false;
function rectHit(a, b) {
return a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y;
}
function inBtn(b, x, y) {
return x >= b.x && x <= b.x + b.width &&
y >= b.y && y <= b.y + b.height;
}
What we just did:
-
vy— vertical velocity. Positive = falling, negative = rising. We'll add gravity to this every frame. -
onGround— a boolean that'struewhen the player is touching the ground or a platform. We need this so the player can only jump when standing on something. -
points— the player's score, starting at 0. -
won— a boolean that becomestruewhen all coins are collected, so we can stop the game logic. -
rectHit(a, b)— the classic AABB (Axis-Aligned Bounding Box) collision test. It returnstrueif rectangleaoverlaps rectangleb. Four comparisons — one for each edge. -
inBtn(b, x, y)— a simpler check: is the point (x, y) inside buttonb? This is used for touch input.
🎬 Step 9: Write the Game Loop
What we're going to do: Write the update(dt) function that runs every frame — this is where all the game logic lives.
Here's the full function, broken into pieces so you can see what each part does.
9a. Camera and fixed UI
function update(dt) {
display.camera.follow(player, true);
btnList.forEach(function(e) { e.fixed(); });
score.fixed();
if (won) return;
What we just did:
-
display.camera.follow(player, true)— the camera smoothly follows the player. Thetrueenables smoothing so the camera lerps toward the player rather than snapping. -
btnList.forEach(...)— calls.fixed()on every button every frame. This keeps them locked to the screen even as the camera scrolls. -
score.fixed()— same for the score display. -
if (won) return;— if the player has won, stop running game logic. The camera and UI still update, but nothing else happens.
Important: .fixed() must be called every frame, not just once. It recalculates the component's position based on the current camera offset.
9b. Touch detection
const mx = display.x;
const my = display.y;
const touchingL = mx !== false && inBtn(btnL, mx, my);
const touchingR = mx !== false && inBtn(btnR, mx, my);
const touchingJ = mx !== false && inBtn(btnJ, mx, my);
What we just did:
-
display.xanddisplay.yhold the current mouse or finger position while pressed, andfalsewhen released. - For each button, we check two things: is the pointer pressed (not
false), and is the pointer inside the button's rectangle. - The result is a
true/falsevalue for each button — we'll use these in the movement code next.
9c. Horizontal movement
const sp = 280;
if (display.keys[37] || display.keys[65] || touchingL) player.x -= sp * dt;
if (display.keys[39] || display.keys[68] || touchingR) player.x += sp * dt;
What we just did:
-
spis the player's horizontal speed, in pixels per second. - The player moves left if the left arrow (37) or A (65) is pressed, or if the left button is being touched.
- The player moves right if the right arrow (39) or D (68) is pressed, or if the right button is being touched.
- Multiplying by
dtmeans movement is frame-rate independent — the player will move at the same real-world speed on a 30fps device and a 144fps device.
9d. Jumping
const jf = 15;
const keyJump = display.keys[38] || display.keys[32] || display.keys[87];
if ((keyJump || touchingJ) && onGround) {
vy = -jf;
onGround = false;
}
What we just did:
-
jfis the jump force — how much vertical velocity we apply when the player jumps. - The player jumps if the Up arrow (38), Space (32), or W (87) is pressed, or if the jump button is being touched.
- The jump only happens if
onGroundistrue. This prevents mid-air jumping. - Setting
vy = -jfgives the player upward velocity. It's negative because in canvas coordinates, up is negative and down is positive. - We immediately set
onGround = falseso the player can't jump again until they land.
9e. Gravity and ground collision
vy += 40 * dt;
player.y += vy;
onGround = false;
if (rectHit(player, ground)) {
player.y = ground.y - player.height;
vy = 0;
onGround = true;
}
What we just did:
-
vy += 40 * dt— apply gravity. Every frame, the player's vertical velocity increases by 40 pixels per second squared, multiplied bydt. -
player.y += vy— move the player by their vertical velocity. -
onGround = false— reset the ground flag. It will be set back totrueif we detect a collision with the ground or a platform below. - If the player's rectangle overlaps the ground rectangle, we snap the player to the top of the ground (
ground.y - player.height), zero out the velocity, and setonGround = true.
The snapping is important — without it, the player would sink slightly into the ground each frame, accumulating errors over time.
9f. Platform collision
for (const p of platforms) {
if (rectHit(player, p) && player.y + player.height - vy <= p.y + 4) {
player.y = p.y - player.height;
vy = 0;
onGround = true;
}
}
What we just did:
- Loop through every platform.
- Check if the player overlaps the platform.
-
Also check that the player was above the platform last frame — this is the
player.y + player.height - vy <= p.y + 4part. Without this second check, the player would "stick" to the underside of platforms when jumping up through them. - If both conditions are true, snap the player to the top of the platform, zero out the velocity, and set
onGround = true.
This is the trickiest part of platformer physics — making sure the player can jump onto platforms from below but can't pass through them from above.
9g. Horizontal bounds
if (player.x < 0) player.x = 0;
if (player.x + player.width > 800) player.x = 800 - player.width;
What we just did: Prevent the player from walking off the left or right edges of the world. These two lines do the same job as move.boundTo(), but written explicitly because we only want to clamp horizontally — vertical movement is handled by gravity.
9h. Coin collection
for (let i = coins.length - 1; i >= 0; i--) {
if (rectHit(player, coins[i])) {
coins[i].destroy();
coins.splice(i, 1);
points += 10;
score.setText('Score: ' + points);
}
}
What we just did:
- Loop through the coins backwards — from the last one to the first. This is important because we're removing items from the array as we go. If we looped forwards, removing an item would shift all the following items down by one index, causing us to skip the next coin.
- If the player overlaps a coin, call
coin.destroy()to remove it from the engine, andcoins.splice(i, 1)to remove it from the array. - Add 10 points to the score and update the display text.
9i. Win condition
if (coins.length === 0 && !won) {
won = true;
score.setText('You win! Score: ' + points);
}
}
What we just did: If there are no more coins, and the player hasn't already won, set won = true and update the score display to show the win message. The game loop will return early from now on, so nothing else happens.
✅ Step 10: The Complete Code
Here's the entire game in one file. Copy it into platformer.html and open it in your browser.
<!DOCTYPE html>
<html>
<head>
<title>Platformer</title>
<script src="epic.js"></script>
</head>
<body>
<script>
// ── SETUP ──
const display = new Display();
display.perform();
display.start(500, 300);
display.backgroundColor('#00ffff');
// ── CAMERA ──
display.camera.worldWidth = 800;
display.camera.worldHeight = 600;
// ── PLAYER ──
const player = new Component(40, 40, '#ff0000', 100, 400, 'rect');
display.add(player);
// ── GROUND ──
const ground = new Component(800, 60, '#00ff00', 0, 540, 'rect');
display.add(ground);
// ── PLATFORMS ──
const platforms = [
new Component(150, 20, '#00ff00', 200, 460, 'rect'),
new Component(150, 20, '#00ff00', 450, 380, 'rect'),
new Component(150, 20, '#00ff00', 650, 300, 'rect')
];
platforms.forEach(function(p) { display.add(p); });
// ── COINS ──
const coinPositions = [
{x: 633, y: 380},
{x: 594, y: 300},
{x: 676, y: 220},
{x: 566, y: 380},
{x: 445, y: 300}
];
const coins = coinPositions.map(function(c) {
const coin = new Component(24, 24, '#ffff00', c.x, c.y, 'rect');
display.add(coin);
return coin;
});
// ── SCORE UI ──
const score = new Tctxt('20px','Arial','white',20,40,'left',false,'top','rgba(0,0,0,0.5)',10,4);
score.setText('Score: 0');
display.add(score);
// ── ON-SCREEN BUTTONS ──
const BTN = 60;
const BTN_Y = 200;
function mkBtn(x, color) {
return new Component(BTN, BTN, color, x, BTN_Y, 'rect');
}
const btnL = mkBtn(20, '#1a1a2e');
const btnR = mkBtn(100, '#1a1a2e');
const btnJ = mkBtn(380, '#2563eb');
const lblL = new Tctxt('36px','Arial','white',50,240,'center',false,'middle');
const lblR = new Tctxt('36px','Arial','white',130,240,'center',false,'middle');
const lblJ = new Tctxt('16px','Arial','white',410,240,'center',false,'middle');
lblL.setText('◀');
lblR.setText('▶');
lblJ.setText('JUMP');
display.add(btnL);
display.add(btnR);
display.add(btnJ);
display.add(lblL);
display.add(lblR);
display.add(lblJ);
const btnList = [btnL, btnR, btnJ, lblL, lblR, lblJ];
// ── HELPERS ──
function inBtn(b, x, y) {
return x >= b.x && x <= b.x + b.width &&
y >= b.y && y <= b.y + b.height;
}
function rectHit(a, b) {
return a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y;
}
// ── GAME STATE ──
let vy = 0;
let onGround = false;
let points = 0;
let won = false;
// ── GAME LOOP ──
function update(dt) {
display.camera.follow(player, true);
btnList.forEach(function(e) { e.fixed(); });
score.fixed();
if (won) return;
const sp = 280;
const jf = 15;
// Touch detection
const mx = display.x;
const my = display.y;
const touchingL = mx !== false && inBtn(btnL, mx, my);
const touchingR = mx !== false && inBtn(btnR, mx, my);
const touchingJ = mx !== false && inBtn(btnJ, mx, my);
// Horizontal movement
if (display.keys[37] || display.keys[65] || touchingL) player.x -= sp * dt;
if (display.keys[39] || display.keys[68] || touchingR) player.x += sp * dt;
// Jump
const keyJump = display.keys[38] || display.keys[32] || display.keys[87];
if ((keyJump || touchingJ) && onGround) {
vy = -jf;
onGround = false;
}
// Gravity
vy += 40 * dt;
player.y += vy;
onGround = false;
// Ground collision
if (rectHit(player, ground)) {
player.y = ground.y - player.height;
vy = 0;
onGround = true;
}
// Platform collision
for (const p of platforms) {
if (rectHit(player, p) && player.y + player.height - vy <= p.y + 4) {
player.y = p.y - player.height;
vy = 0;
onGround = true;
}
}
// Horizontal bounds
if (player.x < 0) player.x = 0;
if (player.x + player.width > 800) player.x = 800 - player.width;
// Coin collection
for (let i = coins.length - 1; i >= 0; i--) {
if (rectHit(player, coins[i])) {
coins[i].destroy();
coins.splice(i, 1);
points += 10;
score.setText('Score: ' + points);
}
}
// Win condition
if (coins.length === 0 && !won) {
won = true;
score.setText('You win! Score: ' + points);
}
}
</script>
</body>
</html>
📊 What You've Learned
| Concept | Why It Matters |
|---|---|
| World vs canvas | The canvas is the viewport; the world is bigger and the camera scrolls within it |
Delta time (dt) |
Multiplies movement so speed is the same on every device |
| Gravity | A velocity that increases every frame and pulls the player down |
| Jump impulse | Setting vy to a negative value for one frame to launch upward |
| AABB collision | The rectangle overlap test that powers every collision in the game |
| One-way platforms | The extra player.y + player.height - vy <= p.y + 4 check allows jumping up through platforms |
| Reverse loops | Required whenever you remove items from an array during iteration |
.fixed() |
Locks a component to the screen so it doesn't scroll with the camera |
| Touch + keyboard | The same logic handles both, so the game is playable anywhere |
🕹️ Introducing the Limn Arcade
You've just built a complete platformer. Now here's the part that makes it more than a local project: you can publish it to the Limn Arcade.
The arcade is a community platform where anyone can upload, browse, and play games made with Limn Engine. It's built and maintained by Desire — the same developer behind Limn Studio.
What the Arcade Includes
| Feature | What It Does |
|---|---|
| Game Gallery | Browse every published game, with search and filters |
| Game of the Week | A rotating spotlight on the best new game |
| Leaderboard | Weekly rankings of top games based on likes and comments |
| Likes and Comments | Community feedback on every game |
| Creator Profiles | See every game a developer has made |
| My Games | Manage your published games from one dashboard |
| Inbox | Stats, updates, and notifications |
| Discord Integration | Direct link to the community hub |
Why This Matters for Beginners
The arcade lowers every barrier that normally stops people from finishing a game:
- No hosting required — you upload, the arcade serves it
- No distribution required — the arcade has an audience
- No marketing required — the leaderboard gives your game visibility
- Instant feedback — comments and likes tell you what's working
That last point is the most important one. In most game dev projects, you finish a game and never find out if anyone played it. On the arcade, you find out the same day.
How to Publish Your Game
- Sign in to the arcade at limn-engine-doc.vercel.app/arcade
- Click + Make a game
- Paste your game code — either the code from this tutorial, or something you built in Limn Studio
- Click share.
Your game appears in the arcade immediately, and anyone can play it, like it, and leave a comment.
🛠️ Or Build It in Limn Studio Instead
If you don't want to write all this code by hand, Limn Studio has a Platformer template that gives you a working starting point.
- Go to limn-engine-doc.vercel.app/editor
- Open the Build tab
- Select Platformer from the template dropdown
- Customise the game using the form fields
- Switch to the Code tab to see (and edit) the generated Limn Engine code
- Click Run to test
- Publish directly to the arcade
Limn Studio also includes templates for Dress-Up, Runner, Shooter, and Clicker — so once you've got the platformer working, you can branch into other genres without starting from scratch.
🐛 Found a Bug? Report It
If something doesn't work, you can report it on GitHub:
👉 github.com/terracodes004/limn-engine-doc/issues
Please include:
- A description of the problem
- Steps to reproduce it
- What you expected vs. what actually happened
- Your browser and device
🚀 What's Next?
Now that you've built a platformer and published it to the arcade, try:
- Adding enemies — patrolling obstacles that reset the player on contact
- Adding lives — three lives before game over
- Adding a timer — complete the level before the clock runs out
- Adding moving platforms — platforms that travel left and right
-
Adding sound — jump, collect, and win sound effects with the
Soundclass - Building a second level — harder platforms, more coins, new obstacles
Then publish it. The arcade is waiting.
🔗 Resources
| Resource | Link |
|---|---|
| Limn Engine Docs | limn-engine-doc.vercel.app |
| Limn Studio (Editor) | limn-engine-doc.vercel.app/editor |
| Limn Arcade | limn-engine-doc.vercel.app/arcade |
| Complete API Reference | limn-engine-doc.vercel.app/reference.html |
| Beginner Guide | limn-engine-doc.vercel.app/beginner.html |
| 10x Developer Guide | limn-engine-doc.vercel.app/10x.html |
| GitHub Repository | github.com/terracodes004/limn-engine-doc |
| Report Bugs | github.com/terracodes004/limn-engine-doc/issues |
| Desire on DEV.to | dev.to/desire_george_434_ai |
🎯 The One-Line Summary
"Build a complete platformer with Limn Engine in about 150 lines — then publish it to the Limn Arcade and let the world play it." 🎮🚀
Draw your game into existence — one platform at a time. 🎮🚀


Top comments (1)
Clap am for jesus