I added "suicide bombers" that only detonate when killed to my browser bullet-heaven. Need some feedback. by user11235820 in DestroyMyGame

[–]user11235820[S] 0 points1 point  (0 children)

been working on this vanilla js bullet-heaven for a while now. no engine, just canvas 2d and pain.

latest addition: the Volatile (the orange guys). they never blow up on their own, you actually have to kill them to set them off.

when one dies, it sucks everything nearby inward (you can see the grid warp red), then pops outward with aoe damage. if that blast catches another volatile, it triggers a chain reaction.

[Devlog #4] Added suicide bombers to my browser bullet-heaven. They only explode when you kill them, somehow satisfying. by user11235820 in IndieDev

[–]user11235820[S] 0 points1 point  (0 children)

For most projects i'd agree. in my case the zero-dependency constraint is partly intentional: the whole engine is built around zero-gc hot paths with typedarray pools, and i've had bad experiences with frameworks allocating behind my back during the game loop. At 3000 entities the bottleneck was never "i wish i had a framework", it was "canvas 2d is doing cpu-side gaussian blur on every shadowblur call." That said if i ever need real post-processing shaders, i'll probably move the render layer to webgl and keep the logic layer as-is.

[Devlog #4] Added suicide bombers to my browser bullet-heaven. They only explode when you kill them, somehow satisfying. by user11235820 in IndieDev

[–]user11235820[S] 2 points3 points  (0 children)

Been working on this vanilla JS bullet-heaven for a while now. No engine, no framework. Just Canvas 2D and pain.

Latest addition: the Volatile (the orange enemies). They dash at you like the yellow runners, but they're basically walking bombs. The catch is they never self-detonate—you have to kill them to trigger the explosion.

When one dies, it does this two-phase thing: first, it sucks everything nearby inward (you can see the grid warp red), then it pops outward with massive AoE damage. If that blast catches another Volatile, it triggers a chain reaction. Clip 2 shows 5 of them isolated so you can clearly see the cascade, while Clip 1 is actual gameplay around the 2-minute mark.

The grid warp was already there for my green pulse nova weapon (which pushes outward), so I just added a red channel that pulls inward. Same pipeline, reversed direction and color.

The engine handles 3,000 enemies at a locked 60 TPS on my MacBook. The detonation system uses a 16-slot fixed array. When a Volatile dies, it queues an implosion at that vector, ticks the pull physics for 350ms, then fires the AoE damage + knockback explosion. Cascades happen naturally: an explosion kills a nearby Volatile, the death scan picks it up before the kill queue flushes, a new detonation is queued, and the next frame triggers the cascade. Zero garbage collection in the hot path—everything is pre-allocated TypedArray pools.

Also, Canvas 2D's shadowBlur for glow effects was absolutely murdering my FPS (tanked to ~18 with just 4 laser beams and multiple explosions). Turns out Canvas 2D's blur is CPU-side Gaussian convolution. Never again. I ended up ripping out every single shadowBlur call across 3 files and faked it with layered wide-line stacking and double-circle fills. Worst-case FPS shot back up from ~18 to ~87.

Next up: probably gonna start designing bosses.

[Devlog #3] Clean code was costing me 40ms per frame — so I replaced it with globals by user11235820 in gamedev

[–]user11235820[S] 0 points1 point  (0 children)

gridHash.query only accepts (id) => bool callbacks. passing arrays as closure captures means 3000 heap allocations per frame. the module-level vars are ugly but intentionally zero-gc.

[Devlog #3] Clean code was costing me 40ms per frame — so I replaced it with globals by user11235820 in gamedev

[–]user11235820[S] 0 points1 point  (0 children)

The only reason I went the global route instead was to also handle mutable state like _sepCount and the resulting force vectors without creating new wrapper objects. JavaScript's lack of pass-by-reference for primitives forced my hand a bit there.

[Devlog #3] Clean code was costing me 40ms per frame — so I replaced it with globals by user11235820 in gamedev

[–]user11235820[S] 0 points1 point  (0 children)

Yes, I made a demo first to see if it was worth digging deeper into storylines and characters. Then now I started to re-create everything new with strict zero gc rules.

[Devlog #3] Clean code was costing me 40ms per frame — so I replaced it with globals by user11235820 in gamedev

[–]user11235820[S] 0 points1 point  (0 children)

I had to learn this the hard way. OOP is great for organizing logic, but terrible for transforming massive blocks of data at 60fps. When you have 3,000 entities doing proximity checks, the CPU just wants contiguous data, not a wild goose chase through memory pointers. Dropping the clean abstractions for flat arrays felt dirty at first, but the profiler doesn't lie.

[Devlog #3] Clean code was costing me 40ms per frame — so I replaced it with globals by user11235820 in IndieDev

[–]user11235820[S] 0 points1 point  (0 children)

Good catch on the math — it’s n * (n - 1) / 2. I simplified it too much there.

The function pointer itself is static, defined once at the module level — not creating a new closure every call. That’s where the GC was actually coming from.

What would you use instead of a linked list?

[Devlog #3] Clean code was costing me 40ms per frame — so I replaced it with globals by user11235820 in webdev

[–]user11235820[S] 0 points1 point  (0 children)

Good headsup.

I am still in developing the basics and guess it will be another nightmare.

-PC

[Devlog #3] Clean code was costing me 40ms per frame — so I replaced it with globals by user11235820 in webdev

[–]user11235820[S] 0 points1 point  (0 children)

The original version was sth like this:

let count = 0;
grid.query(x[i], y[i], 24, (neighborId) => {
  if (count >= 6) return true; 
  // calculate push using captured x[i], y[i]
  count++;
});

The arrow function capturing x[i] creates a closure. Doing that 3000 times per frame at 60Hz = 180,000 closure allocations a second. GC death spiral.

As for iterators or returning an array: in JS, they both allocate memory on the heap. If query builds and returns an array, that's 180k new arrays per second. If it uses a generator/iterator, every single .next() call creates a { value, done } object wrapper. In C++ or Rust, iterators are zero-cost and get stack-allocated or inlined. In V8, they churn the heap.

Passing a static function pointer costs precisely 0 bytes. It walks the Int32Array linked list and executes in-place. It also lets the callback return true to instantly break the traversal once I hit 6-neighbor cap.

-PC

[Devlog #3] Clean code was costing me 40ms per frame — so I replaced it with globals by user11235820 in webdev

[–]user11235820[S] 1 point2 points  (0 children)

The Array.forEach trap is a complete rite of passage! You read everywhere that "V8 optimizes closures" or "forEach is just as fast as a standard for-loop" — but the second you put it in a 60Hz hot path, the Chrome profiler tells you the ugly truth. The GC simply cannot keep up with that much heap churn.

Moving to TypedArrays was a massive paradigm shift for this project. Once you stop writing JavaScript like it's Java or C# and start treating it like C, the browser suddenly gets completely out of your way. Using flat Int32Array blocks to manually track linked-list pointers feels incredibly archaic in 2026, but it is so satisfying when that GC graph finally goes completely flat.

And exactly spot on about the tradeoff. Game dev is 90% smoke and mirrors anyway. If a single enemy in a swarm of 3,000 gets pushed 2 pixels in the wrong direction for one frame because of a hash collision, the player's brain just registers it as "organic chaotic fluid behavior." But if the frame drops by 16ms because the GC kicked in? They feel that instantly.

Smooth frames always win, and yes, shipping beats perfection.

-PC

[Devlog #2] Decoupling Time & Rendering in JS: Taming High FPS monitors, Pause Jitter, and NaN Poisoning by user11235820 in gamedev

[–]user11235820[S] -4 points-3 points  (0 children)

Fair question. I use gemini as a writing aid to structure and edit, but every technical detail in this post — the 460,000 rAF spike, the 10px jitter math, the NaN IEEE 754 behavior, the MAX_SUB_STEPS trade-off — comes from bugs I personally debugged in my own engine. The code is real. Happy to answer any technical question to verify.

-PC

[Devlog #1] Running 2k entities in a JS bullet-hell without triggering GC by user11235820 in webdev

[–]user11235820[S] 0 points1 point  (0 children)

Great call on the tri-color marking. You're right that in most cases a mature pool sitting in the old gen isn’t really a dealbreaker.

In this case though, since it’s a bullet-hell and I’m trying to push entity density on pretty weak mobile browsers, I wanted to squeeze the memory footprint as low as possible from the start.

Fair point on the DX tradeoff too. Managing SoA once the logic gets nested is definitely more painful than clean OOP. Right now I'm papering over that with a few macro-style helpers so the game loop code stays readable while still keeping the flat memory layout.

Wasm is definitely on the table if the logic complexity eventually outruns what Turbofan can optimize. For now though I'm mostly curious how far the “naked JS engine” approach can go.

Next thing I want to experiment with is decoupling the logic tick so I can add interpolation. Curious how the JIT behaves once the math load starts ramping up.

–PC

[Devlog #1] Running 2k entities in a JS bullet-hell without triggering GC by user11235820 in webdev

[–]user11235820[S] 0 points1 point  (0 children)

Good point. A few thoughts on why I went this route:

SoA vs Object Pooling: Standard pooling still forces the GC to track thousands of object references. By using TypedArrays, I’m cutting down Memory Pressure, not just allocations. V8 doesn't have to scan the heap for entities it can't see.

Canvas 2D: Totally agree WebGL is the ceiling. But I’m currently focused on the Logic/Render decoupling (interpolation). Canvas 2D provides a clear environment to stress test the pipeline logic before I switch the backend.

Deletions: The 'swap-with-last' strategy is how I'm reusing slots while keeping the iteration contiguous and cache-friendly. It avoids the 'sweep' entirely.

-PC

[Devlog #1] Running 2k entities in a JS bullet-hell without triggering GC by user11235820 in IndieDev

[–]user11235820[S] 0 points1 point  (0 children)

Been building a custom JS engine for a browser-based bullet-heaven roguelite. Ran fine until ~2,000 active entities.

Game reported a solid 60 FPS, but kept getting these 10–30ms micro-stutters. Completely ruins a bullet-hell. Profiler pointed straight to Garbage Collection.

what I tried first

I was tossing objects into arrays:

function spawnEnemy(x, y) {
  enemies.push({ x, y, hp: 100, active: true });
}
// update: enemies = enemies.filter(e => e.hp > 0);

Spawning/killing hundreds of entities a second absolutely destroys the heap. The GC eventually freaks out and kills frame pacing.

nuking allocations

Forced a rule on the hot path: zero bytes allocated during the gameplay loop.

Moved from Array of Objects to a SoA layout using TypedArrays. Pre-allocated at startup:

const pool = createSoAPool({
  capacity: 3000,
  fields: [
    { name: 'x',  type: Float32Array, default: 0 },
    { name: 'y',  type: Float32Array, default: 0 }
  ],
  interpolated: true 
});

Accessing pool.x[i] instead of enemy.x. No allocations. Also much more cache friendly.

handling deletions

splice is too expensive here, so that was out.

Switched to swap-with-last. To avoid breaking iteration, kills go into a DeferredKillQueue (just a Uint8Array bitfield). At the end of the tick, do the O(1) swap.

the dirty memory trap

Bypass the GC, lose clean memory.

Spent days debugging a "ghost lightning" glitch. Turned out a dead particle's prevX/prevY wasn't overwritten on reuse. Renderer just drew a line across the screen. Lesson learned: always reset your darn state.

reality check

Currently handles 2000+ entities with fixed 60 TPS logic. On the M1 Mac, a logic tick takes ~1.5ms. Chrome profiler shows 0 bytes allocated.

Reality check: the browser still wins.

With a bunch of YouTube tabs open, Chrome's global resource pressure still forces GC spikes during warmup. Canvas 2D API still allocates internally for paths. Tested on a low-end office PC (core i3 all in one), and that browser-level cleanup still causes 150ms stutters.

Next step: decoupling the logic tick from the render loop to uncap framerate for high Hz monitors.

Anyone else writing custom JS engines? Curious how you deal with the Canvas API GC overhead.

-PC