DEV Community

Cover image for โœ…โŒ The Limn Engine Commandments: 31 Do's and Don'ts for Better Games
Kehinde Owolabi
Kehinde Owolabi

Posted on

โœ…โŒ The Limn Engine Commandments: 31 Do's and Don'ts for Better Games

โœ…โŒ The Limn Engine Commandments: 31 Do's and Don'ts for Better Games


๐ŸŽฏ Setup & Configuration

1. โœ… DO: Call display.perform() Before display.start()

const display = new Display();
display.perform();  // โœ… MUST come first
display.start(800, 600);
Enter fullscreen mode Exit fullscreen mode

Why: The perform() method is a critical performance patch that replaces the engine's default rendering loop. By default, when you call display.start(), the engine uses setInterval(() => this.updat(), 20) which locks your game to roughly 50 frames per second. This is because setInterval is unreliable โ€” it doesn't sync with the monitor's refresh rate and can drift over time, causing stutter and inconsistent frame pacing. When you call display.perform() first, it overrides the start() method to use requestAnimationFrame() instead, which synchronizes your game loop with the monitor's refresh rate for smoother animations. More importantly, requestAnimationFrame pauses automatically when the user switches tabs, saving CPU and battery life. If you accidentally call start() before perform(), the setInterval loop is already running and the patch won't take effect. Always call perform() first, then start().

โŒ DON'T: Call display.start() Before display.perform()

const display = new Display();
display.start(800, 600);  // โŒ Wrong order
display.perform();        // โŒ Too late โ€” the damage is done
Enter fullscreen mode Exit fullscreen mode

Why: When you call start() first, the engine launches the old setInterval loop that runs at an unreliable ~50fps. The perform() method works by replacing the start() method itself with a new version that uses requestAnimationFrame. However, if start() has already executed, the damage is done โ€” the setInterval loop is already running, and the perform() patch won't stop it or replace it. You'll end up with two loops fighting for control, causing erratic behavior and inconsistent frame rates. The only way to fix this is to refresh the page and call perform() first. This is one of the most common mistakes beginners make, and it's also one of the hardest to debug because the game appears to run but feels "off" or stuttery. Always remember: perform() first, then start().


2. โœ… DO: Name Your Display Instance display

const display = new Display();  // โœ… Correct
display.start(800, 600);
Enter fullscreen mode Exit fullscreen mode

Why: The Limn Engine was built with the expectation that your main display instance will be called display โ€” lowercase, exactly as spelled. This isn't just a convention; it's a hard dependency baked into the engine's internals. Many of the engine's built-in methods and utilities, such as Component.crashWith(), display.camera.follow(), and the various move functions, directly reference a global variable named display. For example, when you call move.bound(player), the function internally uses display.canvas.width and display.canvas.height to determine the screen boundaries. If you name your instance something else โ€” say, game or app โ€” these internal references will fail because display will be undefined. You'll see cryptic errors like "Cannot read property 'canvas' of undefined" and your game will break. The engine was designed this way to keep the API simple for beginners, but it means you must follow this naming rule strictly.

โŒ DON'T: Use a Different Variable Name

const game = new Display();  // โŒ Wrong
game.start(800, 600);
Enter fullscreen mode Exit fullscreen mode

Why: The engine has display hardcoded in many places. When you use a different name, internal functions like move.bound() and Component.clicked() will look for a variable named display and find nothing. This results in undefined errors that can be confusing to debug. For example, move.bound(player) checks display.canvas.width, but if your instance is named game, display is undefined, and you'll get "Cannot read property 'canvas' of undefined." The error messages won't point to your variable name โ€” they'll point to the internal engine code, making it even harder to figure out. The only way to avoid this is to use the exact name display. This is a small constraint that saves you from hours of debugging.


3. โœ… DO: Call display.perform() Before Using TileMaps

const display = new Display();
display.perform();        // โœ… Creates fake canvas
display.start(800, 600);
display.tileMap();        // โœ… TileMap works!
display.tileFace.show();  // โœ… Tiles appear!
Enter fullscreen mode Exit fullscreen mode

Why: The TileMap system relies on an offscreen canvas called fake to render its tiles. This fake canvas is only created when you call display.perform(). Without perform(), the fake variable remains undefined, and the TileMap has nowhere to draw its tiles. The engine doesn't throw an error โ€” it just silently fails to render anything, leaving you with a blank screen and no indication of what went wrong. This is especially frustrating because the code looks correct and no error messages appear. The fake canvas is also where the engine caches static content for performance, so it's essential for more than just TileMaps. Always call perform() before using any TileMap functionality. If you're using TileMaps, perform() is mandatory, not optional.

โŒ DON'T: Use TileMap Without display.perform()

const display = new Display();
display.start(800, 600);  // โŒ No perform() called
display.tileMap();        // โŒ Creates tilemap
display.tileFace.show();  // โŒ Nothing appears!
Enter fullscreen mode Exit fullscreen mode

Why: Without perform(), the fake canvas doesn't exist. The TileMap has no offscreen buffer to render its tiles, so nothing is drawn. The engine doesn't throw any errors โ€” it just silently fails. You'll see a blank screen and spend hours wondering why your tiles aren't appearing. This is one of the most common and frustrating mistakes beginners make because everything looks correct in the code. The solution is simple: always call perform() before using any TileMap functionality.


4. โœ… DO: Use deltaTime for Movement

function update(dt) {
    player.x += 200 * dt;  // โœ… Frame-rate independent
}
Enter fullscreen mode Exit fullscreen mode

Why: The deltaTime parameter (often abbreviated as dt) is the amount of time that has passed since the last frame, measured in seconds. On a 60Hz monitor, dt is approximately 0.0167 seconds (1/60). On a 144Hz monitor, it's about 0.0069 seconds (1/144). When you multiply your speed values by dt, you ensure that movement is frame-rate independent. For example, if you set player.speedX = 200, and then in your update loop you write player.x += player.speedX * dt, the player will move 200 pixels per second regardless of whether the game is running at 30fps, 60fps, or 144fps. On a 60Hz screen, each frame moves 200 * 0.0167 = 3.34 pixels. On a 144Hz screen, each frame moves 200 * 0.0069 = 1.39 pixels. Over the course of one second, both add up to exactly 200 pixels. This is essential for any game that should run consistently across different hardware.

