DEV Community

Cover image for 🎮 Neon Caverns — Source Code Deep Dive
Kehinde Owolabi
Kehinde Owolabi

Posted on

🎮 Neon Caverns — Source Code Deep Dive

🎮 Neon Caverns — Source Code Deep Dive

Every class, every system, every design decision — explained line by line.


🎯 Live Demo

Play Neon Caverns right now:

👉 limn-engine-doc.vercel.app/arcade/game.html?slug=noen-caverns-thc5

Source code:

👉 github.com/terracodes004/limn-engine-doc

Follow Kehinde on DEV.to for updates:

👉 dev.to/kehinde_owolabi_e2e54567a


📖 Introduction

Neon Caverns is a 2D platformer built with Limn Engine. It has three levels, enemies with patrol-and-chase AI, a boss fight, moving platforms, coins, hearts, a full menu system, pause screen, settings screen, and level select — all in a single JavaScript file.

It's a good example of what a complete Limn Engine game looks like once you move past tutorials. This article walks through the source code to show how each piece is built and why.

In this article, we'll cover the scene system, the custom class hierarchy, the tile collision resolution, the enemy AI, the boss fight, the moving platforms, and the UI. By the end, you'll understand how a full platformer is put together in Limn Engine.

🏗️ The Big Picture

Before diving into the code, let's look at the overall structure of the game. It's organised into five layers:

Layer 1 — Scene constants. Seven numbers that identify each game screen: menu, game, over, win, pause, settings, and level select.

Layer 2 — Global state. Variables like player, enemies, coins, and score that hold the current state of the running game.

Layer 3 — Custom classes. Three classes that extend Component: PatrolEnemy, MovingPlatform, and BossEnemy. Each adds its own behaviour.

Layer 4 — Scene builders. Functions that create the components for each scene: buildMenuScene, buildGameScene, buildPauseScene, and so on.

Layer 5 — Scene updates. One update function per scene — updateMenu, updateGame, updatePause, etc. — dispatched from a single update(dt) entry point.

The main loop calls update(dt), which reads display.scene and forwards to the right scene update function. That's the whole flow.


🎬 The Scene System

What we're going to do: Set up seven scene constants so every screen in the game has its own number.

const SCENE_MENU     = 0;
const SCENE_GAME     = 1;
const SCENE_OVER     = 2;
const SCENE_WIN      = 3;
const SCENE_PAUSE    = 4;
const SCENE_SETTINGS = 5;
const SCENE_LEVEL    = 6;
let currentScene = SCENE_MENU;
let settingsReturnScene = SCENE_MENU;
let selectedLevel = 0;
let sceneEnterTime = 0;
Enter fullscreen mode Exit fullscreen mode

What we just did: We gave each screen its own numeric ID. When we call display.add(component, sceneNumber), the engine only draws and updates that component when display.scene matches. This is how we build seven different screens without ever destroying and recreating objects — every component exists in memory at all times, but only the active scene's components are visible and interactive.

We also track settingsReturnScene so that when the player opens settings from either the menu or the pause screen, we know where to send them back.


🚪 Switching Scenes

What we're going to do: Write a function that changes the active scene, clears input state, and resets the camera.

