verticallines

Loading...

2D Craft: A Voxel-Style Web Game Built from Scratch

Rahul Hathwar - 2024-2025 - Exploring Procedural Generation, Physics Systems, and Dynamic Lighting in Pure JavaScript

2D Craft: Building a Minecraft-Inspired Game Engine in the Browser

What started as an exploration of game development fundamentals evolved into a fully-featured 2D voxel game with procedural world generation, realistic physics, and a sophisticated day/night cycle with dynamic lighting. Built entirely with JavaScript and PixiJS, this project demonstrates core game engine concepts without relying on pre-built game frameworks.

The Vision: A Living, Breathing 2D World

The goal was simple: create a web-based sandbox game where players could explore, gather resources, and interact with a procedurally generated world. But beneath this simplicity lay several technical challenges:

  • How do you make procedurally generated terrain look organic rather than random?
  • How do you implement smooth physics and collision detection on a tile-based grid?
  • How do you create atmospheric lighting that responds dynamically to a day/night cycle?

Technical Foundation: PixiJS and the Rendering Pipeline

The game is built on PixiJS 7.2.4, a powerful 2D WebGL renderer that handles sprite batching and rendering optimization. Rather than using a game engine like Phaser, I built the game systems from scratch to understand the fundamentals.

const app = new PIXI.Application({
    resizeTo: window,
    backgroundColor: 0x87CEEB,
});

The architecture uses a layered container system where rendering order determines visual priority:

  1. Sky Container (bottom layer) - Dynamic gradient background
  2. Stars Container - Procedural star field with alpha transitions
  3. Block Container - All terrain blocks with dynamic lighting
  4. Cloud Container - Organic cloud formations
  5. Player Container - Character sprite with head tracking
  6. Inventory Container (top layer) - UI elements

This separation allows independent updates to each system without affecting others—critical for performance when redrawing lighting every frame.

Challenge 1: Making Blocks Look Natural

The Problem

Early iterations used solid-colored blocks that looked flat and lifeless. Minecraft's appeal comes partly from its textured, varied appearance even with simple geometry.

The Solution: Procedural Texture Generation

I implemented a 4x4 grid-based texture system where each block is subdivided into 16 cells, with each cell having slight color variation:

function generatePatternedBlock(blockType) {
    const g = new PIXI.Graphics();
    const grid = 4;
    const subSize = BLOCK_SIZE / grid;
    
    for (let row = 0; row < grid; row++) {
        for (let col = 0; col < grid; col++) {
            // Apply 10% brightness variation to base color
            let cellColor = adjustColor(blockType, 0.9 + Math.random() * 0.2);
            g.beginFill(cellColor);
            g.drawRect(col * subSize, row * subSize, subSize, subSize);
            g.endFill();
        }
    }
    return g;
}

For grass blocks, I added transitional texturing where the top rows are green and gradually blend into brown dirt toward the bottom—mimicking Minecraft's iconic grass-to-dirt gradient:

if (blockType === BLOCK_TYPES.GRASS) {
    if (row < 2) {
        cellColor = adjustColor(BLOCK_TYPES.GRASS, 0.9 + Math.random() * 0.2);
    } else if (row === 2) {
        // Transition row: 50% chance of grass or dirt
        cellColor = Math.random() < 0.5 
            ? adjustColor(BLOCK_TYPES.GRASS, 0.9 + Math.random() * 0.2)
            : adjustColor(BLOCK_TYPES.DIRT, 0.9 + Math.random() * 0.2);
    } else {
        cellColor = adjustColor(BLOCK_TYPES.DIRT, 0. 9 + Math.random() * 0.2);
    }
}

This small detail adds enormous visual depth—each block is unique while maintaining aesthetic consistency.

Challenge 2: Organic Procedural World Generation

Tree Generation with Ellipse-Based Foliage

Rather than spawning rectangular leaf clusters, I developed an ellipse intersection algorithm for natural-looking tree crowns:

// Define primary and secondary ellipses for organic shapes
const primary = {
    cx: leavesGridW * BLOCK_SIZE / 2,
    cy: leavesGridH * BLOCK_SIZE / 2,
    rx: leavesGridW * BLOCK_SIZE * 0.4,
    ry: leavesGridH * BLOCK_SIZE * 0.4
};

// Add secondary ellipses for irregular shapes
const secondaries = [];
for (let j = 0; j < 2; j++) {
    secondaries.push({
        cx: primary.cx + (Math.random() * leavesGridW * BLOCK_SIZE * 0.2),
        cy: primary.cy + (Math.random() * leavesGridH * BLOCK_SIZE * 0.2),
        rx: leavesGridW * BLOCK_SIZE * 0.25,
        ry: leavesGridH * BLOCK_SIZE * 0.25
    });
}