โŒ DON'T: Use Raw Numbers for Speed

function update(dt) {
    player.x += 4;  // โŒ Frame-rate dependent
}
Enter fullscreen mode Exit fullscreen mode

Why: When you use raw numbers without dt, your game's speed becomes tied to the frame rate. On a fast computer running at 144fps, a speed of 4 pixels per frame translates to 576 pixels per second. On a slow computer running at 30fps, the same speed of 4 pixels per frame translates to only 120 pixels per second. This means your game will run at completely different speeds on different computers โ€” a player on a high-end machine will zoom through levels while a player on a low-end machine will crawl. This is a game-breaking issue that makes your game unplayable for a significant portion of your audience. Always multiply your speeds by dt to ensure consistent gameplay across all devices.


5. โœ… DO: Normalize Diagonal 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;  // โœ… Prevents faster diagonal movement
    my *= 0.707;
}
Enter fullscreen mode Exit fullscreen mode

Why: When you move a player or enemy by incrementing both x and y independently at the same speed, the diagonal speed becomes โˆš(speedยฒ + speedยฒ) = speed ร— โˆš2, which is approximately 1.414 times faster than horizontal or vertical movement. This happens because the two velocity vectors are added together at a right angle, and the resulting vector's magnitude is the square root of the sum of squares (the Pythagorean theorem). In practice, if your player moves at 200 pixels per second horizontally, moving diagonally will make them move at 282 pixels per second โ€” a noticeable and disorienting speed boost. Players will feel like they're "sprinting" when moving diagonally, which breaks the consistency of your game's movement mechanics. To fix this, you need to normalize the direction vector. The most common approach is to multiply both mx and my by 1/โˆš2 โ‰ˆ 0.707 when both are non-zero, which reduces the combined speed back to exactly 1. This ensures that whether the movement is horizontal, vertical, or diagonal, the resulting speed is exactly the same.

โŒ DON'T: Ignore Diagonal Normalization

if (display.keys[37]) player.speedX = -200 * dt;
if (display.keys[39]) player.speedX = 200 * dt;
if (display.keys[38]) player.speedY = -200 * dt;
if (display.keys[40]) player.speedY = 200 * dt;
Enter fullscreen mode Exit fullscreen mode

Why: When both left and up are pressed, the player moves at โˆš(200ยฒ + 200ยฒ) = 282 pixels/second, which is faster than the intended 200 pixels/second. This is a fundamental principle of game physics and one of the most common "beginner traps" in game development.


๐Ÿงฉ Components

6. โœ… DO: Use display.add() for Every Component

const player = new Component(40, 40, "blue", 400, 300, "rect");
display.add(player);  // โœ… Required
Enter fullscreen mode Exit fullscreen mode

Why: Creating a new Component with new Component(50, 50, "red", 100, 100) allocates memory for that object and sets up its properties โ€” but it does not automatically add it to the rendering pipeline. The engine maintains an internal list of components called comm (short for "components") that it iterates over every frame. Inside updat(), the engine loops through comm, calls move() on each component to update its position, and then calls update() to draw it on the canvas. If you forget to call display.add(player), your player object exists in memory but is never added to comm. As a result, updat() never sees it, never updates its position, and never draws it. You'll have a perfectly valid Component object that simply never appears on screen. This is a common source of frustration for beginners who think they've done everything right but see nothing. The rule is simple: every Component you create must be passed to display.add() to become part of the game world.

โŒ DON'T: Forget to Add Components

const player = new Component(40, 40, "blue", 400, 300, "rect");
// โŒ Missing display.add(player)
Enter fullscreen mode Exit fullscreen mode

Why: Without display.add(), your component exists only in memory. It's like creating a character in a game but never spawning them into the world โ€” they exist in the code but have no presence in the game. The engine's render loop iterates over the comm array to draw components, but your component isn't in that array. No error is thrown; the component just never appears. This is one of the most common and frustrating bugs for beginners because everything looks correct in the code, but nothing shows up on screen. Always remember: creating a component is only half the job โ€” you must also add it to the display.


7. โœ… DO: Use setImage() for Images

player.setImage("hero.png");  // โœ… Correct
Enter fullscreen mode Exit fullscreen mode

Why: The setImage() method was designed to handle the complexities of loading images in a browser. When you call component.setImage("player.png"), the method creates a new Image object, sets its src property, and attaches onload and onerror event listeners. The onload callback sets imageLoaded = true, which the engine checks before attempting to draw the image. The onerror callback provides a graceful fallback โ€” if the image fails to load (due to a typo in the filename, network issues, or CORS restrictions), the component automatically converts itself to a red rectangle so your game doesn't crash with broken images. If you instead directly assign properties like component.image = new Image(); component.image.src = "player.png";, you bypass all of this safety logic. The component's type won't be set correctly, imageLoaded will remain false, and the engine might attempt to draw a non-existent image, causing runtime errors or invisible objects. Additionally, setImage() handles the case where you might want to switch images dynamically โ€” it properly cleans up the old image before loading the new one, preventing memory leaks.

โŒ DON'T: Directly Assign Image Properties

player.type = "image";           // โŒ Wrong
player.image = new Image();      // โŒ Wrong
player.image.src = "hero.png";   // โŒ Wrong
Enter fullscreen mode Exit fullscreen mode

Why: Images load asynchronously, which means the browser continues executing code while the image is being downloaded. If you directly assign the image and then immediately try to draw it, the image may not have finished loading yet. The engine's imageLoaded flag is designed to handle this โ€” it's set to true only after the image has fully loaded. When you use setImage(), this flag is properly managed. When you assign properties directly, you bypass this system, and the engine may try to draw an image that isn't ready yet, resulting in invisible objects or rendering errors. Additionally, setImage() sets the component's type to "image" automatically, which tells the engine how to render the component. If you forget to set the type, the engine will treat it as a rectangle and ignore the image entirely.


8. โœ… DO: Use destroy() for Permanent Removal

bullet.destroy();  // โœ… Removes forever
Enter fullscreen mode Exit fullscreen mode

