DEV Community

Cover image for 🧹 How I Cleaned Up Limn Engine's Global Variables (and Fixed Two Bugs Along the Way)
Kehinde Owolabi
Kehinde Owolabi

Posted on

🧹 How I Cleaned Up Limn Engine's Global Variables (and Fixed Two Bugs Along the Way)

🧹 How I Cleaned Up Limn Engine's Global Variables (and Fixed Two Bugs Along the Way)

A refactor that made the engine safer to use, fixed a tile corruption bug, and corrected a particle system issue that had been hiding in plain sight.


🎯 Try the New Version

Play a game built on the updated engine:

👉 limn-engine-doc.vercel.app/arcade

Every game on the arcade runs on this version of the engine.

Grab the source:

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


📖 Introduction

Limn Engine started as a single file with a handful of global variables. That worked fine when the engine and the games were written by the same person, on the same machine, in the same session. But once games started being published to the Limn Arcade — a community platform where anyone can upload a game — the globals became a liability.

This article walks through the cleanup that fixed that. Along the way, two real bugs surfaced: a tile corruption bug that broke levels after restart, and a particle system bug that made every particle invisible on any scene other than the default.

Here's what we're going to cover:

  • Why global variables were a problem in the first place
  • How the refactor moved everything into a single namespace
  • The tile bug — what caused it, and how concat fixed it
  • The particle bug — what caused it, and how a scene parameter fixed it
  • A sound system simplification
  • What this means for existing games

🐛 The Problem with Globals

What we're going to look at: The original engine's top-level variables.

Before the cleanup, the top of epic.js looked like this:

let com;
let refresh = false;
let TCJSgameGameArea;
let commp = [];
let tileComm = [];
const comm = [];
Enter fullscreen mode Exit fullscreen mode

Six globals, all sitting directly in the window's scope. Any script loaded on the same page could read them, overwrite them, or shadow them by accident. If a game defined its own let comm = ..., the engine's internal component list would silently be replaced — and the game would stop drawing anything.

The risk isn't theoretical. Two files on the same page — one being the engine, one being a game — share exactly one global scope. Every top-level declaration is a potential collision.


🛠️ What We're Going to Build

We're going to move every internal engine variable into a single object called TCJSgameVariable. That object becomes the engine's private state, and anything that wants to reach into the engine has exactly one name to remember.

While we're in there, we're going to fix two bugs that surfaced during the move.

The first is a TileMap bug that mutated the caller's tile array in place, breaking levels after a restart. The second is a ParticleSystem bug that always added particles to scene 0, making them invisible on any other scene.

And we're going to simplify the sound system by removing a broken clone-on-overlap code path.

Let me walk through each change.


🔧 Step 1: The Namespace

What we're going to do: Replace the six globals with properties on a single object.

let TCJSgameGameArea;
let TCJSgameVariable = {
  fileLoaded: true,
}
TCJSgameVariable.com = null;
TCJSgameVariable.refresh = false;
TCJSgameVariable.TCJSgameGameArea;
TCJSgameVariable.commp = [];
Enter fullscreen mode Exit fullscreen mode

What we just did: We created TCJSgameVariable as the home for every internal engine value. com (the last-added component wrapper), refresh (the FPS flag), commp (the fake canvas component list), and TCJSgameGameArea (the visibility check component) are now all namespaced.

We kept TCJSgameGameArea as a global too, because existing games reference it directly. The global is set at the same time the namespace property is set, so both stay in sync.


🔧 Step 2: Updating References

What we're going to do: Change every internal use of the old globals to use the namespaced versions.

Inside Display.add():

add(x, scene = 0) {
  TCJSgameVariable.com = { x: x, scene: scene };
  TCJSgameVariable.comm.push(TCJSgameVariable.com);
}
Enter fullscreen mode Exit fullscreen mode

Inside Display.updat():

TCJSgameVariable.comm.forEach((component) => {
  if (component.scene == this.scene) {
    // ...
  }
});
Enter fullscreen mode Exit fullscreen mode

