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

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:

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:

However, I encountered issues for this specific project:

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:

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

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:

  1. Non-programmers could design levels using simple text files
  2. Levels could be version-controlled easily (text beats binary assets)
  3. The system could generate infinite variations beyond pre-made levels
  4. 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:

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

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:

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:

// 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

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:

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.