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:
- Sky Container (bottom layer) - Dynamic gradient background
- Stars Container - Procedural star field with alpha transitions
- Block Container - All terrain blocks with dynamic lighting
- Cloud Container - Organic cloud formations
- Player Container - Character sprite with head tracking
- 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:
- Tests multiple cells across the player's height/width
- Resolves each axis independently to prevent compound errors
- 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
- Static lighting flag: Procedural blocks skip lighting calculations using
skipLightingflag - Container-based rendering: PixiJS batches all blocks in one container
- 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
- Initial Commit (April 5, 2025): Basic world generation and player movement
- Character Draft (April 9, 2025): Procedural character sprites and WASD support
- 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