Why: The destroy() method is the proper way to permanently remove a component from your game. When you call player.destroy(), the method searches the global comm array (and the commp array used by the fake canvas) for references to that component and removes them. It then sets the component's update method to null, effectively cutting all ties to the rendering pipeline. This allows the JavaScript garbage collector to reclaim the memory used by the component and its associated resources (including images, which can be large). If you instead use hide() to make a component invisible, the component is still present in the comm array. Every frame, the engine will still call move() on it (updating its position), check its collision with other objects, and attempt to call update() on it (which now does nothing because you set update = null). This wastes CPU cycles โ€” especially problematic if you have hundreds of destroyed enemies still being processed. hide() is designed for temporary invisibility, like when a coin is collected and you want to respawn it later. For permanent removal, always use destroy().

โŒ DON'T: Hide Objects You'll Never Use Again

bullet.hide();  // โŒ Still in memory
Enter fullscreen mode Exit fullscreen mode

Why: When you hide an object, it remains in the engine's internal arrays and continues to be processed every frame. The engine still calls move() on it, updating its position based on speed and physics. It still checks collision with other objects. The only thing that stops is drawing. This means you're wasting CPU cycles on objects that will never be seen again. If you have 100 bullets hidden this way, you're still updating 100 bullets every frame, which slows down your game. Worse, because the objects are still in memory, they contribute to memory usage and can cause performance issues over time. Use hide() only for objects that you plan to show again later. For objects that are gone forever, use destroy().


9. โœ… DO: Use hide() for Temporary Invisibility

enemy.hide();  // โœ… Disappears but can be shown later
Enter fullscreen mode Exit fullscreen mode

Why: The hide() method is designed for scenarios where you need a component to disappear temporarily but plan to bring it back later. When you call coin.hide(), the method sets the component's update method to null, which prevents it from being drawn on screen. However, the component remains in the comm array and continues to be part of the game loop โ€” its position is still updated, its collision is still checked, and it still exists in memory. This is perfect for a coin that the player just collected but that you want to respawn after 10 seconds. When it's time to respawn, you simply call coin.show(), which restores the update method to its original drawing function, and the coin reappears on screen. If you used destroy() instead, the coin would be completely removed from memory and you'd have to create a brand new component from scratch, with all the overhead of allocating memory, loading images, and re-adding it to the display. This becomes especially important for high-frequency objects like bullets or particles โ€” creating and destroying thousands of objects per second would trigger frequent garbage collection, causing visible stutter.

โŒ DON'T: Use destroy() for Temporary Removal

enemy.destroy();  // โŒ Gone forever
Enter fullscreen mode Exit fullscreen mode

Why: destroy() is permanent โ€” it removes the component from the engine's arrays, sets its methods to null, and marks it for garbage collection. If you call destroy() on an enemy that you planned to respawn later, you'll have to create a whole new enemy from scratch. This means allocating new memory, setting up all the properties again, loading any images again, and adding it back to the display. This is inefficient and can cause performance issues if done frequently. Use destroy() only when you're absolutely sure you won't need the object again. For objects that you'll reuse (like enemies in a wave-based game), use hide() to make them invisible and then show() to bring them back.


10. โœ… DO: Use Tctxt for UI Text

const scoreText = new Tctxt("24px", "Arial", "white", 20, 50);
scoreText.setText("Score: 0");
display.add(scoreText);
Enter fullscreen mode Exit fullscreen mode

Why: The Tctxt class is a specialized text component that provides features regular text doesn't have. Unlike a basic Component with type = "text", Tctxt supports background colors with padding (so your text has a nice box behind it), text alignment (left, center, right), text stroke (outline effect), and baseline control. It also automatically measures text width to handle alignment correctly. When you use Tctxt, your UI will look polished and professional. If you use a basic Component for text, your text will be drawn directly on the canvas with no background, no padding, and no alignment options. This makes your UI look plain and hard to read, especially over complex backgrounds. For any UI element that displays text โ€” scores, health bars, menus, dialog boxes โ€” always use Tctxt for a cleaner, more professional look.

โŒ DON'T: Use Component for Text

const scoreText = new Component("24px", "Arial", "white", 20, 50, "text");
scoreText.setText("Score: 0");
display.add(scoreText);
Enter fullscreen mode Exit fullscreen mode

Why: The basic Component class treats text as a simple rendering operation โ€” it draws the text at the specified position with the specified font and color, and that's it. There's no background, no padding, no alignment, and no stroke. This means your text will be drawn directly on top of whatever is behind it, making it hard to read if the background is busy or has similar colors. You also can't center the text easily because you'd have to manually calculate the text width and adjust the position. Tctxt handles all of this for you, providing a polished UI with minimal effort. For any game that needs to display information to the player, Tctxt is the way to go.


11. โœ… DO: Call .fixed() on UI Elements Every Frame

function update(dt) {
    scoreText.fixed();  // โœ… Keeps UI on screen
}
Enter fullscreen mode Exit fullscreen mode

Why: The .fixed() method adjusts a component's position based on the camera's current position. When you call .fixed(), the component's x and y are recalculated to be relative to the camera's viewport. This ensures that the UI element stays in the same place on the screen, regardless of where the camera moves. Without .fixed(), UI elements would scroll with the camera โ€” they'd move off-screen when the camera moves, and appear to drift around the world. This is because components are positioned in world space by default. By calling .fixed() every frame, you're telling the engine "this element should be fixed to the screen, not the world." You need to call it every frame because the camera's position can change every frame. If you call it once, the UI will be fixed to the camera's position at that moment, but when the camera moves, the UI will stay in the wrong place. For any UI element that should stay on screen (score, health, menus), always call .fixed() in your update() loop.

โŒ DON'T: Call .fixed() Once

scoreText.fixed();  // โŒ Called only once
Enter fullscreen mode Exit fullscreen mode

Why: .fixed() works by setting the component's position relative to the camera's current position. If you call it once at the start of the game, the component is positioned correctly for that moment. But as soon as the camera moves โ€” because the player walks to the right, for example โ€” the component's position is no longer correct. It stays in the world position it was fixed to, which is now off-screen or in the wrong place. To keep UI elements correctly positioned, you must call .fixed() every frame, so that the position is recalculated based on the camera's current position. This ensures that your UI stays in the same place on the screen, no matter where the camera moves.


