🖱️ How I Fixed Canvas Click Coordinates in Limn Engine
The bug that made buttons unclickable on scaled canvases — and the patch that fixes it.
🎯 Try the Fix
Play the demo with the patch installed:
👉 limn-engine-doc.vercel.app/arcade
The Limn Arcade runs every game through this patch. Click any game, tap any button — the coordinates land exactly where they should, even on phones, tablets, and scaled desktop windows.
Grab the patch here:
👉 github.com/terracodes004/limn-engine-doc
📖 Introduction
Every web game eventually hits the same wall. You build a canvas at a fixed resolution — say 800 by 400 — and then you scale it with CSS so it fits on a phone or fills a desktop monitor. The game looks fine. The graphics are crisp. Everything draws where it should.
Then you try to click a button, and nothing happens.
This is a common problem in browser game development. The mouse and touch coordinates that the browser provides are in screen space — relative to the entire page — but your game objects live in canvas space, which has its own internal resolution. When those two coordinate systems don't match, clicks land in the wrong place. On a desktop with a 1:1 canvas, the problem is invisible. On a scaled canvas — which is almost every mobile game — it breaks completely.
The Limn Engine originally handled mouse and touch input without any scaling. That worked for unscaled canvases, but it failed the moment the canvas was stretched or shrunk by CSS. The fix is a small patch to Display.prototype.addEventListeners that converts every mouse and touch position from client coordinates to canvas coordinates using getBoundingClientRect().
In this article, I'll explain exactly what was wrong, walk through the patch that fixes it, and show you how to add the same fix to any Limn Engine project.
🐛 The Bug: Coordinates That Didn't Match
Before we look at the fix, we need to understand what was actually broken. The original addEventListeners method in Limn Engine looked like this:
window.addEventListener('mousedown', (e) => {
this.x = e.pageX + this.camera.x;
this.y = e.pageY + this.camera.y;
});
This code reads e.pageX — the position of the mouse relative to the entire page — and adds the camera offset. On a desktop with a 1:1 canvas, this works. But the moment you scale the canvas with CSS — for example, width: 100vw on a phone — pageX and the canvas's internal coordinates diverge.
Here's the problem in plain numbers. Suppose your canvas is 800 pixels wide internally, but CSS stretches it to 1600 pixels on screen. When the user taps the middle of the canvas, the browser reports e.pageX = 800. But the canvas's internal coordinate for that same point is 400. Every click is off by a factor of two.
The fix is to read the mouse position relative to the canvas and then scale it to match the canvas's internal resolution. That's exactly what the getBoundingClientRect() method was designed for.
🛠️ What We're Going to Build
We're going to replace Display.prototype.addEventListeners with a patched version. The patch does three things the original didn't do:
-
Scales mouse and touch coordinates from client space to canvas space using
getBoundingClientRect(). -
Tracks mouse movement continuously with
mousemoveandtouchmovelisteners. - Unifies mouse and touch handling through a single coordinate helper, so game code works the same on desktop and mobile.
Let me walk through the patch step by step.
Step 1: The Outer Wrapper
What we're going to do: Wrap the entire patch in an immediately-invoked function expression (IIFE) so it runs the moment the file loads. Inside, we check that Display exists before doing anything else.
(function () {
if (typeof Display === 'undefined') {
console.warn('[limn-click-patch] Display not found. Load patch after epic.js.');
return;
}
// ... patch code ...
})();
What we just did: We created a self-contained block of code that runs immediately. The typeof Display === 'undefined' guard protects against running before epic.js has defined the Display class. If someone loads the files in the wrong order, the patch logs a warning and bails out instead of throwing a ReferenceError.
Step 2: The Coordinate Helper
What we're going to do: Write a helper function that converts any client-space position into canvas-space coordinates. This is the core of the fix.
var toCanvasCoords = function (clientX, clientY) {
var rect = self.canvas.getBoundingClientRect();
var scaleX = self.canvas.width / rect.width;
var scaleY = self.canvas.height / rect.height;
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY
};
};
What we just did: We built a function that takes a client-space X and Y and returns the matching canvas-space position.
-
self.canvas.getBoundingClientRect()— gets the canvas's real position and size on the page, after any CSS scaling has been applied. -
self.canvas.width / rect.width— calculates the ratio between the canvas's internal resolution and its rendered size. If the canvas is internally 800 wide but rendered at 400, this is2. -
(clientX - rect.left) * scaleX— subtracts the canvas's left offset from the client X to get the position inside the canvas, then multiplies by the scale factor to convert to internal canvas pixels.
The same math applies to Y. The result is a coordinate pair that exactly matches what the canvas's drawing context sees.
Step 3: The Mouse Down Listener
What we're going to do: Rewrite the mousedown listener to use the coordinate helper, then add the camera offset to convert from canvas space to world space.
window.addEventListener('mousedown', function (e) {
var p = toCanvasCoords(e.clientX, e.clientY);
self.x = p.x + self.camera.x;
self.y = p.y + self.camera.y;
});
What we just did: When the mouse is pressed, we convert the position to canvas coordinates and add the camera offset. This gives us display.x and display.y in world space — the same space where every game object lives. That's what Component.clicked() expects when it checks whether the mouse is inside a rectangle. The camera offset matters because the world can be larger than the canvas — a camera scrolls to show different parts of the world, and clicks need to land on the right object regardless of where the camera is looking.
Step 4: The Mouse Up Listener
What we're going to do: Reset display.x and display.y to false when the mouse is released.
window.addEventListener('mouseup', function () {
self.x = false;
self.y = false;
});
What we just did: We made the engine treat display.x as a boolean-ish value: it holds a number while the mouse is pressed, and false when it isn't. This is the convention the rest of Limn Engine already uses — game code checks if (display.x && button.clicked()) to detect clicks. Releasing the mouse sets it back to false so no click is detected on the next frame.
Step 5: The Touch Start Listener
What we're going to do: Apply the same coordinate conversion to touchstart, so a finger tap on a phone behaves exactly like a mouse click.
window.addEventListener('touchstart', function (e) {
var t = e.touches[0];
var p = toCanvasCoords(t.clientX, t.clientY);
self.x = p.x + self.camera.x;
self.y = p.y + self.camera.y;
});
What we just did: We read the first touch from e.touches[0], convert it to canvas coordinates with the same helper the mouse uses, and add the camera offset. Because the helper is the same, mouse and touch produce identical results for the same physical position on the canvas. That's what makes the game work on both desktop and mobile without any branching logic.
Step 6: The Touch End Listener
What we're going to do: Reset display.x and display.y when the finger is lifted, matching the mouse up behaviour.
window.addEventListener('touchend', function () {
self.x = false;
self.y = false;
});
What we just did: We closed the touch loop. Lifting a finger clears the press state, so the next frame sees no click — exactly the same as releasing a mouse button.
Step 7: The Mouse Move Listener
What we're going to do: Add a new mousemove listener that continuously updates the global mouse object. The original engine didn't have this — it only tracked the mouse when a button was pressed.
window.addEventListener('mousemove', function (e) {
var p = toCanvasCoords(e.clientX, e.clientY);
if (typeof mouse !== 'undefined') {
mouse.x = p.x;
mouse.y = p.y;
}
});
What we just did: Every time the mouse moves — not just when it's clicked — the global mouse object gets its x and y updated in canvas space. This unlocks hover effects, custom cursors, drag-and-drop, and any other pointer-driven feature that needs to know where the mouse is right now. The typeof mouse !== 'undefined' check makes the patch safe to load even if the rest of the engine hasn't defined the mouse object yet.
Step 8: The Touch Move Listener
What we're going to do: Add the equivalent listener for touch, so mobile devices get continuous position tracking too.
window.addEventListener('touchmove', function (e) {
var t = e.touches[0];
var p = toCanvasCoords(t.clientX, t.clientY);
if (typeof mouse !== 'undefined') {
mouse.x = p.x;
mouse.y = p.y;
}
});
What we just did: We mirrored the mousemove behaviour for touch. A finger dragging across the screen updates mouse.x and mouse.y in canvas space in real time, giving mobile games the same tracking accuracy as desktop.
📝 The Complete Patch
Here's the entire patch in one piece, ready to paste at the bottom of epic.js or load as a separate file after the engine.
(function () {
if (typeof Display === 'undefined') {
console.warn('[limn-click-patch] Display not found. Load patch after epic.js.');
return;
}
Display.prototype.addEventListeners = function () {
var self = this;
window.addEventListener('keydown', function (e) {
self.keys[e.keyCode] = true;
});
window.addEventListener('keyup', function (e) {
self.keys[e.keyCode] = false;
});
var toCanvasCoords = function (clientX, clientY) {
var rect = self.canvas.getBoundingClientRect();
var scaleX = self.canvas.width / rect.width;
var scaleY = self.canvas.height / rect.height;
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY
};
};
window.addEventListener('mousedown', function (e) {
var p = toCanvasCoords(e.clientX, e.clientY);
self.x = p.x + self.camera.x;
self.y = p.y + self.camera.y;
});
window.addEventListener('mouseup', function () {
self.x = false;
self.y = false;
});
window.addEventListener('touchstart', function (e) {
var t = e.touches[0];
var p = toCanvasCoords(t.clientX, t.clientY);
self.x = p.x + self.camera.x;
self.y = p.y + self.camera.y;
});
window.addEventListener('touchend', function () {
self.x = false;
self.y = false;
});
window.addEventListener('mousemove', function (e) {
var p = toCanvasCoords(e.clientX, e.clientY);
if (typeof mouse !== 'undefined') {
mouse.x = p.x;
mouse.y = p.y;
}
});
window.addEventListener('touchmove', function (e) {
var t = e.touches[0];
var p = toCanvasCoords(t.clientX, t.clientY);
if (typeof mouse !== 'undefined') {
mouse.x = p.x;
mouse.y = p.y;
}
});
};
console.log('[limn-click-patch] Installed.');
})();
What we've done: We replaced the entire addEventListeners method on Display.prototype with a version that reads client coordinates, scales them to canvas space with getBoundingClientRect(), adds the camera offset for world space, unifies mouse and touch handling, and tracks pointer movement continuously. Every Display instance created after this patch runs gets the corrected behaviour.
📊 Before and After
Here's what the old and new code look like side by side, using the same event as an example.
Before:
window.addEventListener('mousedown', (e) => {
this.x = e.pageX + this.camera.x;
this.y = e.pageY + this.camera.y;
});
After:
window.addEventListener('mousedown', function (e) {
var p = toCanvasCoords(e.clientX, e.clientY);
self.x = p.x + self.camera.x;
self.y = p.y + self.camera.y;
});
What we've done: We changed a single line — e.pageX became toCanvasCoords(e.clientX, e.clientY).x. That substitution makes clicks land exactly where the player intended, on any screen size, at any scale.
🤔 What About the fake Canvas?
You might be wondering whether the fake canvas — Limn Engine's offscreen render buffer — also gets the patched addEventListeners. The answer is yes, but it doesn't matter.
The fake canvas is created with fake.canvas.style.display = "none", which means the browser never displays it and therefore never dispatches mouse or touch events to it. The patched listeners attach to the window object, not the canvas, so they'd technically fire on fake too — but fake never receives clicks, and the coordinate conversion always targets self.canvas, which for fake is the hidden offscreen canvas. Since no clicks ever reach it, the whole question is moot.
The patch is correct as-is. fake doesn't need to be handled specially, and the order in which display and fake are created doesn't matter.
⚠️ Limitations
Being honest about what this patch does not do is just as important as describing what it does.
The patch doesn't handle multi-touch. Only touches[0] — the first finger — is read. If two fingers press the canvas at once, only the first is tracked. For most games this is fine, but if you're building a two-player game on a single phone screen, you'll need to extend the patch.
The patch doesn't clean up after itself. If you destroy a Display instance and create a new one, the old listeners remain attached to window. In practice this doesn't matter, because Limn Engine games usually create one Display and keep it for the lifetime of the page. But if you're building a multi-page app, be aware of it.
📊 What You've Learned
| Concept | Why It Matters |
|---|---|
getBoundingClientRect() |
Gets the canvas's real position and size on the page, after CSS scaling |
| Coordinate scaling | Multiplying by canvas.width / rect.width converts screen pixels to canvas pixels |
| Canvas space vs world space |
mouse.x is in canvas space; display.x is in world space (canvas + camera) |
| Prototype overriding | Replacing Display.prototype.addEventListeners patches every instance at once |
| Client vs page coordinates |
clientX is viewport-relative; pageX is document-relative — clientX is what you want |
| Touch unification | The same coordinate helper serves both mouse and touch, so game code doesn't branch |
🚀 What's Next?
Now that clicks land correctly on every screen, you can build UI that actually works on mobile. Here are some directions to explore.
The first is to extend the patch with multi-touch support, tracking touches[1], touches[2], and beyond for split-screen local multiplayer on a single device. The second is to add a pointerdown / pointermove / pointerup listener path that unifies mouse, touch, and pen input into a single API. The third is to build a proper mouse.justPressed flag that fires for exactly one frame, which makes single-shot buttons reliable without needing to reset keys manually.
If you build any of these, send a pull request. The patch file is small, well-commented, and designed to be extended.
🐛 Report Bugs
If you find a case where clicks still land in the wrong place, report it on GitHub:
👉 github.com/terracodes004/limn-engine-doc/issues
Please include your browser, your device, the scale factor you were using, and a description of where the click landed versus where it should have landed.
🔗 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 |
| 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
"The limn-click patch converts mouse and touch coordinates to canvas space using
getBoundingClientRect(), so buttons land where players expect on any screen size." 🖱️🚀
Draw your game into existence — one click at a time. 🎮🚀
Top comments (0)