// Only place leaves if cell center falls within any ellipse
if (pointInEllipse(cellCenterX, cellCenterY, primary.cx, primary.cy, primary.rx, primary.ry)) {
    inside = true;
}

The pointInEllipse function uses the standard ellipse equation:

function pointInEllipse(px, py, cx, cy, rx, ry) {
    return ((px - cx) ** 2) / (rx * rx) + ((py - cy) ** 2) / (ry * ry) <= 1;
}

This creates irregular, blob-like foliage that feels organic. Each tree is unique, with varying trunk heights (3-5 blocks) and crown sizes (3-5 blocks wide, 3-4 blocks tall). Trees spawn with a 20% probability on grass blocks during world generation.

Cloud Generation

I applied the same ellipse intersection technique to clouds, creating puffy, natural-looking formations instead of rectangular blocks:

function generateClouds() {
    const cloudPalette = [0xFFFFFF, 0xF8F8F8, 0xF0F0F0];
    const NUM_CLOUDS = 7;
    
    for (let i = 0; i < NUM_CLOUDS; i++) {
        const gridW = 7 + Math.floor(Math.random() * 9);  // 7-15 blocks wide
        const gridH = 3 + Math.floor(Math. random() * 5);  // 3-7 blocks tall
        
        // Position clouds in upper half of screen
        const startY = Math.random() * ((WORLD_HEIGHT * BLOCK_SIZE * 0.5));
        
        // Apply ellipse-based generation... 
    }
}

Clouds have subtle color variation from the three-tone white palette, adding depth perception.

Challenge 3: Physics and Collision Detection

The Problem: Grid-Based Collision in Continuous Space

The player moves in continuous pixel coordinates, but the world exists on a discrete grid. Naive collision detection causes "corner catching" where the player gets stuck on edges.

The Solution: Separate Axis Resolution

I implemented separate horizontal and vertical collision resolution that checks grid cells across the player's bounding box:

function resolveHorizontal(newX) {
    let resolvedX = newX;
    const characterWidth = BLOCK_SIZE / 2;  // Player is half a block wide
    const top = player.y;
    const bottom = player.y + BLOCK_SIZE * 2;  // Player is 2 blocks tall
    
    // Check all grid cells along the vertical range
    const gridYStart = Math.floor(top / BLOCK_SIZE);
    const gridYEnd = Math.floor((bottom - 1) / BLOCK_SIZE);
    
    if (player.vx > 0) {  // Moving right
        const gridX = Math.floor((newX + characterWidth - 1) / BLOCK_SIZE);
        for (let j = gridYStart; j <= gridYEnd; j++) {
            if (world[gridX] && world[gridX][j]) {
                resolvedX = gridX * BLOCK_SIZE - characterWidth;
                break;
            }
        }
    }
    // Similar logic for moving left... 
    
    return resolvedX;
}

This approach:

  1. Tests multiple cells across the player's height/width
  2. Resolves each axis independently to prevent compound errors
  3. Aligns to grid edges when collisions occur

The result is smooth movement without corner snagging, even when navigating complex terrain.

Gravity and Jump Physics

The game uses continuous gravity with velocity accumulation:

const GRAVITY = 0.5;
player. vy += GRAVITY;  // Applied every frame

// Jump only triggers when grounded
if ((keys['w'] || keys['space']) && isOnGround()) {
    player. vy = -10;
}

The isOnGround() function checks if solid blocks exist immediately below the player's feet—crucial for preventing mid-air jumps without feeling "floaty."

Challenge 4: Dynamic Day/Night Cycle with Real-Time Lighting

This was the most technically ambitious feature: creating a sun that orbits the world and dynamically lights every block based on its position.

Sun Mechanics

The sun follows a circular path using trigonometry:

const sun = {
    angle: 0,  // 0 = sunrise, π = sunset
    radius: (WORLD_WIDTH * BLOCK_SIZE) * 0.5,
};

function updateSun(deltaTime) {
    sun.angle += 0.001 * deltaTime;
    if (sun.angle > Math.PI * 2) sun. angle -= Math.PI * 2;
    
    const centerX = (WORLD_WIDTH * BLOCK_SIZE) / 2;
    const centerY = (WORLD_HEIGHT * BLOCK_SIZE) * 0.5;
    
    sun.x = centerX + sun.radius * Math.cos(sun.angle);
    sun.y = centerY - sun.radius * Math.sin(sun.angle);
}

Per-Block Lighting Calculation

Every frame, each block's brightness is recalculated based on the sun's position:

function updateLighting() {
    for (let i = 0; i < blockContainer.children.length; i++) {
        const block = blockContainer. children[i];
        
        const blockCenterX = block.x + BLOCK_SIZE / 2;
        const blockCenterY = block.y + BLOCK_SIZE / 2;
        
        // Vector from block to sun
        const dx = sun.x - blockCenterX;
        const dy = sun.y - blockCenterY;
        const dist = Math.sqrt(dx * dx + dy * dy);
        
        // Normalize
        const nx = dx / (dist || 1);
        const ny = dy / (dist || 1);
        
        // Dot product with upward normal (-ny simulates top-lit blocks)
        const dot = (-ny);
        let brightness = 0. 4 + Math.max(0, dot) * 0.8;  // Range: 0. 4-1.2
        
        // Scale by day/night cycle
        const dayFactor = Math.max(0, Math.sin(sun.angle));
        brightness *= (0.5 + 0.5 * dayFactor);  // Darker at night
        
        const newColor = adjustColor(block.originalFill, brightness);
        
        // Redraw block with new color
        block.clear();
        block.beginFill(newColor);
        block. drawRect(0, 0, BLOCK_SIZE, BLOCK_SIZE);
        block.endFill();
    }
}

This creates:

  • Directional lighting where blocks facing the sun are brighter
  • Gradual transitions as the sun moves
  • Night darkening using the sine of the sun's angle

Sky Gradient System

The sky transitions through three states: day (blue), sunset (orange/red), and night (dark blue):

function drawSky() {
    const dayFactor = Math.max(0, Math.sin(sun.angle));
    
    const dayTopColor = 0x90DFFF;
    const nightTopColor = 0x0a0a2a;
    const sunsetTopColor = 0xFF2400;
    
    // Calculate proximity to sunrise/sunset
    let diffToSunrise = Math.min(sun.angle, 2 * Math.PI - sun. angle);
    let diffToSunset = Math. abs(sun.angle - Math.PI);
    const threshold = 0.3;
    
    let sunsetMix = 0;
    if (diffToSunrise < threshold) {
        sunsetMix = (threshold - diffToSunrise) / threshold;
    }
    if (diffToSunset < threshold) {
        sunsetMix = Math.max(sunsetMix, (threshold - diffToSunset) / threshold);
    }
    
    // Interpolate between three color states
    const mixTop = lerpColor(dayTopColor, sunsetTopColor, sunsetMix);
    const finalTopColor = lerpColor(nightTopColor, mixTop, dayFactor);
    
    // Draw as gradient bands... 
}

The sunset effect only appears when the sun is within 0.3 radians of the horizon, creating those brief but stunning golden hour moments.

Star Field with Fade Transitions

Stars appear at night and smoothly fade during sunrise:

function updateStars() {
    const dayFactor = Math.max(0, Math.sin(sun.angle));
    const threshold = 0.2;
    
    // Stars fade out when day factor exceeds threshold
    let fade = dayFactor < threshold ? (threshold - dayFactor) / threshold : 0;
    starsContainer.alpha = fade;
}

Stars are procedurally generated as 1-2 pixel white squares positioned in the top 25% of the screen.

Challenge 5: Interactive Character with Mouse Tracking

Procedural Character Generation

Instead of sprite sheets, the character is procedurally generated from colored rectangles:

function generateCharacterSprite() {
    const container = new PIXI.Container();
    const headSprite = generateHeadGraphic();    // 4x4 cells
    const armSprite = generateArmGraphic();      // 5x4 cells
    const legSprite = generateLegGraphic();      // 7x4 cells
    
    // Set pivot for rotation
    headSprite.pivot. set(8, 16);  // Bottom center
    headSprite.position.set(8, 16);
    
    // Scale arms and legs to half width for proportion
    armSprite.scale. set(0.5, 1);
    legSprite.scale.set(0.5, 1);
    
    container.addChild(headSprite);
    container.addChild(armSprite);
    container.addChild(legSprite);
    
    return container;
}

Each body part uses the same cell-based variation system as terrain blocks, creating a consistent "voxel" aesthetic.

Head Rotation Mechanics

The character's head rotates to track the mouse cursor, with rotation constraints to prevent unnatural angles:

// Calculate angle from face to mouse
const facePoint = new PIXI.Point(16, 8);  // Right edge center of head
const faceGlobal = player.sprite.headSprite.toGlobal(facePoint);
let computedRotation = Math.atan2(
    mousePos.y - faceGlobal.y, 
    mousePos.x - faceGlobal.x
);

// Constrain to natural head rotation range
if (computedRotation >= -1. 25 && computedRotation <= 1.75) {
    player.sprite. headSprite.rotation = computedRotation;
} else {
    // Smoothly interpolate back to forward-facing
    player.sprite.headSprite.rotation = lerp(
        player.sprite. headSprite.rotation, 
        0, 
        0.1
    );
}

The constraints prevent the head from rotating past the shoulders, while the interpolation creates a smooth "return to neutral" animation.

Interaction Systems: Mining and Building