๐Ÿƒ Movement

12. โœ… DO: Use move.bound() to Keep Objects On Screen

move.bound(player);  // โœ… Keeps player on screen
Enter fullscreen mode Exit fullscreen mode

Why: move.bound() is a single-line solution that replaces four separate if statements. When you call move.bound(player), the function checks if the player's x is less than 0 and sets it to 0 if so; checks if x + width exceeds display.canvas.width and sets it to canvas.width - width; and does the same for y and height. This keeps your player (or any component) within the visible screen area. Writing this yourself isn't difficult โ€” it's just four conditionals โ€” but doing it manually for every component that needs boundary checking is repetitive and error-prone. You might forget to subtract the component's width when checking the right edge, or accidentally use height instead of width. You might also need to check boundaries for enemies, bullets, and UI elements, leading to duplicated code. move.bound() centralizes this logic in one well-tested location, reducing bugs and keeping your code clean. It also has a companion function, move.boundTo(), which allows you to specify custom boundaries (like keeping a boss enemy within a specific arena), giving you even more flexibility without writing custom logic.

โŒ DON'T: Write Your Own Edge Detection

if (player.x < 0) player.x = 0;
if (player.x + player.width > canvas.width) player.x = canvas.width - player.width;
if (player.y < 0) player.y = 0;
if (player.y + player.height > canvas.height) player.y = canvas.height - player.height;
Enter fullscreen mode Exit fullscreen mode

Why: Writing your own edge detection is repetitive and error-prone. You have to remember to check all four edges, and you have to remember to subtract the component's width and height when checking the right and bottom edges. If you forget, your object will partially go off-screen before being stopped. You also have to do this for every component that needs boundary checking, leading to duplicated code. move.bound() handles all of this for you in a single function call, with all the edge cases handled correctly. It's faster to write, faster to read, and less likely to contain bugs.


13. โœ… DO: Use move.accelerate() and move.decelerate() for Smooth Physics

if (display.keys[68]) {
    move.accelerate(player, 0.6, 0, 8, 0);  // โœ… Smooth acceleration
} else {
    move.decelerate(player, 0.4, 0);        // โœ… Smooth deceleration
}
Enter fullscreen mode Exit fullscreen mode

Why: move.accelerate() and move.decelerate() provide smooth, weighty movement that feels realistic and responsive. move.accelerate(player, 0.5, 0.5, 5, 5) adds 0.5 pixels per secondยฒ to the player's speedX and speedY, while capping the maximum speed at 5 pixels per second in each direction. This creates a feeling of momentum โ€” the player gradually speeds up to their maximum velocity rather than instantly teleporting to max speed. move.decelerate() does the opposite, reducing speed toward zero, which creates friction and a sense of weight. If you directly set player.speedX = 5 and player.speedY = 5, the player immediately moves at full speed with no acceleration or deceleration, resulting in twitchy, unnatural movement that feels like ice-skating. This is especially noticeable in platformers, where acceleration and deceleration give the player character a sense of weight and control. Directly setting speed is appropriate for some scenarios (like teleportation or menu navigation), but for character movement in games, acceleration-based physics is almost always preferable.

โŒ DON'T: Directly Set Speed for Physics

if (display.keys[68]) {
    player.speedX = 8;  // โŒ Instant speed change
} else {
    player.speedX = 0;  // โŒ Instant stop
}
Enter fullscreen mode Exit fullscreen mode

Why: Directly setting speed creates jerky, unnatural movement. When you press a key, the player instantly jumps to full speed; when you release the key, the player instantly stops. This feels like ice-skating or sliding on ice โ€” there's no sense of weight or momentum. In most games, players expect a gradual acceleration and deceleration. This makes the movement feel more responsive and gives the player a sense of control. move.accelerate() and move.decelerate() provide this smooth, weighty feel with just a few lines of code.


๐ŸŽจ Rendering

14. โœ… DO: Use the Fake Canvas for Static Content

fake.add(tree);      // โœ… Cached, drawn once
fake.add(decoration);
Enter fullscreen mode Exit fullscreen mode

Why: The fake canvas is an offscreen buffer that renders static content once and then caches it as an image. This is the core of the engine's performance optimization. When you add a component to the fake canvas (using fake.add()), it's drawn onto the fake canvas's context. The fake canvas is then drawn as a single image onto the main canvas every frame. This means that instead of drawing 100 trees individually every frame (each requiring its own draw call), the engine draws one image โ€” the fake canvas โ€” in a single draw call. This drastically reduces the number of draw calls, which is the primary bottleneck in 2D rendering. The difference can be dramatic: a game with 1000 static objects might run at 4fps without the fake canvas, but at 60fps with it. The fake canvas is ideal for backgrounds, tilemaps, decorations, and any other content that doesn't move. For dynamic content like players, enemies, and particles, use the main canvas with display.add().

โŒ DON'T: Put Static Content on the Main Canvas

display.add(tree);   // โŒ Redrawn every frame
display.add(decoration);
Enter fullscreen mode Exit fullscreen mode

Why: Every object on the main canvas is redrawn every frame. If you have 100 trees, the engine makes 100 draw calls every frame. Each draw call has overhead โ€” the engine has to set up the context, apply transformations, and draw the object. This overhead adds up quickly. With 100 objects, you might not notice; with 1000 objects, your game will slow down significantly. The fake canvas solves this by combining all static objects into a single image. Instead of 100 draw calls, you have 1. This is one of the most important optimizations in the engine, and it's why the fake canvas exists. Always use it for static content.


15. โœ… DO: Call fake.refresh() After Changes

fake.bgComm.setImage("new_sky.png");
fake.refresh();  // โœ… Forces redraw
Enter fullscreen mode Exit fullscreen mode

Why: The fake canvas caches its content to maximize performance. When you change the content of the fake canvas โ€” for example, by changing the image of a background component, adding or removing tiles, or modifying any static object โ€” the cached image becomes outdated. The engine won't automatically know that the content has changed, so it will continue to display the old cached image. To update the cached image, you need to call fake.refresh(). This forces the engine to clear the fake canvas, redraw all the static components, and cache the new image. If you forget to call fake.refresh(), your changes won't appear on screen, and you'll be stuck wondering why your new sky or updated tilemap isn't showing. Always call fake.refresh() after making any changes to the fake canvas.

