How we tried to build a whole game engine out of stubbornness, and what the browser had to say about that.
📖 Introduction: The "We Can Build It" Syndrome
Every developer starts a project with a dangerous amount of confidence. The day we decided to build a custom HTML5 canvas arcade shooter, we walked into the room like champions — "no shaking, we go handle am." No pre-built modules, no shortcut libraries, no dependencies. Just us, a blank browser tab, and a keyboard doing overtime past midnight.
We genuinely thought hand-coding the physics, the enemy spawn logic, and the bullet arrays would be a calm weekend job. Instead, the CPU started shouting the moment two enemies appeared on screen at once. Frame rate dropped to single digits like it was on strike. Our code turned into spaghetti — and not the good kind you enjoy on a Sunday. It showed us pepper.
We were busy reinventing the wheel from zero, completely blind to the fact that a solid core engine — epic.js — was sitting right there, fully able to carry the heavy part of the matter for us.
This isn't just a "we failed, then we won" story, though. We're going to show you exactly why the naive version fell apart, line by line, and exactly what replaced it — so you can skip the three days we spent finding this out the hard way.
The one-line summary: We tried to build an arcade shooter entirely by hand, our browser humbled us in public, and switching to a pooled, delta-time-driven architecture on epic.js is the thing that actually saved the project.
🪤 Sector 1: Falling Into the "Pure Scratch" Trap
Every developer passes through that phase where using an existing framework feels like cheating. We want total control. We want to write every variable ourselves so we can beat our chest later and say "I built this from the ground, nobody helped me."
Our early object-tracking system was pure chaos. Instead of a clean entity manager, bullets went straight into a raw, unmanaged array that looped endlessly on the main thread:
// Our painful, hand-coded bullet loop
function updateBullets() {
for (let i = 0; i < playerBullets.length; i++) {
playerBullets[i].y -= 10; // manual coordinate tracking
if (playerBullets[i].y < 0) {
playerBullets.splice(i, 1);
i--;
}
}
}
This loop worked fine when there was one lonely bullet on screen, moving like a snail with nowhere to be. But scale that up to a real arcade wave — lasers, particle sparks, three enemy formations descending at once — and the browser will very quickly show you pepper.
🧯 Sector 2: Why Our Hand-Coded Architecture Packed Up (The Actual Technical Reason)
It didn't take long for the DIY masterpiece to scatter completely. On a web canvas, performance is not a bonus feature — it's the entire difference between a smooth arcade game and a slideshow with extra steps.
Here's the part most devlogs skip: why exactly that loop was the problem, not just that it was slow.
Reason 1 — splice() inside a loop is quietly expensive. Every time splice(i, 1) removes a bullet, JavaScript has to shift every element after it one position to the left to close the gap. That's an O(n) operation, happening potentially dozens of times per frame, inside a loop that's already O(n). With 5 bullets on screen, you'd never notice. With 200 bullets, particles, and enemies all doing this every frame, you're paying that shifting cost constantly — this alone was a big chunk of our frame drop.
Reason 2 — we were creating and destroying objects every frame. Every new bullet was { x, y, ... } freshly allocated with push(), then thrown away with splice() a second later. JavaScript's garbage collector has to clean up after all that churn, and garbage collection pauses are exactly the kind of thing that shows up as a stutter, not a steady slowdown — which is why our frame rate wasn't just low, it was inconsistent.
Reason 3 — collision checks were brute-force. Every bullet was checked against every enemy, every frame — an O(n × m) comparison with no shortcuts. Ten bullets and ten enemies is 100 checks. Fine. Fifty bullets and thirty enemies is 1,500 checks, every single frame, on the main thread, alongside rendering.
Stacked together, here's roughly what that did to us on a mid-range laptop:
| Scenario | Active bullets/particles | Frame rate |
|---|---|---|
| One bullet on screen | ~1 | 60 FPS, smooth |
| A real wave (lasers + enemies + sparks) | ~180 | 6–9 FPS |
| Same wave, boss fight | ~350 | Effectively frozen |
Our custom approach failed for three structural reasons on top of the raw math:
| Reason | What it actually looked like |
|---|---|
| CPU overload | Every bullet trajectory, boundary check, and collision box was computed manually on the main thread — the browser choked under the weight |
| Tightly coupled code | Game logic, rendering, and input listeners were tangled like cheap earphone wires — touch one variable, three unrelated features scatter |
| The "reinventing the wheel" tax | Three full days spent hand-writing collision logic that solid engine frameworks had already solved years ago |
We were so busy trying to prove a point that we forgot the first rule of smart engineering: don't carry heavy load when a trailer is passing free of charge. Ego writes the first draft. Engineering rewrites it.
🚦 When to walk away from pure-scratch: if your frame rate dips below 30 FPS with fewer than 100 objects on screen, stop optimizing your hand-rolled version and start integrating an engine. That's the reinvention tax ceiling — past that point, you're paying in days what a framework already solved in years.
To be fair to our stubborn selves, though — building it wrong first wasn't pure waste. If your goal is learning how canvas internals actually work, keep going by hand, that struggle is the whole point. Just don't ship the hand-rolled version to production. Know which one you're doing before you start.
🔧 Sector 3: The Actual Fix — Object Pooling and Delta Time
Before we get to epic.js itself, we want to show the two changes that mattered most — because you can apply both of these in any canvas game, engine or no engine.
Fix 1 — Object pooling (stop creating and destroying bullets)
Instead of constantly allocating new bullet objects and throwing them away, we pre-allocate a fixed pool once, and just recycle inactive ones:
// A simple bullet pool — allocate once, reuse forever
const BULLET_POOL_SIZE = 200;
const bulletPool = Array.from({ length: BULLET_POOL_SIZE }, () => ({
x: 0, y: 0, active: false
}));
function spawnBullet(x, y) {
const bullet = bulletPool.find(b => !b.active);
if (!bullet) return; // pool exhausted, skip spawn this frame
bullet.x = x;
bullet.y = y;
bullet.active = true;
}
function updateBullets(deltaTime) {
for (const bullet of bulletPool) {
if (!bullet.active) continue;
bullet.y -= 600 * deltaTime; // speed in px/sec, not px/frame
if (bullet.y < 0) bullet.active = false; // recycle, don't destroy
}
}
No splice(). No push(). No garbage collector chasing after us. We just flip a boolean. This one change took our bullet-heavy scenes from stuttering to steady on the same laptop.
Fix 2 — Delta time instead of fixed pixel steps
Our original loop moved bullets by a flat 10 pixels every frame — which means speed was secretly tied to frame rate. Drop to 30 FPS, and every bullet moves at half the speed it should, which makes a slow machine feel even more broken than it is.
let lastTime = performance.now();
function gameLoop(currentTime) {
const deltaTime = (currentTime - lastTime) / 1000; // seconds since last frame
lastTime = currentTime;
updateBullets(deltaTime);
render();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
With delta time, updateBullets always knows how much real time passed, so movement stays consistent whether the game is running at 60 FPS on a good laptop or 25 FPS on a tired one. Speed becomes pixels per second, not pixels per frame — a small change with a big effect on how "broken" a slow device feels.
Where epic.js comes in (and where it honestly doesn't)
Let's be precise here, because we don't want to overclaim what the engine hands you for free. Once our engine version was routed correctly:
if (versionDropdown) {
let selectedVersion = versionDropdown.value.toLowerCase();
if (selectedVersion.includes('v4')) {
engineScriptFile = "https://limn-engine-doc.vercel.app/asset/epic.js";
}
}
...here's what epic.js actually gave us, straight from the source:
A component system with built-in collision, so we stopped hand-writing AABB math. Every game object is a Component — you give it a width, height, position, and it comes with .crashWith(otherObject) for rectangle collision (with rotation support baked in) and .enableCircleCollision() / .crashWithCircle() if you'd rather check circles:
const bullet = new Component(4, 12, "yellow", playerX, playerY);
bullet.speedY = -10;
display.add(bullet);
// later, in your own update loop:
if (bullet.crashWith(enemy)) {
bullet.destroy(); // removes it from the engine's render list for you
enemy.destroy();
}
We didn't have to write crashWith() ourselves — that was three of our "reinvented the wheel" days, gone.
Automatic viewport culling, which is the real reason the frame rate recovered. Here's the part we didn't expect: the engine checks each component's Rect-like x property — which holds x, y, width, and height — against the game area bounds before doing any work on it:
if (TCJSgameGameArea.crashWith(component.x)) {
component.x.update(display.context);
}
Off-screen bullets, particles, and enemies still exist in memory, but they stop costing us render and update time the moment they leave the visible area. That single check quietly did more for our frame rate than anything we'd hand-rolled ourselves — it wasn't pooling that saved us, it was the engine simply refusing to do work on things nobody could see.
What it does not give you for free: object pooling. This is the honest part. Objects go into a global list via display.add() and come back out via .destroy(), which does a findIndex + splice() under the hood — the exact same pattern we were trying to escape in Sector 1. Even the engine's own particle system creates and destroys particles this way. If you want true pooling — pre-allocating and recycling — that's still on you to build on top of Component, the same way we showed in the manual fix above.
Speed is still per-frame, not per-second, by default. move() applies this.x += this.speedX directly — no delta-time scaling built in. display.deltaTime is exposed for you to use in your own update() function, but the engine won't apply it for you automatically. So the delta-time discipline from the fix above still matters, even once you're on epic.js — the engine gives you the tools, not the guarantee.
Being honest about it: this wasn't instant magic. Wiring epic.js into a codebase that had spent three days being hand-rolled and tightly coupled meant untangling our own mess first — pulling rendering logic out of the input listeners it had no business living inside. The framework didn't fix our architecture. It gave us collision handling and free culling. Pooling and delta time discipline were still ours to get right.
So when do you actually switch?
We were three days in when we switched. That's not a universal number — here's a rougher rule of thumb than "just use an engine":
| Your situation | What to do |
|---|---|
| Object count under ~50, frame rate is fine | Stay pure. You don't have a performance problem yet. |
| Object count over ~50, frame rate holds but you're hand-writing AABB/circle collision | Grab a collision-only library or a crashWith()-style utility — you don't need a full engine yet. |
| Object count over ~50 and frame rate is dropping and you're hand-writing collision | Grab the engine. You've hit both problems at once — that's the actual signal, not a fixed day count. |
| You're doing this specifically to learn canvas internals | Keep going by hand, on purpose — just don't ship that version. |
The difference, in numbers
| Version | Active bullets/particles | Frame rate |
|---|---|---|
Original — splice(), no delta time, checking everything on-screen or not |
~180 | 6–9 FPS |
| Manual fix — pooling + delta time, still checking everything | ~180 | 48–55 FPS |
On epic.js — built-in crashWith() + automatic viewport culling, our own pooling on top |
~350 | Steady 60 FPS |
Same laptop, same scene, three different architectures. The lesson wasn't "epic.js is magic" — it's that pooling and delta time recovered most of the performance ourselves, and the engine's free viewport culling plus not having to hand-write collision math is what let us push the object count even higher on top of that.
🎓 Sector 4: Hard-Earned Lessons From the Trenches
Some lessons don't come from documentation. They come from watching your frame rate crawl at 1 AM and asking yourself hard questions.
Under-the-hood mechanics still matter. Writing those messy loops by hand taught us why canvas rendering contexts and coordinate grids behave the way they do. You genuinely have to break something manually at least once to understand how it ticks — the pain wasn't wasted, even if the code was.
Frameworks aren't cheating. Leaning on a solid core asset like epic.js isn't cutting corners — it's smart architecture. It frees up your mental bandwidth for the part that actually moves the needle: game design and how it feels to play.
Knowing when to pivot is a skill, not a defeat. Admitting your hand-coded system has become the bottleneck isn't failure — it's a checkpoint in engineering maturity. The stubborn version of us needed to hear that first.
📊 What You've Learned
| Concept | How It Applies |
|---|---|
splice() cost |
Removing from the middle of an array shifts every element after it — expensive when it happens every frame |
| Object pooling | Recycle objects instead of creating/destroying them — cuts garbage collection stutter dramatically |
| Delta time | Move objects by speed × secondsElapsed, not a flat number per frame, so speed stays consistent across devices |
| Viewport culling | Skipping update/render work on objects outside the visible area can matter more than clever collision math |
| Read your dependency's source | We assumed epic.js did pooling for us — it doesn't. Checking the actual code saved us from shipping on a wrong assumption |
| The reinvention tax | Solving a problem a mature framework already solved costs days you don't get back |
🚀 Conclusion & What's Next
Building an arcade shooter from scratch taught us humility, patience, and the real value of proper tool selection. Dropping the ego, ending the "pure scratch" marathon, and integrating epic.js is what saved this project from a very avoidable disaster.
Here's the receipts — the editor running the real V4 build, and the actual arcade wave rendering steady instead of crawling:
If you're currently hand-coding every single piece of your own engine, take a breath. Step back, look at your tools honestly, and let clean architecture carry some of the weight for you. And if you take nothing else from this, at least go add object pooling and delta time to whatever you're building right now.
🔗 Come Say Hi
- 🌐 Docs & assets: Limn Engine Vercel Assets
- 💬 Community & support: Join our Discord
- 📂 Source code: GitHub
Your turn: what's the thing you rebuilt from scratch before finally admitting a library already did it better? Drop it below. 🎮
Limn Engine — draw your game into existence. No shaking, no scattering, just clean architecture doing the heavy lifting for you. 🎨✨



Top comments (1)
I'll be dropping a more detailed article on this very soon, let's anticipate.🙏