Range-Based Interaction

All interactions check Euclidean distance from the player's center:

const INTERACTION_RANGE = 150;  // pixels

const playerCenter = { 
    x: player.x + BLOCK_SIZE / 2, 
    y: player.y + BLOCK_SIZE 
};
const blockCenter = { 
    x: blockX * BLOCK_SIZE + BLOCK_SIZE / 2, 
    y: blockY * BLOCK_SIZE + BLOCK_SIZE / 2 
};

const distance = Math.sqrt(
    (playerCenter.x - blockCenter. x) ** 2 + 
    (playerCenter.y - blockCenter.y) ** 2
);

if (distance > INTERACTION_RANGE) return;

This creates a natural "reach" circle around the player—you can't mine distant blocks.

Input Handling

  • Left-click: Break blocks and collect resources
  • Right-click (auxclick): Place selected block
  • W/A/S/D or Arrow Keys: Movement
  • Space/W/Up Arrow: Jump (only when grounded)

The inventory system uses event-driven UI where clicking a block type selects it:

inventorySlot.eventMode = 'static';
inventorySlot.cursor = 'pointer';
inventorySlot.on('pointerdown', () => {
    player.selectedBlock = blockType;
    createInventoryUI();  // Redraw with new selection
});

Performance Considerations

What I Optimized

  1. Static lighting flag: Procedural blocks skip lighting calculations using skipLighting flag
  2. Container-based rendering: PixiJS batches all blocks in one container
  3. Conditional redraws: Stars and sky only update when sun angle changes significantly

What Could Be Improved

  • Spatial partitioning: Currently checks all blocks for lighting (works for small worlds)
  • Chunk loading: Dynamic world expansion as player explores
  • GPU-based lighting: Shaders instead of per-block CPU calculations

Architecture Insights

Design Patterns Used

  • Entity-Component pattern: Player object contains state (position, velocity, inventory)
  • Layered rendering: Z-ordering through container hierarchy
  • Observer pattern: Inventory updates trigger UI redraws
  • Delta-time updates: Physics and animation tied to frame time

Data Structures

The world is a 2D array where world[x][y] references either:

  • A PixiJS Graphics object (solid block)
  • null (air)

This allows O(1) collision checks and efficient spatial queries.

What I Learned

Game Loop Architecture

Building the game loop from scratch taught me about:

  • Fixed timestep vs. variable timestep (I used variable with delta-time)
  • Update order matters (physics → rendering → input)
  • State management in event-driven systems

Graphics Programming

  • How linear interpolation creates smooth transitions
  • The power of procedural generation for varied content
  • Why separate update and render phases improve performance

The Importance of "Juice"

Small details create feel:

  • Block texture variation
  • Smooth lighting transitions
  • Head tracking animation
  • Organic world generation

These weren't necessary for functionality but made the game feel alive.

Development Journey

Commit History Analysis

  1. Initial Commit (April 5, 2025): Basic world generation and player movement
  2. Character Draft (April 9, 2025): Procedural character sprites and WASD support
  3. Jump Improvements (April 9, 2025): Continuous jump mechanics and spacebar support

The rapid iteration shows an experimental approach—building features, testing, and refining.

Technical Stack

Core Technologies:

  • PixiJS 7.2.4: WebGL-based 2D rendering
  • Vanilla JavaScript: No frameworks, pure ES6+
  • HTML5 Canvas: Through PixiJS abstraction

Mathematics:

  • Trigonometry (sun orbit, rotation)
  • Linear algebra (vector operations, lighting)
  • Ellipse equations (procedural shapes)
  • Linear interpolation (color blending, animations)

Potential Expansions

Immediate Improvements

  • Save/load system: LocalStorage serialization
  • More block types: Crafting recipes, tools, ores
  • Sound design: Environmental audio and interaction feedback

Advanced Features

  • Multiplayer: WebSocket-based synchronization
  • Procedural generation: Infinite worlds with Perlin noise
  • Particle systems: Mining effects, weather
  • Enemy AI: Pathfinding and behavior trees
  • Biome system: Different terrain types with unique generation rules

Conclusion: From Concept to Complete System

What began as a simple Minecraft clone evolved into a deep exploration of game engine fundamentals. Every system—from procedural generation to physics to lighting—was built from first principles, providing insights into how professional game engines work under the hood.

The project demonstrates that sophisticated game mechanics don't require complex frameworks. With solid fundamentals in rendering, mathematics, and architecture, you can build compelling interactive experiences entirely in the browser.


Technologies: JavaScript ES6+, PixiJS 7.2.4, WebGL, HTML5 Canvas
Concepts: Procedural generation, physics simulation, dynamic lighting, collision detection, event-driven architecture

Copyright © Rahul Hathwar. All Rights Reserved.