โŒ DON'T: Forget to Refresh

fake.bgComm.setImage("new_sky.png");
// โŒ Missing fake.refresh()
Enter fullscreen mode Exit fullscreen mode

Why: The fake canvas is designed to be static โ€” it renders once and then reuses the same image every frame. This is what makes it so performant. However, when you change the content of the fake canvas, the cached image becomes outdated. The engine doesn't automatically detect this change, so it continues to use the old cached image. Your changes appear to have no effect, even though the code is correct. This is a common source of confusion for beginners. Always remember to call fake.refresh() after modifying any content on the fake canvas.


๐ŸŽฅ Camera

16. โœ… DO: Set worldWidth and worldHeight

display.camera.worldWidth = 2000;
display.camera.worldHeight = 2000;
Enter fullscreen mode Exit fullscreen mode

Why: The camera needs to know the boundaries of your game world so it can clamp its position and never show empty space outside the world. When the camera follows the player, it calculates its position based on the player's location. However, if the player moves to the edge of the world, the camera might try to move beyond the world boundaries, showing black space or empty areas. By setting worldWidth and worldHeight, you tell the camera the maximum extent of your world. The camera's follow() method uses these values to clamp the camera's position so that it never shows areas outside the world. Without these values, the camera defaults to 1000x1000, which may not match your world size. If your world is larger (say, 2000x2000), the camera will stop at 1000 pixels, cutting off half your world. If your world is smaller (say, 800x600), the camera will show empty space beyond the world boundaries. Always set worldWidth and worldHeight to match your world's actual size.

โŒ DON'T: Leave World Bounds Unset

// โŒ Missing worldWidth and worldHeight
Enter fullscreen mode Exit fullscreen mode

Why: Without world bounds, the camera has no idea where the world ends. When the player reaches the edge of the world, the camera will continue moving beyond the world boundary, showing empty space. This breaks the illusion of a cohesive game world and can be disorienting for players. The default bounds of 1000x1000 are arbitrary and won't match most games. Always set worldWidth and worldHeight to match your level's dimensions.


17. โœ… DO: Use Camera Shake for Impact

if (player.crashWith(enemy)) {
    display.camera.shake(8, 8);  // โœ… Impact feedback
}
Enter fullscreen mode Exit fullscreen mode

Why: Camera shake is a powerful visual feedback mechanism that makes impacts feel more intense and satisfying. When the player collides with an enemy, takes damage, or triggers an explosion, a brief camera shake communicates the force of the event to the player. The shake is created by temporarily displacing the camera's position (and optionally its rotation) and then quickly returning it to normal. The camera.shake(x, y) method applies a displacement of x and y pixels, and then automatically reverts after a short duration (approximately 1/24 of a second). This creates a brief, sharp jolt that tells the player "something significant just happened." Without camera shake, impacts can feel flat and unsatisfying. However, camera shake should be used sparingly โ€” too much shake is disorienting and can make players dizzy. A good rule of thumb is to use shake for major events (player hit, boss death, explosion) but not for minor events (collecting a coin, jumping).

โŒ DON'T: Overuse Camera Shake

display.camera.shake(20, 20);  // โŒ Too intense
Enter fullscreen mode Exit fullscreen mode

Why: Camera shake is like spice โ€” a little bit enhances the flavor, but too much ruins the dish. When you use camera shake too frequently or with too much intensity, it becomes disorienting and annoying. Players may feel dizzy or nauseous, especially if the shake is combined with rapid movement. Use camera shake sparingly, for major events only, and keep the intensity moderate. A shake of 5-10 pixels is usually sufficient to convey impact without being overwhelming.


18. โœ… DO: Use camera.follow() Every Frame

function update(dt) {
    display.camera.follow(player, true);  // โœ… Called every frame
}
Enter fullscreen mode Exit fullscreen mode

Why: The camera's follow() method calculates the camera's position based on the target's position. However, follow() only updates the camera's position at the moment it's called. If you call follow() once at the start of the game, the camera will lock onto the player's position at that exact moment and never update again. When the player moves, the camera will stay in its original position, and the player will eventually move off-screen. To keep the camera following the player smoothly, you need to call follow() every frame, typically in your update() loop. This ensures that the camera's position is recalculated based on the player's current position every frame, keeping the player centered on the screen (or at the specified offset). The second parameter, smooth, enables smooth interpolation, which makes the camera movement less jerky by easing the camera toward the target position over several frames. For most games, smooth following is preferable for a polished feel.

โŒ DON'T: Call camera.follow() Once

display.camera.follow(player, true);  // โŒ Called only once
Enter fullscreen mode Exit fullscreen mode

Why: camera.follow() is not a one-time setup โ€” it's a continuous operation that needs to be performed every frame. If you only call it once, the camera will set its position to follow the player at that exact moment and then never update again. As the player moves, the camera stays in its original position, and the player eventually moves off-screen. This completely defeats the purpose of a following camera. Always call camera.follow() in your update() loop to keep the camera tracking the player.


19. โœ… DO: Set Zoom When It Changes

if (display.keys[90]) {
    display.camera.setZoom(1.5);  // โœ… Only when zoom changes
}
Enter fullscreen mode Exit fullscreen mode

Why: The camera.setZoom() method applies a scale transformation to the rendering context, which affects how everything is drawn. This transformation is applied every frame as part of the rendering process. However, if you call setZoom() with the same value every frame, you're forcing the engine to recalculate and apply the same transformation 60 times per second, which wastes CPU cycles. The zoom level typically changes only when the player uses a zoom-in or zoom-out control, or during specific game events (like a sniper scope). For these cases, you should call setZoom() only when the zoom value actually changes. Store the current zoom level in a variable, and only call setZoom() when the variable changes. This reduces unnecessary calculations and improves performance, especially on lower-end devices.

โŒ DON'T: Set Zoom Every Frame

function update(dt) {
    display.camera.setZoom(1.5);  // โŒ Every frame
}
Enter fullscreen mode Exit fullscreen mode