Inside Component.destroy():

let index = TCJSgameVariable.comm.findIndex((c) => c.x === this);
if (index > -1) TCJSgameVariable.comm.splice(index, 1);

index = TCJSgameVariable.commp.findIndex((c) => c.x === this);
if (index > -1) TCJSgameVariable.commp.splice(index, 1);
Enter fullscreen mode Exit fullscreen mode

What we just did: Every read and write of the old globals now goes through TCJSgameVariable. Same logic, same behaviour — but the engine's state is now contained.

One thing to note: TCJSgameVariable.comm is declared as a plain array at the top of the file, but the actual let comm = [] line was renamed. If you're looking for where the component list lives now, it's TCJSgameVariable.comm.


🐛 Step 3: The Tile Bug

What we're going to do: Fix a bug in TileMap that corrupted the level data after restart.

Here was the original code:

class TileMap {
  constructor(render, map, tile, width, height, scene = 0) {
    this.map = [map];
    this.width = width;
    this.height = height;
    fake.canvas.width = width;
    fake.canvas.height = height;
    this.tile = tile;
    this.tile.unshift(0);   // ← This is the bug
    this.tileHeight = this.height / this.map[0].length;
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

What was wrong: unshift(0) mutates the caller's array in place. Every time a TileMap was constructed, another 0 was inserted at the front of the user's tile array.

The first level load looks like this:

// User's array
tile = [grass, stone, gold, spike, brick];

// After TileMap construction
tile = [0, grass, stone, gold, spike, brick];
Enter fullscreen mode Exit fullscreen mode

The second level load — say, after a restart — looks like this:

// Same user array
tile = [0, grass, stone, gold, spike, brick];

// After TileMap construction
tile = [0, 0, grass, stone, gold, spike, brick];
Enter fullscreen mode Exit fullscreen mode

Now tile[1] is 0 instead of grass. Every tile lookup is shifted by one. The entire map renders with the wrong tile IDs.

Here's the fix:

this.tile = [0].concat(tile);
Enter fullscreen mode Exit fullscreen mode

What we just did: We create a new array with 0 prepended, leaving the caller's array untouched. The 0 at index 0 still serves as the "empty tile" placeholder, but the user's data is never mutated. Reloading the level any number of times produces identical results.

This bug was probably invisible for a long time because many games only loaded a level once. It only surfaced when a game restarted — or when a scene changed back to the game scene after visiting a menu.


🐛 Step 4: The Particle Bug

What we're going to do: Fix ParticleSystem so particles are added to the scene the game is actually using.

Here was the original code:

class ParticleSystem {
  constructor(display) {
    this.display = display;
    this.particles = [];
    this.emitters = [];
  }

  emit(x, y, options = {}) {
    const particle = new Particle(x, y, options);
    this.particles.push(particle);
    this.display.add(particle);   // ← No scene argument, defaults to 0
    return particle;
  }
}
Enter fullscreen mode Exit fullscreen mode

What was wrong: display.add(particle) defaults to scene 0. If the game runs on scene 1 — which is where every game that uses menus lives — the particle is added to a scene that isn't being rendered. It exists. It updates. But it's never drawn.

This meant particles were silently invisible in any game that used the scene system. Explosions, sparkles, blood, smoke — none of it showed up. The code all ran without errors. The particles just never appeared.

Here's the fix:

class ParticleSystem {
  constructor(display = display, scene = 0) {
    this.display = display;
    this.particles = [];
    this.emitters = [];
    this.scene = scene;
  }

  emit(x, y, options = {}) {
    const particle = new Particle(x, y, options);
    this.particles.push(particle);
    this.display.add(particle, this.scene);
    return particle;
  }
}
Enter fullscreen mode Exit fullscreen mode

What we just did: The ParticleSystem now stores the scene number it should render to, and passes it to display.add() for every particle. A game that runs on scene 1 can now create particles that actually appear.

The default value of scene = 0 preserves backwards compatibility with any existing code that used particles on the default scene.


🔊 Step 5: The Sound System Simplification

What we're going to do: Remove a broken clone-on-overlap code path from Sound.play().

Here was the original:

play(volume = this.volume) {
  if (!this.loaded) {
    console.warn(`Sound not loaded yet: ${this.src}`);
    return this;
  }

  // Clone for overlapping sounds (effects)
  if (this.loop === false && this.audio.currentTime > 0) {
    const clone = new Sound(this.src, { volume: volume });
    clone.play();
    return clone;
  }

  this.audio.volume = volume;
  this.audio.currentTime = 0;
  this.audio.play();
  return this;
}
Enter fullscreen mode Exit fullscreen mode

What was wrong: The clone-on-overlap block looked clever, but it never worked. When you create new Sound(...), the constructor starts loading the file asynchronously. The loaded flag doesn't become true until the browser fires canplaythrough — which happens on a later microtask, not synchronously.

So when clone.play() is called on the very next line, clone.loaded is still false. The guard at the top of play() returns immediately, and the clone never plays. Every "overlap" was silently dropped.

The fix is simply to remove the block:

play(volume = this.volume) {
  if (!this.loaded) {
    console.warn(`Sound not loaded yet: ${this.src}`);
    return this;
  }

  this.audio.volume = volume;
  this.audio.currentTime = 0;
  this.audio.play().catch((e) => console.warn("Audio play prevented:", e));
  return this;
}
Enter fullscreen mode Exit fullscreen mode

What we just did: Now every call to play() produces audible sound. If the sound is already playing, currentTime = 0 rewinds it and play() restarts it. That's a much better default than a code path that silently fails.

If layering is ever needed — for example, a shotgun spread playing two distinct shots in the same frame — the right fix is to pre-build a small pool of Audio elements at load time and cycle through them. Cloning on demand, which is what the old code tried to do, will always fail for the same reason.


📊 Before and After

Aspect Before After
Engine state 6+ globals in the window scope Contained in TCJSgameVariable
Level restart Tile IDs corrupted by repeated unshift Levels reload correctly
Particles on non-zero scenes Silently invisible Correctly added to the game's scene
Overlapping sounds Broken clone-on-overlap path Restarts the sound cleanly
Backwards compatibility N/A TCJSgameGameArea still global

📊 What You've Learned

Concept Why It Matters
Namespacing Keeps engine internals from colliding with user code
concat vs unshift unshift mutates the caller's array — concat returns a new one
Scene-aware object creation Objects added to the wrong scene are invisible, not broken
Async loading pitfalls Anything that depends on .loaded must wait for the load event
Clone-on-demand A clone that hasn't loaded can't play
Backwards compatibility shims Keeping a global alive while also namespacing it costs nothing

⚠️ A Note on Backwards Compatibility

If any existing game on the arcade references comm, commp, tileComm, or refresh directly, those references will now throw ReferenceError. In practice, none of the published games do — those were never part of the documented API — but it's worth knowing.

The engine itself has been updated to use TCJSgameVariable.* throughout, so the internal reference path is complete. Only external code that reached into engine internals would be affected.

The TCJSgameGameArea global is preserved because it was used externally. Both the global and the namespace property are set in Display.start().


🚀 What's Next?

Now that the engine's state is contained, the next cleanup targets are:

  • Wrapping the engine in an IIFE or ES module so even TCJSgameVariable isn't on the window
  • Adding JSDoc comments to every public method
  • Renaming confusing variables like comm and commp to componentList and fakeComponentList
  • Adding a timeScale property to Display so games can pause cleanly without setting dt to 0

If you build any of these, send a pull request.


🐛 Report Bugs

If you find a bug introduced by this refactor, report it on GitHub:

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


🔗 Resources


🎯 The One-Line Summary

"Moving Limn Engine's globals into a namespace also surfaced a tile corruption bug and a particle scene bug — both fixed in the same pass." 🧹🚀


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

Top comments (0)