function goToScene(n) {
    currentScene = n;
    display.scene = n;
    sceneEnterTime = Date.now();
    clearHitAreas();

    if (n === SCENE_GAME) {
        fake.tileFace.show();
        display.once = true;
        display.camera.x = 0;
        display.camera.y = 0;
    } else if (n === SCENE_PAUSE) {
        display.once = false;
        display.camera.x = 0;
        display.camera.y = 0;
    } else {
        fake.context.clearRect(0, 0, fake.canvas.width, fake.canvas.height);
        display.once = false;
        display.camera.x = 0;
        display.camera.y = 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

What we just did: When we switch scenes, we reset the camera to (0,0) so every menu starts from a consistent position, and we clear the hit areas so no button from the previous scene accidentally fires. When entering the game scene, we tell the engine to redraw the fake canvas (which holds the level's static tiles) and we set display.once = true to force a one-time cache refresh.

sceneEnterTime is a timestamp we use later to ignore taps for a brief moment after a scene change — otherwise a click that starts the game could immediately trigger a button on the next screen.


🖱️ The Input System

What we're going to do: Build a button registry that tracks every clickable rectangle in every scene.

const interactables = {};

function makeButton(scene, id, x, y, w, h, label, opts) {
    opts = opts || {};
    const baseColor = opts.color || "rgba(124,58,237,0.7)";
    const hoverColor = opts.hoverColor || "rgba(124,58,237,1)";

    const btn = new Component(w, h, baseColor, x, y, "rect");
    btn.changeAngle = false;
    btn.move = function () {};
    display.add(btn, scene);

    let lbl = null;
    if (label) {
        lbl = new Tctxt(opts.fontSize || "22px", "Arial", opts.textColor || "white",
                        x + w / 2, y + h / 2, "center", false, "middle", "transparent");
        lbl.setText(label);
        display.add(lbl, scene);
    }

    interactables[id] = { x, y, w, h, active: false, scene, btn, lbl, baseColor, hoverColor };
    return btn;
}
Enter fullscreen mode Exit fullscreen mode

What we just did: We built a helper that creates a button component, an optional text label, and an entry in the interactables registry. Every button in the game is created through this function, which means every button has a matching hit rectangle in interactables. That makes click detection uniform — one loop over the registry handles every button on the current scene.

We also disable changeAngle and override move on each button so it doesn't rotate or drift — buttons are static.


📐 Converting Touch and Click Coordinates

What we're going to do: Write a function that converts a browser event into canvas coordinates, accounting for CSS scaling.

function getCanvasCoords(clientX, clientY) {
    const canvas = display.canvas;
    const rect = canvas.getBoundingClientRect();
    const sx = canvas.width / rect.width;
    const sy = canvas.height / rect.height;
    return { x: (clientX - rect.left) * sx, y: (clientY - rect.top) * sy };
}
Enter fullscreen mode Exit fullscreen mode

What we just did: We built the same coordinate conversion we covered in the limn-click article. getBoundingClientRect() gives us the canvas's real position on the page, and dividing the internal canvas size by that gives us the scale factor. This is what makes buttons work on phones where the canvas is scaled to fit the screen.

Without this, taps on mobile would land in the wrong place — the coordinates would be in screen space instead of canvas space.


🖲️ Hit Detection and Input Dispatch

What we're going to do: Write functions that read every active touch or click, check it against the current scene's buttons, and mark which ones are being pressed.

function updateHitAreas(e) {
    if (Date.now() - sceneEnterTime < 250) {
        clearHitAreas();
        return;
    }

    for (const key in interactables) interactables[key].active = false;

    const list = e.touches
        ? Array.from(e.touches)
        : (e.clientX !== undefined ? [e] : []);

    for (const t of list) {
        const p = getCanvasCoords(t.clientX, t.clientY);
        for (const key in interactables) {
            const a = interactables[key];
            if (a.scene !== currentScene) continue;
            if (p.x >= a.x && p.x <= a.x + a.w &&
                p.y >= a.y && p.y <= a.y + a.h) {
                a.active = true;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

What we just did: Every time a mouse or touch event fires, we clear all buttons, then loop through every active pointer and check it against every button in the current scene. If the pointer is inside a button's rectangle, that button is marked active.

The 250ms guard at the top prevents taps from bleeding through from one scene to the next. Without it, a tap that starts the game could immediately register on the game's pause button.


🗺️ Level Data

What we're going to do: Define three levels as 2D arrays of tile IDs.

const LEVELS = [
    {
        name: "Caverns",
        map: [
            [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,1,1],
            [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
            // ... more rows ...
            [2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2],
        ],
        enemyCount: 7,
        bossHP: 3,
        bossX: 1750,
        bossChaseSpeed: 2.4,
    },
    // ... Towers, Boss Arena ...
];
Enter fullscreen mode Exit fullscreen mode

What we just did: Each level is an object with a map — a grid of numbers where each number is a tile type — plus metadata: how many enemies to spawn, the boss's HP, where the boss starts, and how fast the boss chases.

The tile IDs are: 0 for empty, 1 for grass (solid), 2 for stone (solid), 3 for gold (solid), 4 for spike (damaging), and 5 for brick (solid). The game distinguishes between solid tiles and spikes when it builds the tile list.


👾 Custom Enemy Class — PatrolEnemy

What we're going to do: Create a class that extends Component and gives enemies three AI states: patrol, chase, and return.

class PatrolEnemy extends Component {
    constructor(x, y, cfg) {
        super(30, 30, cfg.color || "#ff4500", x, y, "rect");
        this.physics = true;
        this.gravity = 0.7;
        this.bounce = 0;
        this.onGround = false;
        this.move = function () {};
        this.patrolLeft = cfg.patrolLeft;
        this.patrolRight = cfg.patrolRight;
        this.dir = 1;
        this.patrolSpeed = cfg.patrolSpeed || 1.5;
        this.chaseSpeed  = cfg.chaseSpeed  || 2.8;
        this.detectRangeX = cfg.detectRangeX || 260;
        this.detectRangeY = cfg.detectRangeY || 130;
        this.lostGrace = 90;
        this.lostTimer = 0;
        this.state = "patrol";
        this.baseColor = cfg.color || "#ff4500";
        this.chaseColor = "#ff0033";
        this.returnColor = "#ffdd00";
    }
    // ... think() method below ...
}
Enter fullscreen mode Exit fullscreen mode

What we just did: We subclassed Component so enemies inherit all the collision and drawing logic, then added enemy-specific fields: how far to patrol, how fast to move, how far to detect the player, and how long to chase after losing sight.

We overrode move to a no-op because we want to control the enemy's position manually — the engine's built-in physics isn't flexible enough for the collision system we're using. The physics flag and gravity value are still there so the base class knows the entity has mass.


The Enemy AI Think Method

What we're going to do: Write a think() method that runs every frame and updates the enemy's state based on the player's position.

think(player) {
    const dx = player.x - this.x, dy = player.y - this.y;
    const canSee = Math.abs(dx) < this.detectRangeX && Math.abs(dy) < this.detectRangeY;
    if (canSee) {
        this.state = "chase";
        this.lostTimer = this.lostGrace;
    } else if (this.state === "chase") {
        this.lostTimer--;
        if (this.lostTimer <= 0) this.state = "return";
    }
    if (this.state === "chase") {
        if (dx < -4) this.dir = -1;
        else if (dx > 4) this.dir = 1;
        this.speedX = this.dir * this.chaseSpeed;
        this.color = this.chaseColor;
    } else if (this.state === "return") {
        const center = (this.patrolLeft + this.patrolRight) / 2;
        if (Math.abs(this.x - center) < 12) this.state = "patrol";
        else {
            this.dir = this.x < center ? 1 : -1;
            this.speedX = this.dir * this.patrolSpeed;
        }
        this.color = this.returnColor;
    } else {
        this.speedX = this.dir * this.patrolSpeed;
        if (this.x <= this.patrolLeft) this.dir = 1;
        if (this.x + this.width >= this.patrolRight) this.dir = -1;
        this.color = this.baseColor;
    }
}
Enter fullscreen mode Exit fullscreen mode

What we just did: We built a three-state state machine.

In the patrol state, the enemy walks back and forth between two points, flipping direction when it reaches either boundary.

In the chase state, the enemy moves toward the player at a faster speed, changing direction based on which side the player is on.

In the return state, the enemy walks back to the centre of its patrol area, then transitions back to patrol.

The transitions are: patrol → chase when the player enters detection range, chase → return after losing sight for lostGrace frames, return → patrol when the enemy reaches home. Each state also sets a different colour — orange for patrol, red for chase, yellow for return — so the player can read the enemy's intent at a glance.


🎢 Custom Platform Class — MovingPlatform

What we're going to do: Create a platform that oscillates along an axis using a sine wave, and that carries the player when they're standing on it.

class MovingPlatform extends Component {
    constructor(x, y, w, h, cfg) {
        super(w, h, "#00ccff", x, y, "rect");
        this.changeAngle = false;
        this.move = function () {};
        this.startX = x;
        this.startY = y;
        this.axis = cfg.axis || "x";
        this.distance = cfg.distance || 200;
        this.speed = cfg.speed || 1.2;
        this.t = cfg.phase || 0;
        this.prevX = x;
        this.prevY = y;
    }
    step() {
        this.prevX = this.x;
        this.prevY = this.y;
        this.t += this.speed / 60;
        const offset = Math.sin(this.t) * this.distance;
        if (this.axis === "x") this.x = this.startX + offset;
        else this.y = this.startY + offset;
    }
    dx() { return this.x - this.prevX; }
    dy() { return this.y - this.prevY; }
}
Enter fullscreen mode Exit fullscreen mode

What we just did: We created a platform that uses Math.sin() to produce smooth back-and-forth motion. Each frame, step() increments the time variable t and recalculates the position based on the sine value.

Before moving, we store prevX and prevY so we can calculate the delta after the move — the dx() and dy() methods return how far the platform moved this frame. The game uses those deltas to carry the player along, which is what makes moving platforms feel solid instead of slippery.


👹 Custom Boss Class — BossEnemy

What we're going to do: Create a larger, tougher enemy with multiple HP that flashes red when hit.

class BossEnemy extends Component {
    constructor(x, y, cfg) {
        super(cfg.size || 60, cfg.size || 60, "#8b0000", x, y, "rect");
        this.physics = true;
        this.gravity = 0.7;
        this.bounce = 0;
        this.onGround = false;
        this.move = function () {};
        this.maxHP = cfg.hp || 3;
        this.hp = this.maxHP;
        this.homeX = x;
        this.patrolRange = cfg.patrolRange || 220;
        this.patrolSpeed = cfg.patrolSpeed || 1;
        this.chaseSpeed = cfg.chaseSpeed || 2.2;
        this.detectRange = cfg.detectRange || 420;
        this.dir = 1;
        this.state = "patrol";
        this.hurtFlash = 0;
    }
    think(player) {
        // ... similar to PatrolEnemy but simpler ...
    }
    takeHit() { this.hp--; this.hurtFlash = 30; return this.hp <= 0; }
}
Enter fullscreen mode Exit fullscreen mode

What we just did: The boss has the same patrol-and-chase pattern as regular enemies, but with a much larger detection range (420 pixels) and higher chase speed. It has maxHP and hp fields, and a takeHit() method that reduces HP and starts a flash.

The hurtFlash counter ticks down each frame. While it's active, the boss alternates between bright red and dark red every 10 frames, giving the player a clear visual signal that damage landed.


🏗️ Building the Game Scene

What we're going to do: Write buildGameScene() — the function that creates every component for a level.

function buildGameScene(levelIndex) {
    const level = LEVELS[levelIndex];

    fake.tile = [
        new Component(TILE, TILE, "#39ff14", 0, 0),
        new Component(TILE, TILE, "#7c3aed", 0, 0),
        new Component(TILE, TILE, "#ffdd00", 0, 0),
        new Component(TILE, TILE, "#ff0033", 0, 0),
        new Component(TILE, TILE, "#5c4a72", 0, 0),
    ];

    fake.map = level.map;
    fake.tileMap();
    fake.tileFace.show();
    solidTiles = fake.tileFace.tileList.filter(t => t.tid !== 4);
    spikeTiles = fake.tileFace.tileList.filter(t => t.tid === 4);

    // ... create player, enemies, platforms, boss, coins, HUD ...
}
Enter fullscreen mode Exit fullscreen mode

What we just did: We rebuild fake.tile from scratch every time the scene is created. This is important — the engine's TileMap constructor calls tile.unshift(0), which mutates the array in place. Without recreating it, zeroes would stack up at the front and break the tile lookup after a restart.

We then set fake.map to the level's map, call tileMap() to build it, and call tileFace.show() to render the tiles. After that, we split the tile list into two arrays — solidTiles (everything that isn't a spike) and spikeTiles (tile ID 4). The game uses these arrays during collision resolution.


Creating the Player

What we're going to do: Spawn the player with physics enabled and custom movement.

player = new Component(28, 40, "#ff0080", 120, 500, "rect");
player.physics = true;
player.gravity = 0.7;
player.bounce = 0;
player.onGround = false;
player.onPlatform = null;
player.move = function () {};
display.add(player, SCENE_GAME);
Enter fullscreen mode Exit fullscreen mode

What we just did: We created a 28x40 pink rectangle as the player. We set physics = true and gravity = 0.7 so the entity knows it's affected by gravity, but we override move to a no-op because the game's custom collision system updates position directly.

We also added an onPlatform field that will hold a reference to whichever moving platform the player is currently standing on, so the platform's motion can be applied to the player each frame.


Creating the Enemies

What we're going to do: Spawn the level's enemies at hand-tuned positions with patrol ranges.

enemies = [];
const basePositions = [
    { x: 380,  patrolLeft: 330,  patrolRight: 500,  patrolSpeed: 1.2, chaseSpeed: 2.2, detectRangeX: 220 },
    { x: 600,  patrolLeft: 520,  patrolRight: 740,  patrolSpeed: 1.8, chaseSpeed: 3.0, detectRangeX: 260 },
    // ... more positions ...
];
for (let i = 0; i < Math.min(level.enemyCount, basePositions.length); i++) {
    const cfg = basePositions[i];
    const e = new PatrolEnemy(cfg.x, 400, cfg);
    display.add(e, SCENE_GAME);
    enemies.push(e);
}
Enter fullscreen mode Exit fullscreen mode

What we just did: We defined seven enemy positions with different patrol ranges, speeds, and detection distances. Each level spawns up to level.enemyCount of them.

Rather than randomising enemy placement, we hand-tuned these positions so each level has a specific rhythm — early enemies are slow and short-sighted, late enemies are fast and aggressive.


Creating the Platforms

What we're going to do: Spawn four moving platforms with different axes, distances, and speeds.

platforms = [];
[
    { x: 480,  y: 480, w: 120, h: 20, axis: "y", distance: 80,  speed: 1.0 },
    { x: 780,  y: 380, w: 120, h: 20, axis: "x", distance: 150, speed: 1.2 },
    { x: 1250, y: 480, w: 130, h: 20, axis: "y", distance: 100, speed: 1.4 },
    { x: 1560, y: 400, w: 130, h: 20, axis: "x", distance: 180, speed: 1.0 },
].forEach(cfg => {
    const p = new MovingPlatform(cfg.x, cfg.y, cfg.w, cfg.h, cfg);
    display.add(p, SCENE_GAME);
    platforms.push(p);
});
Enter fullscreen mode Exit fullscreen mode

What we just did: We placed four platforms at key traversal points — two that move vertically and two horizontally, at different speeds and distances. Together they create rhythm in the level: the player has to time their jumps to match the platform's cycle.


Creating the Boss

What we're going to do: Spawn the boss at the end of the level with HP and speed from the level's config.

boss = new BossEnemy(level.bossX, 400, {
    size: 70, hp: level.bossHP, patrolRange: 200,
    patrolSpeed: 1.2, chaseSpeed: level.bossChaseSpeed, detectRange: 450
});
display.add(boss, SCENE_GAME);
Enter fullscreen mode Exit fullscreen mode

What we just did: We created a 70-pixel boss with the level's HP and chase speed. Level 1 has 3 HP and a slow boss; level 3 has 6 HP and a fast boss. The boss's patrol range is 200 pixels, and it detects the player from 450 pixels away.


Creating the Coins

What we're going to do: Place 18 coins at hand-tuned positions that reward exploration.

coins = [];
[
    [160,620],[220,600],[300,620],[420,620],[480,600],
    [620,470],[680,450],[740,470],
    [900,620],[980,600],[1040,620],
    [1230,470],[1290,450],[1350,470],
    [1450,620],[1540,600],
    [1750,470],[1810,450],
].forEach(p => {
    const c = new Component(16, 16, "#ffdd00", p[0], p[1], "rect");
    display.add(c, SCENE_GAME);
    coins.push(c);
});
Enter fullscreen mode Exit fullscreen mode

What we just did: We placed 18 coins along paths the player will naturally take. Some are on the ground, some are mid-jump, and some reward exploring the moving platforms.


Creating the HUD

What we're going to do: Add a score display, three hearts, and a pause button to the game scene.

hud = new Tctxt("16px", "Arial", "white", 16, 16, "left",
                false, "hanging", "rgba(0,0,0,0.6)", 12, 8);
hud.setText("Score: 0  |  Coins: 0 / " + coins.length);
hud.fixed();
display.add(hud, SCENE_GAME);

hearts = [];
for (let i = 0; i < 3; i++) {
    const h = new Tctxt("26px", "Arial", "#ff3366", 0, 0, "center",
                        false, "middle", "transparent");
    h.setText("");
    h.aX = 640 + i * 34; h.aY = 30;
    display.add(h, SCENE_GAME);
    hearts.push(h);
}
Enter fullscreen mode Exit fullscreen mode

What we just did: We created a HUD line showing the score and coin count, and three heart symbols in the top-right. Each heart has an aX and aY — its anchor position — that stays fixed while fixed() recalculates the actual x and y based on the camera offset.

That's how UI elements stay in place when the camera moves.


Creating the Pause Button

What we're going to do: Add a pause button that stays pinned to the screen.

pauseBtnRef = new Component(50, 45, "rgba(124,58,237,0.7)", 0, 0, "rect");
pauseBtnRef.changeAngle = false;
pauseBtnRef.move = function () {};
pauseBtnRef.aX = 740;
pauseBtnRef.aY = 15;
display.add(pauseBtnRef, SCENE_GAME);

interactables.pauseBtn = {
    x: 740, y: 15, w: 50, h: 45, active: false, scene: SCENE_GAME,
    btn: pauseBtnRef,
    baseColor: "rgba(124,58,237,0.7)",
    hoverColor: "rgba(255,51,102,0.9)",
};
Enter fullscreen mode Exit fullscreen mode

What we just did: We created a pause button component with aX and aY set to its intended screen position. The game loop calls pauseBtnRef.fixed() every frame to keep it there. We also registered it in interactables so the hit detection system can find it.


⚔️ Tile Collision Resolution

What we're going to do: Write a function that resolves collisions between an entity and a list of tiles by pushing the entity out on the axis of least overlap.

function resolveTileCollisions(entity, tiles) {
    entity.onGround = false;
    for (let i = 0; i < tiles.length; i++) {
        const t = tiles[i];
        if (!t.crashWith(entity)) continue;

        const oL = (entity.x + entity.width) - t.x;
        const oR = (t.x + t.width) - entity.x;
        const oT = (entity.y + entity.height) - t.y;
        const oB = (t.y + t.height) - entity.y;

        const minX = Math.min(oL, oR), minY = Math.min(oT, oB);

        if (minX < minY) {
            if (oL < oR) entity.x = t.x - entity.width;
            else entity.x = t.x + t.width;
            entity.speedX = 0;
        } else {
            if (oT < oB) {
                entity.y = t.y - entity.height;
                entity.gravitySpeed = 0;
                entity.onGround = true;
            } else {
                entity.y = t.y + t.height;
                if (entity.gravitySpeed < 0) entity.gravitySpeed = 0;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

What we just did: For each tile that overlaps the entity, we calculate four overlap amounts: how far the entity extends past the tile's left, right, top, and bottom edges. We take the minimum on each axis to find how much overlap there is horizontally versus vertically.

If the horizontal overlap is smaller, the collision is horizontal — we snap the entity to the left or right of the tile and zero out its horizontal speed. If the vertical overlap is smaller, the collision is vertical — we snap the entity to the top or bottom of the tile. If it lands on top, we set onGround = true so the player can jump.

The onGround = false at the start of the function resets the flag each frame. It only gets set to true when the entity lands on something.


🎬 The Main Game Update

What we're going to do: Write updateGame(dt) — the biggest function in the file, which handles every frame of gameplay.

function updateGame(dt) {
    if (!player || !hud) return;

    // ── Pin UI + game buttons to screen every frame ──
    for (const c of comm) {
        if (c.scene !== SCENE_GAME) continue;
        const x = c.x;
        if (x === hud || hearts.indexOf(x) !== -1) x.fixed();
        if (x.tag === 'left' || x.tag === 'right' || x.tag === 'jump') x.fixed();
    }
    if (pauseBtnRef) pauseBtnRef.fixed();
    // ... rest of the update ...
}
Enter fullscreen mode Exit fullscreen mode

What we just did: We loop through every component in the game scene and call fixed() on the HUD, the hearts, and the on-screen buttons. This keeps them anchored to the screen even as the camera scrolls.

Then we handle the pause button — if it's active, we switch to the pause scene.


Player Input

What we're going to do: Read keyboard and on-screen button input, set the player's speed and jump velocity.

player.speedX = 0;
if (display.keys[65] || display.keys[37] || interactables.gameLeft.active)  player.speedX = -SPEED;
if (display.keys[68] || display.keys[39] || interactables.gameRight.active) player.speedX =  SPEED;
if ((display.keys[87] || display.keys[38] || display.keys[32] || interactables.gameJump.active) && player.onGround) {
    player.gravitySpeed = JUMP;
}
Enter fullscreen mode Exit fullscreen mode

What we just did: We reset the player's horizontal speed to 0, then set it based on which keys or buttons are active. The player can move with A/D, arrow keys, or on-screen buttons.

The jump is only allowed when onGround is true, which prevents mid-air jumping. Pressing jump sets gravitySpeed to a negative value (the JUMP constant), which launches the player upward.


Player Physics and Collision

What we're going to do: Apply gravity, update the player's position, then resolve collisions against solid tiles.

player.gravitySpeed += player.gravity;
player.x += player.speedX;
player.y += player.speedY + player.gravitySpeed;
resolveTileCollisions(player, solidTiles);
Enter fullscreen mode Exit fullscreen mode

What we just did: We add gravity to gravitySpeed each frame, then apply the player's speed to their position. Finally we call resolveTileCollisions() to push the player out of any solid tiles they've overlapped. That's the entire physics system — three lines of movement, one call to resolve.


Moving Platform Collisions

What we're going to do: Check if the player is standing on a moving platform, and if so, carry them along with it.

for (const p of platforms) {
    if (!p.crashWith(player)) continue;
    const oT = (player.y + player.height) - p.y;
    const oB = (p.y + p.height) - player.y;
    const oL = (player.x + player.width) - p.x;
    const oR = (p.x + p.width) - player.x;
    const minX = Math.min(oL, oR), minY = Math.min(oT, oB);
    if (minY < minX) {
        if (oT < oB) {
            player.y = p.y - player.height;
            player.gravitySpeed = 0;
            player.onGround = true;
            player.onPlatform = p;
        } else {
            player.y = p.y + p.height;
            if (player.gravitySpeed < 0) player.gravitySpeed = 0;
        }
    } else {
        if (oL < oR) player.x = p.x - player.width;
        else         player.x = p.x + p.width;
        player.speedX = 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

What we just did: We use the same overlap-resolution logic as the tile collision, but with one extra step: when the player lands on top, we set player.onPlatform = p. That tells the game loop to apply the platform's motion to the player's position on the next frame, so the player moves with the platform.


Enemy Updates and Collisions

What we're going to do: Update each enemy's AI, apply physics, resolve collisions, and check for player-enemy contact.

for (const e of enemies) {
    e.think(player);
    e.gravitySpeed += e.gravity;
    e.x += e.speedX;
    e.y += e.speedY + e.gravitySpeed;
    resolveTileCollisions(e, solidTiles);
}

for (let i = enemies.length - 1; i >= 0; i--) {
    const e = enemies[i];
    if (!player.crashWith(e)) continue;
    const falling = player.gravitySpeed > 0;
    const above = (player.y + player.height) < e.y + e.height * 0.6;
    if (falling && above) {
        e.destroy(); enemies.splice(i, 1);
        player.gravitySpeed = -13;
        score += 25;
    } else { damagePlayer(); }
}
Enter fullscreen mode Exit fullscreen mode

What we just did: Each enemy runs its AI, moves under gravity, and resolves collisions against solid tiles. Then we loop through the enemies in reverse — necessary because we're removing some — and check for player-enemy contact.

A hit is a stomp if the player is falling (gravitySpeed > 0) and above the enemy's upper 60%. That destroys the enemy, bounces the player upward, and awards 25 points. Otherwise, the player takes damage.


Boss Update and Collisions

What we're going to do: Apply the same pattern to the boss, but with HP tracking.

if (boss) {
    boss.think(player);
    boss.gravitySpeed += boss.gravity;
    boss.x += boss.speedX;
    boss.y += boss.speedY + boss.gravitySpeed;
    resolveTileCollisions(boss, solidTiles);

    if (player.crashWith(boss)) {
        const falling = player.gravitySpeed > 0;
        const above = (player.y + player.height) < boss.y + boss.height * 0.6;
        if (falling && above) {
            const dead = boss.takeHit();
            player.gravitySpeed = -14;
            score += 50;
            if (dead) {
                boss.destroy(); boss = null;
                won = true;
                goToScene(SCENE_WIN);
                return;
            }
        } else { damagePlayer(); }
    }
}
Enter fullscreen mode Exit fullscreen mode

What we just did: The boss moves and collides just like a regular enemy. When the player stomps it, takeHit() reduces its HP and returns true if that was the killing blow. If so, the boss is destroyed, the game state switches to won, and we transition to the win scene.

Otherwise, the boss just flashes red and continues fighting.


The Heart System

What we're going to do: Update the hearts when the player takes damage.

function damagePlayer() {
    if (Date.now() < invincibleUntil) return;
    playerHP--;
    invincibleUntil = Date.now() + 1500;
    for (let i = 0; i < hearts.length; i++) {
        hearts[i].color = i < playerHP ? "#ff3366" : "rgba(255,51,102,0.15)";
    }
    if (playerHP <= 0) {
        gameOver = true;
        goToScene(SCENE_OVER);
    }
}
Enter fullscreen mode Exit fullscreen mode

What we just did: We check for invincibility first — if the player was recently hit, this call does nothing. Otherwise, we reduce HP, set a 1.5-second invincibility window, and update the heart colours. Hearts above the current HP are bright red; hearts below are dimmed. If HP reaches zero, we switch to the game over scene.

The invincibleUntil timestamp is what prevents the player from losing all three hearts in one collision.


📊 What You've Learned

Concept Why It Matters
Scene system One display.scene value switches between menu, game, pause, and more
Custom classes Extending Component lets you give each object its own behaviour
State machines Patrol → chase → return is a simple, flexible AI pattern
Overlap resolution Push entities out on the axis of least overlap to prevent sticking
Moving platforms Store previous position, calculate delta, apply to rider
Invincibility frames A timestamp prevents repeated damage in the same collision
Anchor positions aX and aY plus fixed() keep UI in place while the camera moves
Tile list filtering Split solid vs spike tiles after building the map

⚠️ Known Limitations

Neon Caverns doesn't have multiplayer. The game is single-player only.

The game doesn't save progress between sessions. Closing the tab and reopening it starts from the menu every time.

The game doesn't support gamepad input. Only keyboard, mouse, and touch.

The score is stored in memory, not localStorage. There's no persistence for high scores.

These are deliberate trade-offs — the game was designed to be a compact example, not a full production title.


🚀 What's Next?

If you want to extend Neon Caverns, here are some directions:

Add localStorage persistence for high scores. Add a second boss with a different attack pattern. Add coin physics so coins can be knocked around by the player. Add a minimap in the corner showing the player's position. Add sound effects that trigger on stomps and coin collection. Add a level editor that writes JSON, so new levels can be designed visually.

The source code is small and modular, so any of these would be a good first contribution.


🐛 Report Bugs

If you find a bug in Neon Caverns, report it on GitHub:

👉 github.com/terracodes004/limn-engine-doc/issues


🔗 Resources


🎯 The One-Line Summary

"Neon Caverns is a complete platformer — three levels, boss fights, moving platforms, and a full scene system — built in a single file with Limn Engine." 🎮🚀


Draw your game into existence — one stomp at a time. 🎮🚀

Top comments (0)