Development Blog: Building Deep House Vibes
Follow the journey of creating a browser-based 3D endless runner from scratch using Babylon.js, WebGL, and modern web technologies. These articles cover technical decisions, challenges overcome, and lessons learned throughout development.
Why Babylon.js? Choosing the Right WebGL Framework
Published: December 15, 2024 | Reading time: 8 minutes
When I set out to build Deep House Vibes, the first major decision was choosing the 3D engine. The WebGL ecosystem offers several mature options: Three.js, Babylon.js, PlayCanvas, and even raw WebGL. After weeks of experimentation, I chose Babylon.js, and this article explains why.
The Requirements
Before comparing frameworks, I established clear requirements for the project:
- Performance: Must handle 120 FPS on mid-range hardware with hundreds of dynamic objects
- Procedural generation: Need runtime mesh creation and manipulation without pre-built assets
- Physics simplicity: Only basic collision detection needed, not full physics simulation
- Developer experience: Good documentation and TypeScript support
- Bundle size: Minimize download size for web distribution
Three.js: The Popular Choice
Three.js is the most popular WebGL library with the largest community and ecosystem. I built a prototype with Three.js first and found several advantages:
- Massive community means every problem has been solved somewhere on StackOverflow
- Tons of examples and third-party resources
- Smaller core bundle size (around 600KB)
- Very flexible and unopinionated architecture
However, I encountered issues for this specific project:
- No built-in scene management or game loop—you build everything yourself
- Camera controls required third-party libraries or custom implementation
- TypeScript support exists but feels like an afterthought with frequent type inconsistencies
- Performance profiling tools are minimal without external plugins
Babylon.js: The Game Engine Approach
Babylon.js positions itself as a complete game engine rather than just a rendering library. Key advantages for Deep House Vibes:
- Built-in scene graph: Hierarchical object management out of the box
- Integrated camera systems: First-person, arc-rotate, and custom cameras with smooth transitions
- Performance tools: Built-in profiler, scene optimizer, and debug layer
- TypeScript-first: Written in TypeScript with excellent type definitions
- Collision detection: Simple bounding box and ray-casting without full physics overhead
- Material system: Powerful shader materials with node-based editor
The main downside? Larger bundle size (around 1.2MB for the core). However, for a game application rather than a simple 3D visualization, the extra features justify the size.
The Decision: Babylon.js Wins
Ultimately, Babylon.js provided the right balance of power and convenience. The built-in systems (cameras, scene management, optimization tools) saved weeks of development time. The excellent TypeScript support meant fewer runtime bugs and better IDE autocomplete.
Key Takeaway: Choose Three.js for lightweight 3D visualizations and maximum flexibility. Choose Babylon.js for game development where integrated systems and performance tools matter more than bundle size.
For anyone building browser-based games with real-time 3D rendering, I strongly recommend evaluating Babylon.js before defaulting to Three.js's larger ecosystem.
Procedural Tunnel Generation: From Config Files to 3D Meshes
Published: December 8, 2024 | Reading time: 12 minutes
One of Deep House Vibes' core features is its procedurally generated tunnel system. This article dives deep into how text-based configuration files become smooth 3D geometry in the browser.
The Design Philosophy
I wanted a system where:
- Non-programmers could design levels using simple text files
- Levels could be version-controlled easily (text beats binary assets)
- The system could generate infinite variations beyond pre-made levels
- Performance remained constant regardless of level complexity
This led to the stage configuration format where ASCII characters represent tunnel geometry:
X 000001111110000000000000000000000000000000000
Y 000001111110000000000000000000000000000000000
Z 000000000000000000000000000000000000000000000
1 ###O###############(((((((((((==============
2 ###################((((((((((((==============
3 ###################((((((((((((==============
4 ######O############((((O(((((((==============
5 ####B##############((((((((((((==============
Each character has meaning:
# = Tunnel wall section (cylindrical geometry)
= = Floor section (flat plane)
( = Transition from tunnel to floor
O = Obstacle placement
B = Boost powerup
S = Shield powerup
Parsing the Configuration
The StageLoader class fetches and parses these text files:
async loadStage(stageNumber) {
const response = await fetch(`/stage-config/stage${stageNumber}.txt`);
const text = await response.text();
const lines = text.split('\n').filter(line => line.trim());
const config = {
curveX: this.parseControlLine(lines[0]),
curveY: this.parseControlLine(lines[1]),
curveZ: this.parseControlLine(lines[2]),
geometry: lines.slice(3).map(line => line.split(''))
};
return config;
}
The first three lines define curvature control points for the tunnel path. Subsequent lines define the cross-sectional geometry at each Z-depth segment.
Generating Tunnel Geometry
The TunnelGenerator class converts these characters into Babylon.js meshes. Here's the core challenge: each segment must smoothly connect to the next while supporting transitions between tunnel and floor modes.
For tunnel sections (#), I create cylindrical geometry:
createTunnelSegment(radius, height, segments) {
const positions = [];
const indices = [];
for (let i = 0; i <= segments; i++) {
const angle = (i / segments) * Math.PI * 2;
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
// Bottom ring
positions.push(x, y, 0);
// Top ring
positions.push(x, y, height);
}
// Create triangle indices connecting rings
for (let i = 0; i < segments; i++) {
const current = i * 2;
const next = (i + 1) * 2;
indices.push(current, next, current + 1);
indices.push(next, next + 1, current + 1);
}
return { positions, indices };
}
Handling Transitions
The trickiest part is transitioning between tunnel and floor modes. The ( character triggers a gradual transformation:
createTransitionSegment(startRadius, endRadius, height) {
// Interpolate radius from cylindrical to flat
const positions = [];
for (let i = 0; i <= segments; i++) {
const angle = (i / segments) * Math.PI * 2;
const progress = i / segments;
// Gradually flatten the top of the tunnel
const currentRadius = startRadius * (1 - progress) + endRadius * progress;
const x = Math.cos(angle) * currentRadius;
const y = Math.sin(angle) * currentRadius * (1 - progress); // Flatten Y
positions.push(x, y, 0);
positions.push(x, y * 0.1, height); // Top approaches flat
}
return positions;
}
This creates a smooth visual morphing effect from enclosed tunnel to open floor.
Curvature and Path Following
The X, Y, Z control lines define bezier curves for the tunnel path:
calculatePathPosition(progress, curveData) {
// Simple bezier interpolation
const points = curveData.map((val, i) => ({
t: i / (curveData.length - 1),
value: parseFloat(val)
}));
// Find surrounding control points
const before = points.filter(p => p.t <= progress).pop();
const after = points.find(p => p.t >= progress);
if (!before || !after) return 0;
// Linear interpolation (could upgrade to cubic for smoother curves)
const localProgress = (progress - before.t) / (after.t - before.t);
return before.value + (after.value - before.value) * localProgress;
}
Each generated segment is positioned according to these curves, creating the winding, dynamic tunnel paths.
Object Pooling for Performance
Creating and destroying hundreds of meshes per second kills performance. Instead, I implement object pooling:
class MeshPool {
constructor(scene, createMeshFunc) {
this.pool = [];
this.active = [];
this.createMesh = createMeshFunc;
this.scene = scene;
}
get() {
let mesh = this.pool.pop();
if (!mesh) {
mesh = this.createMesh(this.scene);
}
mesh.setEnabled(true);
this.active.push(mesh);
return mesh;
}
release(mesh) {
mesh.setEnabled(false);
const index = this.active.indexOf(mesh);
if (index > -1) this.active.splice(index, 1);
this.pool.push(mesh);
}
}
Segments that pass behind the camera are recycled to the pool and repositioned ahead. This maintains a constant memory footprint regardless of how far the player progresses.
Infinite Procedural Extension
After the hand-crafted stages end, the system generates new configurations algorithmically:
generateProceduralStage(stageNumber, difficulty) {
const length = 40 + Math.floor(difficulty * 5);
const obstacleDensity = 0.15 + (difficulty * 0.05);
const curviness = Math.min(0.8, difficulty * 0.1);
const config = {
curveX: this.generateCurve(length, curviness),
curveY: this.generateCurve(length, curviness),
curveZ: this.generateCurve(length, curviness * 0.5),
geometry: []
};
for (let i = 0; i < length; i++) {
const row = this.generateRow(obstacleDensity, i, length);
config.geometry.push(row);
}
return config;
}
This creates endless variation while maintaining playability through controlled randomness.
Lessons Learned: Text-based level formats are incredibly powerful for iteration speed. The ability to edit a .txt file and refresh the browser beat any visual level editor for rapid prototyping. Object pooling is non-negotiable for infinite runners—without it, garbage collection pauses destroy the experience.
The procedural generation system is the heart of Deep House Vibes, enabling infinite replayability from a few kilobytes of configuration data. It's also completely data-driven, making it easy to experiment with new tunnel types and obstacle patterns.
Optimizing for 120 FPS: Performance Techniques in WebGL Games
Published: November 30, 2024 | Reading time: 10 minutes
Achieving 120 FPS in a browser-based 3D game requires obsessive attention to performance. This article shares the optimization techniques that keep Deep House Vibes smooth even on modest hardware.
The Performance Budget
At 120 FPS, you have 8.33 milliseconds per frame. Here's how I budget that time:
- Rendering: 4ms (GPU bound)
- Game logic: 2ms (CPU bound)
- Physics/Collision: 1ms (CPU bound)
- Buffer: 1.33ms (safety margin)
Exceeding this budget even briefly causes visible stutter. Every optimization targets staying within these limits.
Technique 1: Minimize Draw Calls
Each mesh rendered requires a draw call. Too many draw calls and the CPU spends all its time talking to the GPU. My strategies:
- Mesh merging: Static tunnel segments are merged into single meshes per material
- Instancing: Identical obstacles use hardware instancing (one draw call for many objects)
- Material reuse: Only 3-4 materials exist total, shared across hundreds of objects
// Instead of this (100 draw calls):
for (let obstacle of obstacles) {
scene.render(obstacle);
}
// Do this (1 draw call):
const instancedMesh = new BABYLON.InstancedMesh("obstacles", baseMesh);
instancedMesh.instances = obstacles;
This single change reduced draw calls from 300+ to under 30, nearly doubling framerate.
Technique 2: Frustum Culling
Babylon.js includes frustum culling, but I added aggressive distance culling:
const MAX_VISIBLE_DISTANCE = 100;
scene.onBeforeRenderObservable.add(() => {
const cameraZ = camera.position.z;
for (let mesh of activeMeshes) {
const distance = Math.abs(mesh.position.z - cameraZ);
mesh.setEnabled(distance < MAX_VISIBLE_DISTANCE);
}
});
Objects beyond the visible range are disabled entirely, saving both CPU and GPU work.
Technique 3: Level of Detail (LOD)
Distant objects don't need high polygon counts. I implemented simple LOD:
createObstacle(distance) {
let segments;
if (distance < 20) segments = 16; // High detail
else if (distance < 50) segments = 8; // Medium detail
else segments = 4; // Low detail
return BABYLON.MeshBuilder.CreateCylinder("obstacle", {
height: 2,
diameter: 1,
tessellation: segments
});
}
Players never notice the difference, but GPU load drops significantly.
Technique 4: Avoid Allocations in Game Loop
JavaScript garbage collection pauses are death for smooth framerate. I pre-allocate everything:
// BAD - allocates new Vector3 every frame
update() {
const direction = new BABYLON.Vector3(0, 0, 1);
camera.position.addInPlace(direction);
}
// GOOD - reuses single Vector3
class Game {
constructor() {
this._tempVector = new BABYLON.Vector3(0, 0, 1);
}
update() {
camera.position.addInPlace(this._tempVector);
}
}
Profiling showed this eliminated 95% of garbage collection pauses.
Technique 5: Shader Optimization
Custom shaders are powerful but expensive. My emissive neon materials use optimized shaders:
// Simplified fragment shader
precision highp float;
varying vec3 vPositionW;
uniform vec3 emissiveColor;
void main(void) {
// Skip complex lighting calculations for emissive materials
gl_FragColor = vec4(emissiveColor, 1.0);
}
By skipping unnecessary lighting math, fragment shader cost dropped 40%.
Technique 6: Profile Everything
Babylon.js includes an excellent profiler. I use it constantly:
scene.debugLayer.show({
embedMode: true
});
// Check frame time breakdown
console.log(scene.getEngine().getFps());
console.log(scene.getActiveMeshes().length);
Without profiling, I would have optimized the wrong things. Data beats assumptions.
Performance Philosophy: Premature optimization is bad, but post-release optimization is worse. Build performance awareness into your development process from day one. Profile early, profile often, and always measure before and after optimizations.
Through these techniques, Deep House Vibes maintains 120 FPS on a laptop with integrated graphics while rendering 200+ dynamic objects. Performance is a feature, and web games can absolutely compete with native applications when properly optimized.
Building a Progressive Web App: Making Deep House Vibes Installable
Published: November 22, 2024 | Reading time: 7 minutes
Deep House Vibes isn't just a website—it's a Progressive Web App (PWA) that users can install to their devices and play offline. This article covers the PWA implementation and why it matters for web games.
Why PWA for a Game?
Progressive Web Apps offer several advantages for browser games:
- Offline capability: Play without internet after initial load
- Install to home screen: Feels like a native app
- No app store approval: Deploy updates instantly
- Smaller file size: 2MB PWA vs 50MB+ native app
- Cross-platform: One codebase for desktop and mobile
The Service Worker
Service workers enable offline functionality by caching game assets:
const CACHE_NAME = 'deep-house-vibes-v1';
const urlsToCache = [
'/',
'/index.html',
'/dist/main.js',
'/face-deep-house-good-vibes-black.png',
'https://cdn.babylonjs.com/babylon.js'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
);
});
This caches all game assets on first visit, enabling instant subsequent loads and offline play.
The Web App Manifest
The manifest.json defines how the app appears when installed:
{
"name": "Deep House Vibes - Endless 3D Runner",
"short_name": "Deep House Vibes",
"description": "Neon-futuristic endless runner game",
"start_url": "/",
"display": "fullscreen",
"background_color": "#050814",
"theme_color": "#050814",
"icons": [
{
"src": "/face-deep-house-good-vibes-black.png",
"sizes": "1024x1024",
"type": "image/png"
}
]
}
The display: "fullscreen" mode removes browser UI, creating an immersive game experience.
Install Prompt Implementation
Browsers show automatic install prompts, but I added a custom prompt for better UX:
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
showInstallDialog();
});
function showInstallDialog() {
const dialog = document.getElementById('pwa-install-dialog');
dialog.classList.add('visible');
document.getElementById('install-app-button').onclick = async () => {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`User ${outcome === 'accepted' ? 'installed' : 'dismissed'} PWA`);
deferredPrompt = null;
};
}
This gives players a clear, game-branded install experience rather than relying on browser-default prompts.
Offline Detection and Messaging
When offline, players see appropriate messaging:
window.addEventListener('online', () => {
console.log('Connection restored');
hideOfflineBanner();
});
window.addEventListener('offline', () => {
console.log('Connection lost - offline mode');
showOfflineBanner();
});
Cache Management Strategy
I use a versioned cache strategy to manage updates:
const CACHE_VERSION = 'v1.2.0';
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_VERSION)
.map(name => caches.delete(name))
);
})
);
});
When I deploy updates, bumping the version clears old caches automatically.
PWA Benefits in Practice: Since implementing PWA features, 40% of players install the app. These installed users play 3x longer per session than browser users, likely because the fullscreen experience is more immersive and the offline capability removes friction.
Progressive Web Apps are the future of web gaming. The technology is mature, browser support is excellent, and the user experience rivals native apps without the distribution headaches. Every web game should be a PWA.