Why: Setting zoom every frame is a waste of CPU cycles. The zoom level is a transformation that's applied to the canvas context. When you call setZoom(), the engine has to recalculate the transformation matrix and apply it to the context. This is a small operation, but when done 60 times per second unnecessarily, it adds up. More importantly, it clutters your code and makes it harder to read. Only set the zoom when it actually changes โ€” typically when the player presses a zoom key or when a game event triggers a zoom change.


๐Ÿ—บ๏ธ TileMaps

20. โœ… DO: Set Fake Canvas Size to World Size

fake.canvas.width = display.camera.worldWidth;
fake.canvas.height = display.camera.worldHeight;
Enter fullscreen mode Exit fullscreen mode

Why: The fake canvas serves as the offscreen buffer for your tilemap. When you render a tilemap, the tiles are drawn onto the fake canvas, and the fake canvas is then drawn as a single image on the main canvas. For this to work correctly, the fake canvas needs to be large enough to hold the entire tilemap. If the fake canvas is smaller than the tilemap, the tiles that fall outside the fake canvas boundaries won't be drawn. If the fake canvas is larger than needed, it wastes memory and might cause performance issues. The ideal size for the fake canvas is exactly the size of the tilemap in pixels, which should match the worldWidth and worldHeight you set for the camera. By setting fake.canvas.width and fake.canvas.height to the world size, you ensure that the entire tilemap fits perfectly on the fake canvas, with no wasted space or missing tiles.

โŒ DON'T: Sync Camera Instead

fake.camera.x = display.camera.x;  // โŒ Defeats caching
Enter fullscreen mode Exit fullscreen mode

Why: The fake canvas is designed to be a static cache. If you start moving the fake camera, you defeat the entire purpose of caching. The fake canvas is meant to be rendered once and then reused as a single image. If you move the fake camera, you're effectively trying to render the tilemap from a different perspective each frame, which means you'd have to redraw the tilemap every frame โ€” exactly what the fake canvas was designed to avoid. The correct approach is to render the entire tilemap once on the fake canvas (with the fake camera at the origin), and then use the main camera to view different parts of the fake canvas.


21. โœ… DO: Use tilemap.crashWith() for Collision

if (tilemap.crashWith(player, 1)) {  // โœ… Collision with walls
    // Handle collision
}
Enter fullscreen mode Exit fullscreen mode

Why: The tilemap.crashWith() method is an efficient way to check collision between a component and any tiles in the tilemap. You can optionally specify a tile ID to check collision only with specific tile types (e.g., ID 1 for walls, ID 2 for water, ID 3 for spikes). The method loops through the tilemap's internal list of tiles and checks collision between the component and each tile of the specified type. This is much more efficient than manually looping through all tiles and checking collision yourself, because crashWith() uses the engine's internal optimized collision detection, and it only checks tiles that are relevant (based on the tile ID filter). If you manually check collision with a for loop, you'll be duplicating logic that the engine already provides, and you'll likely miss the optimization that crashWith() implements. For any tile-based collision, always use tilemap.crashWith().

โŒ DON'T: Use Manual Tile Collision

const tiles = tilemap.tiles(1);
for (let tile of tiles) {
    if (player.crashWith(tile)) {
        // Handle collision
    }
}
Enter fullscreen mode Exit fullscreen mode

Why: Manual tile collision is inefficient and error-prone. You have to manually retrieve the tiles, loop through them, and check collision with each one. The tilemap.crashWith() method does all of this for you, with optimized internal logic that's faster and more reliable. Additionally, crashWith() only checks tiles that are near the component, not all tiles in the map, making it much more performant for large maps. Always use tilemap.crashWith() instead of writing your own collision loop.


โšก Performance

22. โœ… DO: Use Object Pooling for High-Frequency Objects

class BulletPool {
    // Pre-creates bullets and reuses them
}
Enter fullscreen mode Exit fullscreen mode

Why: Object pooling is a design pattern where you pre-create a set of objects and reuse them instead of creating and destroying them on the fly. In JavaScript, creating a new object with new Component() allocates memory, and destroying it with destroy() eventually triggers garbage collection when the memory is reclaimed. Garbage collection pauses the JavaScript engine, causing frame drops and stutter. If you're creating and destroying hundreds of objects per second (like bullets, particles, or enemies), garbage collection will happen frequently, causing visible lag. Object pooling avoids this by creating a fixed number of objects upfront and reusing them. When you need a bullet, you take one from the pool. When the bullet is done, you return it to the pool (by hiding it and resetting its properties). This eliminates constant allocation and garbage collection, resulting in smoother performance. For any game with high-frequency object creation and destruction, object pooling is essential.

โŒ DON'T: Create and Destroy Objects Constantly

function shoot() {
    const bullet = new Component(5, 10, "yellow", player.x, player.y, "rect");
    display.add(bullet);  // โŒ Creates new object every time
}
Enter fullscreen mode Exit fullscreen mode

Why: Creating and destroying objects constantly is a performance killer. Each new allocates memory, and each destroy() eventually triggers garbage collection. Garbage collection pauses the JavaScript engine to clean up unused memory, causing frame drops and stutter. In a game where the player shoots 10 bullets per second, you're creating 10 new objects per second and destroying them shortly after. This will cause frequent garbage collection, making your game feel laggy and unresponsive. Object pooling is a simple and effective solution to this problem.


23. โœ… DO: Remove Objects from Arrays

enemies.splice(i, 1);  // โœ… Removes from array
Enter fullscreen mode Exit fullscreen mode

Why: When you use splice() to remove an element from an array, the array shrinks, reducing the number of iterations in future loops. This is especially important if you're iterating over the array frequently, such as in the update() loop where you process enemy AI, movement, and collision. A smaller array means fewer loop iterations, which means less CPU usage and better performance. Additionally, splice() properly removes the object from the array, allowing the garbage collector to reclaim its memory. If you instead set the element to null, the array remains the same size, and you'll need to check for null values in your loops, which adds unnecessary branching and CPU overhead. Over time, a large array with many null values will slow down your game. Always use splice() (or filter()) to remove objects from arrays.

โŒ DON'T: Set Objects to null

enemies[i] = null;  // โŒ Still in array
Enter fullscreen mode Exit fullscreen mode

