Vibecoding a Cosmic Arcade
PixiJ, Procedural Assets, and Managing LLM Memory Constraints
Little Black Hole is an open-source, mobile-first browser game born out of a desire for an unhurried, relaxing arcade experience, allowing players to steer a growing singularity through celestial systems without arbitrary timers or fail states. Developed through iterative prompt engineering using TypeScript, PixiJS 8, D3.js, and custom GLSL shaders, the project required strict memory constraints to prevent WebGL frame degradation, combining a pre-allocated 4,000-entity pool with a persistent memory.md specification file to discipline the AI co-pilot's code generation habits.
1. The Genesis: Designing for Relaxed Consumption
The initial spark caught fire on an ordinary evening while watching my girlfriend play competitive "hole-it" arcade games on her phone. Instead of unwinding, she found herself increasingly exasperated by ticking countdown clocks, artificial quest conditions, and constant monetization interruptions. She put her phone down, turned to me, and asked whether I could build a game where she could simply sit back, float around, and watch celestial bodies get consumed at her own pace.
That evening, I opened my editor and drafted the first three prompts, generating a working prototype where a tiny black hole absorbed orbital space junk. Giving her the initial build for a trial run immediately yielded fresh inspiration: "It would be cool to be a black hole that eats actual stars." That simple observation expanded the scope dramatically, driving the game's mechanics beyond local debris fields into binary star systems, pulsars, supermassive black holes, and an endlessly scaling multiverse loop.
2. Asset Pipeline: Generating Textures from Code
A core design constraint was eliminating hand-drawn art entirely, delegating every graphical texture and visual effect to programmatic generation at runtime.
Biomes and Textures via D3.js and Noise
Planetary surfaces, moons, and atmospheric layers rely on 2D fractal noise projected through D3.js spatial mapping algorithms. D3 calculates continental outlines, ocean boundaries, and polar caps, drawing vector shapes directly onto off-screen HTML canvas elements to synthesize procedural texture maps during initialization.
function generateTerrestrialPlanet(filename: string, oceanColor: string, thresholds: {val: number, color: string}[], hasAtmo: boolean, hasClouds: boolean, seedOffsetX: number = 0, seedOffsetY: number = 0) {
const size = 512;
const draw = createDraw(size, size);
if (hasAtmo) {
const atmoGlow = draw.gradient('radial', (add) => {
add.stop(0, '#ffffff', 0);
add.stop(0.8, '#ffffff', 0.1);
add.stop(0.95, oceanColor, 0.5);
add.stop(1, '#000000', 0);
});
draw.circle(size).center(size/2, size/2).fill(atmoGlow);
}
const planetSize = hasAtmo ? size * 0.85 : size * 0.95;
const pcx = size / 2;
const planetGroup = draw.group();
const clipCircle = draw.circle(planetSize).center(pcx, pcx);
planetGroup.clipWith(clipCircle);
// Base
planetGroup.rect(size, size).fill(oceanColor);
const gridWidth = 128;
const gridHeight = 128;
const values = new Array(gridWidth * gridHeight);
for (let y = 0; y < gridHeight; y++) {
for (let x = 0; x < gridWidth; x++) {
const nx = x / 30 + seedOffsetX;
const ny = y / 30 + seedOffsetY;
let e = 1 * noise2D(1 * nx, 1 * ny)
+ 0.5 * noise2D(2 * nx, 2 * ny)
+ 0.25 * noise2D(4 * nx, 4 * ny);
e = e / 1.75;
values[y * gridWidth + x] = e;
}
}
const contourGen = contours().size([gridWidth, gridHeight]);
const projection = geoIdentity().scale(size / gridWidth);
const pathGen = geoPath(projection);
for (const t of thresholds) {
const polys = contourGen.thresholds([t.val])(values);
for (const poly of polys) {
const d = pathGen(poly as any);
if (d) planetGroup.path(d).fill(t.color);
}
}
// Clouds
if (hasClouds) {
const cloudValues = new Array(gridWidth * gridHeight);
for (let y = 0; y < gridHeight; y++) {
for (let x = 0; x < gridWidth; x++) {
const nx = x / 20 + 5.2 + seedOffsetX;
const ny = y / 20 + 1.3 + seedOffsetY;
cloudValues[y * gridWidth + x] = noise2D(nx, ny) + 0.5 * noise2D(2*nx, 2*ny);
}
}
const cloudPolys = contourGen.thresholds([0.4])(cloudValues);
for (const poly of cloudPolys) {
const d = pathGen(poly as any);
if (d) planetGroup.path(d).fill('#ffffff').opacity(0.6);
}
}
// 3D Shadow
const shadow = draw.gradient('radial', (add) => {
add.stop(0, '#000000', 0);
add.stop(0.6, '#000000', 0);
add.stop(0.9, '#000000', 0.6);
add.stop(1, '#000000', 0.9);
});
shadow.attr({ cx: '35%', cy: '35%', r: '65%' });
planetGroup.circle(planetSize).center(pcx, pcx).fill(shadow);
savePng(`planets/${filename}.png`, draw);
}
Shader Execution and Performance Triage
Real-time screen-space gravitational lensing, stellar convection, and accretion disk distortions are executed through custom GLSL ES 3.0 fragment shaders.
// Screen-Space Gravitational Lensing Fragment Shader
#version 300 es
precision highp float;
in vec2 vLocalCoord;
out vec4 finalColor;
uniform sampler2D uTexture;
#define MAX_BLACK_HOLES 16
uniform int uBlackHoleCount;
uniform vec2 uPlayerPos[MAX_BLACK_HOLES];
uniform float uPlayerScale[MAX_BLACK_HOLES];
uniform vec2 uResolution;
void main() {
vec2 aspect = vec2(uResolution.x / uResolution.y, 1.0);
vec2 uv_correct = vLocalCoord * aspect;
vec2 total_deflection = vec2(0.0);
float total_factor = 0.0;
for (int i = 0; i < uBlackHoleCount; i++) {
vec2 bh_correct = uPlayerPos[i] * aspect;
vec2 diff = uv_correct - bh_correct;
float dist = length(diff);
float eh_radius = (20.0 * uPlayerScale[i]) / uResolution.y;
float rE = eh_radius * 2.0;
float max_distort_radius = eh_radius * 3.5;
float t = clamp((max_distort_radius - dist) / max_distort_radius, 0.0, 1.0);
float factor = smoothstep(0.0, 1.0, t);
float safe_dist = max(dist, eh_radius * 0.4);
float defl_mag = (rE * rE) / safe_dist;
vec2 dir = dist > 0.001 ? normalize(diff) : vec2(0.0, 1.0);
total_deflection += dir * defl_mag * factor;
total_factor = max(total_factor, factor);
}
vec2 sample_uv_correct = uv_correct - total_deflection;
vec2 lensed_vLocalCoord = mix(vLocalCoord, sample_uv_correct / aspect, total_factor);
finalColor = texture(uTexture, clamp(lensed_vLocalCoord, 0.0, 1.0));
}Early prompts produced mathematically ornate shaders filled with multi-pass ray-marching routines. While visually striking, these initial shaders crushed frame rates down to 2 FPS. Reaching a fluid 60 FPS required trimming expensive mathematical iterations and reducing texture samples. In later levels containing dozens of massive celestial bodies, active shader passes are conditionally bypassed to maintain rendering performance.
3. Conquering Garbage Collection: The Pre-Allocated Entity Pool
When generating code through LLMs, models instinctively default to dynamic heap allocation patterns, invoking new Sprite() or constructing temporary arrays inside requestAnimationFrame callbacks. Rendering hundreds of moving objects through these naive patterns caused the browser's Garbage Collection (GC) engine to trigger frequent, irritating frame stutters.
Eliminating GC pauses required rebuilding the core entity architecture around a fixed, pre-allocated 4,000-element object pool:
export class GameECS {
public entities: Entity[] = [];
private freeSprites: Sprite[] = [];
private freeGraphics: Graphics[] = [];
private freeContainers: Container[] = [];
constructor(gameContainer: Container) {
this.gameContainer = gameContainer;
// Allocate fixed memory upfront to eliminate runtime heap allocations
for (let i = 0; i < 4000; i++) {
this.entities.push({
id: i,
active: false,
type: 'asteroid',
x: 0, y: 0, scale: 1, vx: 0, vy: 0,
mass: 1, isOrbiting: false,
sprite: null,
// ...
});
}
}
public acquireSprite(texture: Texture): Sprite {
let sprite = this.freeSprites.pop();
if (sprite) {
sprite.texture = texture;
sprite.visible = true;
sprite.alpha = 1.0;
sprite.tint = 0xffffff;
} else {
sprite = new Sprite(texture);
}
this.gameContainer.addChild(sprite);
return sprite;
}
public releaseSprite(sprite: Sprite) {
if (!sprite) return;
this.gameContainer.removeChild(sprite);
sprite.visible = false;
this.freeSprites.push(sprite);
}
}4. Architectural Discipline via memory.md
As codebases grow, generative AI models suffer from context drift, frequently reverting to unoptimized allocation habits and discarding established performance patterns unless constrained.
Maintaining architectural integrity required establishing a persistent memory.md specification file within the repository. Referencing this file during planning turns bound the AI co-pilot to strict operational rules:
- Zero heap allocations permitted within frame render loops.
- Viewport culling restricting sprite transform updates to active screen coordinates plus a 300-unit buffer.
- Compulsory recycling of pre-allocated sprite containers.
5. Agent-Driven Development Feasibility
Developing Little Black Hole confirms that modern AI agents can execute the complete technical lifecycle of a browser game, spanning game architecture, physics systems, WebGL shaders, and code-generated 2D art. When anchored by clear architectural constraints and continuous memory enforcement, code-generation models produce performant software directly from high-level developer intent.