Why: Setting an element to null doesn't remove it from the array โ€” it just leaves an empty slot. The array length stays the same, which means your loops will still iterate over that slot. You'll have to add a check for null in your loops, which adds an extra branch and wastes CPU cycles. Over time, as more and more objects are set to null, your arrays will grow large with many empty slots, and your loops will waste time checking for null values. Use splice() to actually remove elements from the array.


24. โœ… DO: Use clearMargin Optimization

display.clearMargin = [800, 600];  // โœ… Optimized
Enter fullscreen mode Exit fullscreen mode

Why: The clearMargin array controls the size of the area that the engine clears on the canvas before rendering. By default, it's set to [width*width, height*height], which is the square of the canvas dimensions. For a canvas sized 800x600, this defaults to [640000, 360000] โ€” a massive area far larger than the visible canvas. This means the engine is clearing a huge rectangle off-screen, which wastes CPU cycles and can hurt performance. By setting clearMargin to the actual canvas dimensions (e.g., [800, 600]), you reduce the cleared area to exactly what's visible on screen, saving CPU cycles and improving performance. This optimization is especially noticeable on lower-end devices. Always set clearMargin to match your canvas size after calling display.start() or display.scale().

โŒ DON'T: Keep the Default

// display.clearMargin = [640000, 360000];  // โŒ Huge area
Enter fullscreen mode Exit fullscreen mode

Why: The default clearMargin is [width*width, height*height], which for a 800x600 canvas is [640000, 360000]. This is far larger than the visible canvas, meaning the engine is clearing a huge area off-screen that will never be drawn. This wastes CPU cycles and can cause performance issues, especially on lower-end devices. Always set clearMargin to the actual canvas dimensions to minimize the clearing area.


๐ŸŽต Audio

25. โœ… DO: Preload Sounds Before display.start()

const sound = new Sound("jump.wav");  // โœ… Preloaded
display.start(800, 600);
Enter fullscreen mode Exit fullscreen mode

Why: Sound files need to be loaded from the server before they can be played. The Sound class loads the audio file asynchronously in the background. If you create a Sound object and immediately call play(), the audio file may not be fully loaded yet, causing a delay or a silent failure. Worse, if you create a Sound object during gameplay (e.g., in the update() loop), you're forcing the engine to load an audio file while the game is running, which causes lag and stutter. To avoid this, you should preload all sounds before the game starts. Create your Sound objects at the beginning of the game (before calling display.start()), and store them in variables or in a SoundManager. This gives the audio files time to load while the game is initializing, ensuring that they're ready to play when you need them. Preloading is especially important for sounds that play frequently, like jump sounds or collision sounds.

โŒ DON'T: Play Sounds Without Preloading

function update() {
    const sound = new Sound("jump.wav");  // โŒ Created during gameplay
    sound.play();
}
Enter fullscreen mode Exit fullscreen mode

Why: Creating a Sound object during gameplay forces the engine to load the audio file while the game is running. This causes a delay while the file is downloaded and decoded, resulting in lag and stutter. This is especially problematic for sounds that play frequently, like jump sounds or collision sounds, because the lag would happen every time the sound plays. Preloading all sounds at the start of the game eliminates this issue, ensuring that sounds play instantly when triggered.


26. โœ… DO: Use move.sound.* for Quick Audio

move.sound.play("jump");  // โœ… Shortcut
Enter fullscreen mode Exit fullscreen mode

Why: The move.sound object provides a convenient shortcut for playing sounds that have been loaded into the global soundManager. Instead of writing if (soundManager) { soundManager.play("jump"); }, you can simply write move.sound.play("jump"). The move.sound wrapper handles null references (if soundManager doesn't exist) and manages volume automatically. It also provides shortcuts for music control (move.sound.playMusic(), move.sound.stopMusic()) and global volume control (move.sound.setMasterVolume(), move.sound.mute()). Using move.sound reduces code verbosity and makes your audio code cleaner and easier to read. For simple audio playback, always prefer move.sound over manually calling soundManager.

โŒ DON'T: Manually Handle Audio for Simple Cases

if (soundManager) {
    soundManager.play("jump");  // โŒ More code
}
Enter fullscreen mode Exit fullscreen mode

Why: Manually checking for soundManager and calling play() is verbose and repetitive. The move.sound wrapper handles these checks for you, with less code and less room for error. Additionally, move.sound provides volume management and other features that you'd have to implement yourself if you manually handled audio. Use move.sound for cleaner, more reliable audio code.


๐Ÿงช Testing & Debugging

27. โœ… DO: Use console.log() for Debugging

console.log("Player position:", player.x, player.y);  // โœ… Debug
Enter fullscreen mode Exit fullscreen mode

Why: The console.log() function is a simple and effective way to understand what's happening in your game. By logging variables, positions, and states, you can trace the flow of your game and identify where things are going wrong. For example, if your player isn't moving, you can log the speedX and speedY values to see if they're being set correctly. If a collision isn't working, you can log the positions of the colliding objects to see if they're overlapping. Logging is especially useful for reproducing and diagnosing bugs that are hard to see visually. However, it's important to remove or comment out debug logs in production builds, as logging every frame can slow down the game and clutter the console with unnecessary information. Use logging liberally during development, but clean it up before releasing your game.

โŒ DON'T: Leave Debug Logs in Production

console.log("Frame:", display.frameNo);  // โŒ Debug in production
Enter fullscreen mode Exit fullscreen mode

Why: Logging every frame is a performance drain. Each console.log() call takes time, and if you're logging 60 times per second, it can add up and slow down your game. Additionally, leaving debug logs in production clutters the console with unnecessary information, making it harder to see important messages. Always remove debug logs before releasing your game.


28. โœ… DO: Test on Different Browsers

// โœ… Test on Chrome, Firefox, Edge, Safari
Enter fullscreen mode Exit fullscreen mode

Why: Different browsers implement JavaScript, Canvas, and audio differently. What works perfectly in Chrome might break in Safari, or what's fast in Firefox might be slow in Edge. This is especially true for advanced features like WebGL, audio contexts, and touch events. To ensure your game works for all users, you should test on multiple browsers, including different versions. At minimum, test on Chrome (the most common browser), Firefox (for its excellent developer tools), and Safari (for iOS users). If possible, also test on Edge and on mobile browsers (Safari on iOS, Chrome on Android). Testing on different browsers will help you catch compatibility issues early and ensure that your game provides a consistent experience for all players.

โŒ DON'T: Only Test on One Browser

// โŒ Only test on Chrome
Enter fullscreen mode Exit fullscreen mode

Why: Testing only on Chrome is a common mistake. While Chrome is the most popular browser, it's not the only one. Safari, Firefox, and Edge all have significant market shares. Features that work in Chrome may not work in other browsers, or may work differently. By only testing on Chrome, you risk releasing a game that's broken for a significant portion of your audience. Always test on multiple browsers to ensure compatibility.


๐Ÿ“š Documentation

29. โœ… DO: Comment Your Code

// โ”€โ”€ PLAYER MOVEMENT โ”€โ”€
// Arrow keys move the player at 200px/s
Enter fullscreen mode Exit fullscreen mode

Why: Comments are essential for understanding your own code, especially when you come back to it after a few weeks or months. A well-commented codebase is easier to debug, easier to extend, and easier to share with others. Comments should explain the "why" behind the code โ€” the intent, the reasoning, and the trade-offs. For example, instead of just writing if (display.keys[37]) player.speedX = -200 * dt;, you might comment // Left arrow: move player left at 200px/s, using dt for frame-rate independence. This helps you (and others) quickly understand what the code does without having to decipher it. Comments are especially useful for complex logic, math, and game mechanics. Always comment your code, especially the parts that aren't immediately obvious.

โŒ DON'T: Leave Code Uncommented

if (display.keys[37]) player.speedX = -200 * dt;
Enter fullscreen mode Exit fullscreen mode

Why: Uncommented code is hard to understand. When you come back to your code after a few weeks, you might not remember why you wrote a particular line or what it's supposed to do. This makes debugging and extending your code much more difficult. Comments help you and others understand the code more quickly and with less effort.


30. โœ… DO: Use Meaningful Variable Names

const playerSpeed = 200;  // โœ… Clear
Enter fullscreen mode Exit fullscreen mode

Why: Meaningful variable names make your code self-documenting. When you read const playerSpeed = 200;, you immediately know that this variable represents the player's movement speed. When you read const a = 200;, you have no idea what a represents. This becomes a problem when you have dozens of variables โ€” you'll constantly have to remember what each one does, or look back at where it was defined. Meaningful names reduce cognitive load, make code easier to read and maintain, and reduce the number of bugs caused by misinterpretation. In general, variable names should be descriptive but not overly long. playerSpeed is a good name; playerMovementSpeedInPixelsPerSecond is too long. Avoid abbreviations that aren't immediately obvious (pSpeed vs playerSpeed). Good naming is a habit that pays off in every line of code you write.

โŒ DON'T: Use Cryptic Names

const a = 200;  // โŒ What is 'a'?
Enter fullscreen mode Exit fullscreen mode

Why: Cryptic variable names make code hard to read and understand. When you use names like a, b, x, y, or temp, you're forcing yourself and others to remember what each variable represents. This is especially problematic in longer functions where you might have many variables. Meaningful names make your code self-documenting and reduce the need for comments. Always use descriptive names that clearly indicate what the variable represents.


๐ŸŽฏ Final Tips

31. โœ… DO: Have Fun and Experiment

// โœ… Try new things, break things, learn from mistakes
Enter fullscreen mode Exit fullscreen mode

Why: Game development is a creative and iterative process. The best way to learn is to experiment, try new things, and make mistakes. Don't be afraid to break your game โ€” that's how you learn what works and what doesn't. Every mistake teaches you something valuable, whether it's a subtle bug that took hours to find or a design choice that didn't work out. The most successful game developers are the ones who aren't afraid to fail and learn from their failures. So have fun with it! Try weird ideas, push the engine to its limits, and see what you can create. The journey of learning and building is just as rewarding as the final product.

โŒ DON'T: Be Afraid to Make Mistakes

// โŒ Fear of breaking things prevents learning
Enter fullscreen mode Exit fullscreen mode

Why: Fear of making mistakes is one of the biggest obstacles to learning. If you're afraid to break things, you'll never try new things, and you'll never grow as a developer. Mistakes are a natural part of the learning process. Every developer, from beginners to experts, makes mistakes. The key is to learn from them and move on. Don't be afraid to experiment, try new features, and push the boundaries of what you can create.


๐Ÿ“Š Quick Reference Table

# Do Don't
1 Call perform() before start() Call start() before perform()
2 Name your display instance display Use a different variable name
3 Call perform() before using TileMaps Use TileMap without perform()
4 Use deltaTime for movement Use raw numbers for speed
5 Normalize diagonal movement Ignore diagonal normalization
6 Use display.add() for every component Forget to add components
7 Use setImage() for images Directly assign image properties
8 Use destroy() for permanent removal Hide objects you'll never use again
9 Use hide() for temporary invisibility Use destroy() for temporary removal
10 Use Tctxt for UI text Use Component for text
11 Call .fixed() every frame Call .fixed() once
12 Use move.bound() to keep objects on screen Write your own edge detection
13 Use accelerate() and decelerate() for physics Directly set speed for physics
14 Use fake canvas for static content Put static content on the main canvas
15 Call fake.refresh() after changes Forget to refresh
16 Set worldWidth and worldHeight Leave world bounds unset
17 Use camera shake for impact Overuse camera shake
18 Call camera.follow() every frame Call camera.follow() once
19 Set zoom when it changes Set zoom every frame
20 Set fake canvas size to world size Sync camera instead
21 Use tilemap.crashWith() Use manual tile collision
22 Use object pooling for high-frequency objects Create and destroy objects constantly
23 Remove objects from arrays Set objects to null
24 Use clearMargin optimization Keep the default
25 Preload sounds before display.start() Play sounds without preloading
26 Use move.sound.* for quick audio Manually handle audio
27 Use console.log() for debugging Leave debug logs in production
28 Test on different browsers Only test on one browser
29 Comment your code Leave code uncommented
30 Use meaningful variable names Use cryptic names
31 Have fun and experiment Be afraid to make mistakes

๐ŸŽฏ The One-Line Summary

"Follow these 31 do's and don'ts to write better, faster, and more reliable Limn Engine games."


Draw your game into existence โ€” the right way. ๐ŸŽฎ๐Ÿš€

Top comments (0)