Build a GTA VI-Style World Streamer
Stream a 10,000-chunk procedural city with no loading screens.
Introduction
30 Second Summary
Every time you drive across a city in an open-world video game, the world loads seamlessly around you. The map is far too big to hold in memory, so the engine quietly streams pieces in and out as you move.
In this project, you will build a 2D open-world city streamer on HTML Canvas that loads a 10,000-chunk procedural city without a single loading screen. You will implement the same streaming pipeline used in production game engines and capture concrete before/after metrics for system design interviews.
What You'll Build
Move your player across a sprawling procedural city in the browser, toggle vehicle mode to blast through at 5x speed, and watch a real-time debug overlay reveal exactly how the streaming engine loads, renders, and evicts chunks around you.
By the end of this project, you'll have:
- A seamless procedural city of 10,000 chunks you can explore at 60 FPS with zero loading screens, plus concrete before/after metrics to quantify every optimization.
- A predictive pre-fetching system that uses your velocity vector to load chunks ahead of movement, keeping the world seamless even at 5x vehicle speed.
- A full debug overlay visualizing every layer of the streaming pipeline: FPS, frame time, chunk states, LOD levels, memory usage, and a minimap tracking your predicted position.
- Secret Mission: Implement LRU cache eviction with a hard memory budget that discards the least-recently-used chunks when memory pressure rises.
Are there any prerequisites?
This project assumes you are comfortable with intermediate JavaScript, including classes, event listeners, and requestAnimationFrame. You will also need Node.js and a code editor installed before starting.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up the Project and Canvas
Before you can stream a single chunk of the open world, you need a foundation to build on. Every system in this project, the streamer, the renderer, the prefetcher, plugs into a game loop that runs each frame.
In this step, you'll scaffold a Vite project, set up a full-viewport HTML Canvas, and write a game loop that draws a player square responding to keyboard input.
In this step, get ready to:
- Scaffold a Vite vanilla JavaScript project.
- Set up a full-viewport canvas in the browser.
- Build a game loop with player movement and camera following.
Scaffold a Vite vanilla JavaScript project
Vite is a fast build tool that serves your JavaScript modules with hot module reloading. You edit a file, save it, and the browser updates instantly without restarting a server.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project by running this command:
npm create vite@latest world-streamer -- --template vanilla
Seeing a confirmation prompt?
If npm asks you to install create-vite, type y and press Enter to confirm.
- Confirm the folder was created by running:
ls
You should see world-streamer listed in the output.
Not seeing world-streamer?
Make sure you ran the npm create command from the Desktop directory. Vite requires Node.js version 20.19+ or 22.12+. Check your version with node --version and upgrade if needed.
Help me troubleshoot why my Vite project didn't scaffold
- Move into the project folder and install dependencies by running these commands:
cd world-streamer
npm install
- Start the development server by running:
npm run dev
- Open your browser and navigate to http://localhost:5173.
You should see the default Vite counter app. This confirms your project is scaffolded and the dev server is running.
Keep the dev server running
The npm run dev command runs continuously in your terminal. Leave it running for the rest of this project. When you need to run other terminal commands later, open a new terminal tab or window.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type the name of your code editor and press Enter to open it.
- Open the world-streamer folder from your Desktop.
Replace the HTML with a full-viewport canvas
The scaffold created a default index.html with a <div> element for the counter app. You need a <canvas> element that fills the entire browser window instead.
- In your code editor, open index.html at the root of the world-streamer folder.
- Select all the existing content and replace it with this code:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>World Streamer</title>
<style>
/* Remove default spacing and fill the viewport */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #111; }
canvas { display: block; }
</style>
</head>
<body>
<canvas id="game-canvas"></canvas>
<script type="module" src="/src/main.js"></script>
</body>
</html>
What does this HTML do?
- The <style> block removes default margins, hides scrollbars with overflow: hidden, and sets a dark background.
- The <canvas> element with id="game-canvas" is where every frame of the game draws.
- The <script> tag loads your entry point src/main.js as an ES module.
- Save index.html.
Your browser auto-refreshes thanks to Vite's hot module reloading. You should see a dark page replacing the default Vite counter app.
Still seeing the Vite counter app?
Make sure you saved index.html. Check that the <canvas> tag has id="game-canvas". If the browser did not auto-refresh, manually refresh the page.
Help me troubleshoot why my canvas page isn't showing
✔️ Awesome, I've got everything!
Great. Make sure you've saved index.html before moving on.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>World Streamer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #111; }
canvas { display: block; }
</style>
</head>
<body>
<canvas id="game-canvas"></canvas>
<script type="module" src="/src/main.js"></script>
</body>
</html>
Build the game loop with player movement
A game loop is a function that runs every frame, typically 60 times per second. Each frame, it calculates how much time has passed, updates the game state, and redraws the canvas.
- Create a new file called src/game.js in your code editor.
- Add this code to set up the Game class with its constructor, input listeners, and game loop scaffold:
// Player movement speed in pixels per second
const WALK_SPEED = 200;
export class Game {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d', { alpha: false });
this.keys = {};
this.player = { x: 12800, y: 12800, vx: 0, vy: 0 };
this.lastTime = 0;
// Track which keys are currently held down
window.addEventListener('keydown', (e) => {
this.keys[e.key.toLowerCase()] = true;
});
window.addEventListener('keyup', (e) => {
this.keys[e.key.toLowerCase()] = false;
});
}
start() {
this.lastTime = performance.now();
requestAnimationFrame((t) => this.loop(t));
}
loop(timestamp) {
const dt = (timestamp - this.lastTime) / 1000;
this.lastTime = timestamp;
this.update(dt);
this.render();
requestAnimationFrame((t) => this.loop(t));
}
What does this code do?
- WALK_SPEED sets the player's movement to 200 pixels per second.
- The constructor grabs the 2D drawing context with alpha: false for a rendering performance boost, since the canvas never needs transparency.
- this.player starts at position (12800, 12800), which is the center of the future 100x100 chunk grid where each chunk is 256 pixels.
- loop() runs every frame. It calculates delta time (dt) in seconds so movement stays smooth regardless of frame rate.
- Below the loop() method, still inside the Game class, add the player update logic and rendering:
update(dt) {
let vx = 0, vy = 0;
if (this.keys['w'] || this.keys['arrowup']) vy = -WALK_SPEED;
if (this.keys['s'] || this.keys['arrowdown']) vy = WALK_SPEED;
if (this.keys['a'] || this.keys['arrowleft']) vx = -WALK_SPEED;
if (this.keys['d'] || this.keys['arrowright']) vx = WALK_SPEED;
// Normalize diagonal so it isn't faster than cardinal movement
if (vx !== 0 && vy !== 0) { vx *= 0.7071; vy *= 0.7071; }
this.player.vx = vx;
this.player.vy = vy;
this.player.x += vx * dt;
this.player.y += vy * dt;
}
render() {
const ctx = this.ctx;
const canvas = this.canvas;
// Clear the entire canvas each frame
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save();
// Shift drawing so the player is always at screen center
ctx.translate(canvas.width / 2 - this.player.x, canvas.height / 2 - this.player.y);
ctx.fillStyle = '#44ff44';
ctx.fillRect(this.player.x - 8, this.player.y - 8, 16, 16);
ctx.restore();
}
}
What does this code do?
- update() reads which keys are pressed and sets the player velocity accordingly. The 0.7071 multiplier normalizes diagonal movement so moving diagonally isn't faster than moving in a single direction.
- render() clears the canvas, then uses ctx.translate() to shift the drawing origin so the player is always at the center of the viewport. This is the camera follow effect.
- Save src/game.js.
- Confirm game.js appears under the src/ folder in your code editor's file explorer.
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/game.js before moving on.
ⓧ I'd like to double check the full code
Your src/game.js file should contain both code blocks you added above, in order. Scroll up and confirm these sections are present:
- The WALK_SPEED constant, the Game class constructor with input listeners, start(), and loop().
- The update() and render() methods, followed by the closing } of the class.
The Game class is ready. Now you need an entry point that creates the canvas, connects it to the game, and starts the loop.
- Open src/main.js in your code editor.
- Select all the existing content and replace it with this code:
import { Game } from './game.js';
// Set the canvas to fill the entire browser window
const canvas = document.getElementById('game-canvas');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
// Keep canvas full-size when the window is resized
window.addEventListener('resize', () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
});
const game = new Game(canvas);
game.start();
What does this code do?
- It grabs the <canvas> element by its id and sets its dimensions to fill the browser window.
- The resize listener keeps the canvas full-size whenever the window dimensions change.
- It creates a new Game instance, passes the canvas in, and calls start() to kick off the game loop.
Before you save, predict: what should appear on the canvas when the browser refreshes?
- Save src/main.js.
A green square appears at the center of a dark canvas. Press the W, A, S, D keys or arrow keys to move the player around. The square stays centered on screen while the camera viewport follows it.
Don't see the green square?
- Make sure both src/game.js and src/main.js are saved.
- Open the browser console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows) and check for errors. A common issue is a typo in the import path.
- Verify that index.html has id="game-canvas" on the <canvas> element and that the import in main.js matches './game.js' exactly.
Help me debug why my player isn't showing on the canvas
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/main.js before moving on.
ⓧ I'd like to double check the full code
import { Game } from './game.js';
const canvas = document.getElementById('game-canvas');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
window.addEventListener('resize', () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
});
const game = new Game(canvas);
game.start();
Why does the camera follow the player?
The ctx.translate() call shifts the entire drawing coordinate system each frame so the player's world position maps to the center of the screen. As the player moves through the world, everything else shifts around them. This is the same camera pattern used in 2D games to create the illusion of movement through a larger space.
Your game loop is running and the player moves smoothly across an empty canvas. Next up, you'll generate a massive procedural city and try to render all 10,000 chunks at once to see what happens to performance.
Generate a Massive City and Measure the Failure
Your game loop is running and your player dot moves smoothly across an empty canvas. That smooth 60 FPS is about to end.
Open-world games render cities far too large to fit in memory at once. To understand why streaming matters, you need to experience the alternative first.
In this step, you will generate a massive procedural city of 10,000 chunks, try to brute-force draw all of them every frame, and capture hard performance numbers that prove the naive approach fails.
In this step, get ready to:
- Generate 10,000 procedural city chunks in a 100x100 grid.
- Build a performance HUD that tracks FPS, frame time, and draw calls.
- Draw every chunk each frame and record your "before" baseline numbers.
Create the world data generator
The city is divided into chunks. Each chunk is a 256x256 pixel tile containing procedurally generated buildings, roads, or parks.
- Create a new file called src/world.js and paste in the following code:
export class WorldData {
// Manages a grid of procedural city chunks
constructor(cols, rows, chunkSize) {
this.cols = cols;
this.rows = rows;
this.chunkSize = chunkSize;
}
// Computes a representative color for LOD rendering
computeAvgColor(type, rng) {
if (type === 'park') return `hsl(130, 40%, 30%)`;
const hue = 210 + rng() * 20;
return `hsl(${hue}, 20%, 28%)`;
}
// Deterministic random number generator from a seed
seededRandom(seed) {
let s = seed;
return () => {
s = (s * 16807 + 0) % 2147483647;
return (s - 1) / 2147483646;
};
}
}
What does this code do?
WorldData stores the grid dimensions (100 columns, 100 rows) and chunk size (256 pixels).
seededRandom() creates a deterministic random number generator from a seed. The same seed always produces the same sequence of numbers, so chunk (50, 50) always generates identical buildings even if it gets unloaded and reloaded later.
computeAvgColor() returns a single representative color for the chunk. You will use this later when adding Level of Detail rendering.
- Add the generateChunk method to the WorldData class. Place it between the constructor and computeAvgColor by pasting this code:
generateChunk(cx, cy) {
// Deterministic seed: same coordinates always produce the same city block
const seed = cx * 7919 + cy * 6271;
const buildings = [];
const rng = this.seededRandom(seed);
const type = rng() > 0.85 ? 'park' : 'city';
if (type === 'park') {
buildings.push({ x: 0, y: 0, w: this.chunkSize, h: this.chunkSize, color: `hsl(${120 + rng() * 30}, 50%, ${25 + rng() * 15}%)`, isPark: true });
} else {
const roadWidth = 16;
buildings.push({ x: 0, y: 0, w: this.chunkSize, h: roadWidth, color: '#3a3a3a', isRoad: true });
buildings.push({ x: 0, y: 0, w: roadWidth, h: this.chunkSize, color: '#3a3a3a', isRoad: true });
const gridSize = 4 + Math.floor(rng() * 3);
const cellSize = (this.chunkSize - roadWidth) / gridSize;
for (let gx = 0; gx < gridSize; gx++) {
for (let gy = 0; gy < gridSize; gy++) {
if (rng() < 0.2) continue;
const bx = roadWidth + gx * cellSize + cellSize * 0.1;
const by = roadWidth + gy * cellSize + cellSize * 0.1;
const bw = cellSize * 0.8;
const bh = cellSize * 0.8;
const height = 0.3 + rng() * 0.7;
const hue = 200 + rng() * 40;
const lightness = 20 + height * 30;
buildings.push({ x: bx, y: by, w: bw, h: bh, color: `hsl(${hue}, 30%, ${lightness}%)`, outlineColor: `hsl(${hue}, 30%, ${lightness + 10}%)`, height });
}
}
}
return { cx, cy, buildings, avgColor: this.computeAvgColor(type, rng) };
}
What does generateChunk do?
Each chunk gets a deterministic seed from its grid coordinates (cx * 7919 + cy * 6271). This means chunk (50, 50) always generates the same buildings, even across page reloads.
About 15% of chunks become parks (green patches). The rest become city blocks with gray road strips and a grid of randomly sized buildings in a blue-gray palette.
- Save src/world.js.
✔️ Awesome, I've got everything!
Great. Confirm you have saved src/world.js with no syntax errors in your editor.
ⓧ I'd like to double check the full code
Your src/world.js should contain the WorldData class with four sections in this order:
- The constructor storing cols, rows, and chunkSize.
- The generateChunk method with the park/city branching logic and the building grid loop.
- The computeAvgColor method returning an HSL color string.
- The seededRandom method returning a closure.
Draw all 10,000 chunks each frame
Time to stress-test. You will update game.js to generate every chunk in the grid and draw it every single frame.
- In src/game.js, replace everything from the top of the file through the closing } of the constructor with this code:
import { WorldData } from './world.js';
const WALK_SPEED = 200;
export class Game {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d', { alpha: false });
this.keys = {};
this.player = { x: 12800, y: 12800, vx: 0, vy: 0 };
this.lastTime = 0;
// Track frame timing to measure performance
this.fps = 60;
this.frameTime = 0;
// Generate the full 100x100 city grid
this.worldData = new WorldData(100, 100, 256);
window.addEventListener('keydown', (e) => { this.keys[e.key.toLowerCase()] = true; });
window.addEventListener('keyup', (e) => { this.keys[e.key.toLowerCase()] = false; });
}
What changed in the constructor?
You added an import for WorldData and created a new instance with 100 columns, 100 rows, and a chunk size of 256 pixels. That is 10,000 chunks covering a 25,600 x 25,600 pixel world.
fps and frameTime will track how fast each frame renders, so you can measure the damage.
- In the loop() method, find this line:
this.lastTime = timestamp;
- Add these two lines directly below it:
this.frameTime = dt * 1000;
this.fps = dt > 0 ? 1 / dt : 60;
- Now replace the entire render() method with this version that loops through all 10,000 chunks:
render() {
const ctx = this.ctx;
const canvas = this.canvas;
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(canvas.width / 2 - this.player.x, canvas.height / 2 - this.player.y);
let drawCalls = 0;
const totalChunks = this.worldData.cols * this.worldData.rows;
// Loop through ALL 10,000 chunks and draw every one
for (let cy = 0; cy < this.worldData.rows; cy++) {
for (let cx = 0; cx < this.worldData.cols; cx++) {
const chunk = this.worldData.generateChunk(cx, cy);
const worldX = cx * this.worldData.chunkSize;
const worldY = cy * this.worldData.chunkSize;
for (const b of chunk.buildings) {
ctx.fillStyle = b.color;
ctx.fillRect(worldX + b.x, worldY + b.y, b.w, b.h);
drawCalls++;
}
}
}
ctx.fillStyle = '#44ff44';
ctx.fillRect(this.player.x - 8, this.player.y - 8, 16, 16);
ctx.restore();
}
What does the new render method do?
The nested for loops iterate over all 100 rows and 100 columns. For each of the 10,000 chunks, generateChunk() produces the buildings, and a third inner loop draws every building with ctx.fillRect().
That means thousands of draw calls per frame, every frame. The drawCalls counter tracks exactly how many.
Before you save, what do you think will happen when the browser tries to draw 10,000 chunks every frame?
- Save src/game.js. Vite will reload the page automatically.
You should see a colorful procedural city appear on the canvas. Try moving with WASD and notice how the player crawls through the world in painful slow motion.
Seeing a blank canvas or a console error?
- Check the browser console for import errors. Make sure your src/world.js file exports the WorldData class and the import path in game.js matches exactly: './world.js'.
- If you see a city but no lag, check that the loop iterates this.worldData.rows and this.worldData.cols (100 each), not a smaller number.
Still stuck? Help me debug why my canvas is blank after adding WorldData.
Measure the failure with a performance HUD
The lag is obvious, but you need hard numbers. A performance HUD will show exactly how badly the naive approach is performing so you can reference these numbers as your "before" baseline.
- Create a new file called src/perf-hud.js and paste in the following code:
export class PerfHUD {
constructor() {
// Store FPS readings for a rolling average
this.fpsHistory = [];
this.maxHistory = 30;
}
render(ctx, data) {
const { fps, frameTime, chunksProcessed, drawCalls, memoryEstimateKB } = data;
this.fpsHistory.push(fps);
if (this.fpsHistory.length > this.maxHistory) this.fpsHistory.shift();
// Smooth FPS by averaging the last 30 readings
const avgFps = this.fpsHistory.reduce((a, b) => a + b, 0) / this.fpsHistory.length;
const panelX = 10, panelY = 10, panelW = 280, lineHeight = 18;
const lines = [
`FPS: ${avgFps.toFixed(1)} (frame: ${frameTime.toFixed(1)}ms)`,
`Chunks processed: ${chunksProcessed}`,
`Draw calls: ${drawCalls}`,
`Est. memory: ${memoryEstimateKB.toFixed(1)} KB`,
];
const panelH = lines.length * lineHeight + 16;
- Paste the following code directly below to draw the HUD panel and finish the class:
// Draw semi-transparent background panel
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.fillRect(panelX, panelY, panelW, panelH);
ctx.strokeStyle = '#ff4444';
ctx.lineWidth = 1;
ctx.strokeRect(panelX, panelY, panelW, panelH);
// Render each metric line in red monospace text
ctx.fillStyle = '#ff6666';
ctx.font = '13px monospace';
ctx.textBaseline = 'top';
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], panelX + 8, panelY + 8 + i * lineHeight);
}
}
}
What does the PerfHUD do?
The HUD tracks FPS with a rolling 30-frame average to smooth out jitter. It displays four metrics: FPS, frame time in milliseconds, total chunks processed per frame, and estimated memory usage in KB.
The panel draws as red text on a semi-transparent black background in the top-left corner of the canvas.
- Save src/perf-hud.js.
✔️ Awesome, I've got everything!
Great. Confirm src/perf-hud.js is saved with no syntax errors.
ⓧ I'd like to double check the full code
Your src/perf-hud.js should contain the PerfHUD class with two sections:
- A constructor initializing fpsHistory and maxHistory.
- A render() method that computes a rolling FPS average, builds the lines array, then draws a black panel with red text.
Now wire the PerfHUD into game.js so the metrics appear on screen.
- In src/game.js, add this import line directly below the existing WorldData import:
import { PerfHUD } from './perf-hud.js';
- Find the line const WALK_SPEED = 200; and add this constant directly below it:
const BYTES_PER_CHUNK = 2048;
- Inside the constructor, find the line this.worldData = new WorldData(100, 100, 256); and add this line directly below it:
this.perfHUD = new PerfHUD();
- In the render() method, find the line ctx.restore();. Add the following code between ctx.restore(); and the closing } of render():
// Feed frame metrics to the performance HUD
this.perfHUD.render(ctx, {
fps: this.fps,
frameTime: this.frameTime,
chunksProcessed: totalChunks,
drawCalls,
memoryEstimateKB: (totalChunks * BYTES_PER_CHUNK) / 1024,
});
Why is perfHUD.render() called after ctx.restore()?
ctx.restore() resets the canvas transform back to screen coordinates. The HUD renders in fixed screen space (top-left corner), not in world space, so it must be drawn after the camera transform is removed.
Before you save, what numbers do you think the HUD will show for FPS and frame time?
- Save src/game.js. Vite reloads the page automatically.
A red-bordered metrics panel now appears in the top-left corner of the canvas. The numbers confirm what you felt: FPS is in the single digits, frame time is well above 100ms, chunks processed reads 10,000, and estimated memory shows the entire world loaded at once.
What do these baseline numbers mean?
At ~5 FPS and 100+ ms per frame, the browser is spending all its time iterating through 10,000 chunks and issuing thousands of draw calls. A playable game needs 60 FPS (under 17ms per frame). You are roughly 10-20x over budget.
These numbers are your "before" baseline. Every optimization in the next steps will be measured against them.
- Write down or take note of the four numbers the HUD is showing: FPS, frame time, chunks processed, and estimated memory. You will compare these against the optimized numbers later.
Not seeing the red metrics panel?
- Check the browser console for import errors. Confirm the import path says './perf-hud.js' and the file is saved as src/perf-hud.js.
- Make sure this.perfHUD = new PerfHUD(); is inside the constructor, not outside the class.
- Verify the this.perfHUD.render() call is after ctx.restore() but still inside the render() method.
Still stuck? Help me debug why my PerfHUD metrics panel is not appearing.
✔️ Awesome, I've got everything!
Great. Confirm src/game.js is saved and the red HUD is visible on screen.
ⓧ I'd like to double check the full code
Your src/game.js should have all of the following, in order:
- Two imports: WorldData from './world.js' and PerfHUD from './perf-hud.js'.
- Two constants: WALK_SPEED = 200 and BYTES_PER_CHUNK = 2048.
- The constructor with this.fps, this.frameTime, this.worldData, and this.perfHUD.
- The loop() method computing this.frameTime and this.fps right after this.lastTime = timestamp.
- The render() method looping through all 10,000 chunks, drawing the player, then calling this.perfHUD.render() with the metrics object.
You now have hard proof that drawing 10,000 chunks per frame is not viable. Next up, you will fix this by loading only the chunks near the player using spatial partitioning.
Implement Chunk-Based Streaming with Spatial Partitioning
In the previous step, you proved that the naive approach is broken. Drawing all 10,000 chunks every frame crushed the frame rate below 10 FPS. You now have hard baseline numbers to quantify every improvement from here on.
The fix is a core system design concept called spatial partitioning. Instead of touching every chunk, you only load and render chunks near the player. This step introduces two new modules that split the work cleanly: a WorldStreamer that decides which chunks to keep in memory, and a WorldRenderer that draws only what is visible.
In this step, get ready to:
- Create a WorldStreamer that loads chunks based on player proximity.
- Create a WorldRenderer that draws only visible chunks.
- Add vehicle mode to stress-test the streaming pipeline.
Create the WorldStreamer
The player is always at one point in a massive world. Every chunk far away is wasted work. The WorldStreamer's job is simple: figure out which chunks are close enough to load, and throw away everything else.
- Create a new file called src/streamer.js in your code editor.
- Paste the following code to set up the class constructor and the first half of the update() method:
export class WorldStreamer {
constructor(worldData, options = {}) {
this.worldData = worldData;
this.loadRadius = options.loadRadius || 5;
this.loadedChunks = new Map(); // loaded chunks keyed by "cx,cy"
this.evictionCount = 0;
this.loadedThisFrame = 0;
}
update(player) {
this.loadedThisFrame = 0;
const chunkSize = this.worldData.chunkSize;
// Convert pixel position to chunk grid coordinates
const pcx = Math.floor(player.x / chunkSize);
const pcy = Math.floor(player.y / chunkSize);
// Build the set of every chunk key within the load radius
const needed = new Set();
for (let dx = -this.loadRadius; dx <= this.loadRadius; dx++) {
for (let dy = -this.loadRadius; dy <= this.loadRadius; dy++) {
const cx = pcx + dx;
const cy = pcy + dy;
if (cx < 0 || cy < 0 || cx >= this.worldData.cols || cy >= this.worldData.rows) continue;
needed.add(`${cx},${cy}`);
}
}
What does this code do?
The constructor stores a reference to your WorldData and creates an empty Map to hold loaded chunks. The loadRadius of 5 means the streamer keeps an 11x11 grid of chunks around the player (5 in each direction plus the player's own chunk).
The update() method converts the player's pixel position to chunk coordinates using Math.floor(). It then builds a Set of all chunk keys within the radius. Each key is a simple string like "50,50" representing the chunk's grid position.
- Continue building src/streamer.js by pasting the remaining code directly below what you just added:
// Evict any loaded chunk outside the radius
for (const key of this.loadedChunks.keys()) {
if (!needed.has(key)) {
this.loadedChunks.delete(key);
this.evictionCount++;
}
}
// Load any needed chunk not yet in memory
for (const key of needed) {
if (!this.loadedChunks.has(key)) {
const [cx, cy] = key.split(',').map(Number);
const chunk = this.worldData.generateChunk(cx, cy);
this.loadedChunks.set(key, chunk);
this.loadedThisFrame++;
}
}
}
getLoadedChunks() { return this.loadedChunks; }
getStats() {
return {
loadedCount: this.loadedChunks.size,
queueLength: 0,
prefetchCount: 0,
evictionCount: this.evictionCount,
loadedThisFrame: this.loadedThisFrame,
};
}
}
What does the rest of the streamer do?
Two loops handle the streaming lifecycle. The first loop evicts any chunk that is loaded but NOT in the needed set. The second loop loads any chunk that IS needed but not yet in memory.
The getLoadedChunks() method exposes the loaded data without the streamer ever touching the canvas. The getStats() method returns counts for the PerfHUD. Notice the streamer never draws anything. It only manages data.
- Save src/streamer.js.
- Confirm the file exists by running:
ls src/
- You should see streamer.js alongside your existing files.
✔️ Awesome, I've got everything!
Great. Make sure you saved the file before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm these two code blocks are in src/streamer.js, in order:
- Block 1: The WorldStreamer class declaration, constructor, and the first half of update() (building the needed set).
- Block 2: The eviction loop, loading loop, getLoadedChunks(), getStats(), and the closing } brace.
Create the WorldRenderer and wire up the game loop
The streamer decides what data to keep in memory. The renderer decides what is visible on screen and draws it. This separation is the same pattern used in production game engines. Neither module knows the other's internals.
- Create a new file called src/renderer.js and paste the following code:
export class WorldRenderer {
constructor(chunkSize) {
this.chunkSize = chunkSize;
this.renderedCount = 0;
this.drawCallCount = 0;
}
render(ctx, loadedChunks, player, canvasWidth, canvasHeight) {
this.renderedCount = 0;
this.drawCallCount = 0;
const halfW = canvasWidth / 2;
const halfH = canvasHeight / 2;
// Only draw chunks whose screen position overlaps the viewport
for (const [key, chunk] of loadedChunks) {
const worldX = chunk.cx * this.chunkSize;
const worldY = chunk.cy * this.chunkSize;
const screenX = worldX - player.x + halfW;
const screenY = worldY - player.y + halfH;
if (screenX + this.chunkSize < -this.chunkSize || screenX > canvasWidth + this.chunkSize ||
screenY + this.chunkSize < -this.chunkSize || screenY > canvasHeight + this.chunkSize) continue;
this.renderedCount++;
for (const b of chunk.buildings) {
ctx.fillStyle = b.color;
ctx.fillRect(worldX + b.x, worldY + b.y, b.w, b.h);
this.drawCallCount++;
}
}
}
getStats() {
return { renderedCount: this.renderedCount, drawCallCount: this.drawCallCount, lod0: 0, lod1: 0, lod2: 0 };
}
}
What does this code do?
The renderer receives the loaded chunks from the streamer and checks each one against the viewport. If a chunk falls entirely outside the visible area, it is skipped. Only chunks on screen are drawn.
It counts renderedCount (how many chunks made it to the screen) and drawCallCount (total fillRect calls). The PerfHUD will show both numbers so you can see the difference between "in memory" and "on screen."
- Save src/renderer.js.
- Confirm both new files exist by running:
ls src/
- You should see streamer.js and renderer.js listed alongside your existing files.
✔️ Awesome, I've got everything!
Great. Make sure you saved the file before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm the single code block you pasted into src/renderer.js contains the WorldRenderer class with a constructor, render() method, and getStats() method.
Now you need to rewire src/game.js to use the streamer and renderer instead of the brute-force loop from Step 2.
- Open src/game.js in your editor.
- Select all the existing code and delete it.
- Paste the new imports, constants, and constructor:
import { WorldData } from './world.js';
import { WorldStreamer } from './streamer.js';
import { WorldRenderer } from './renderer.js';
import { PerfHUD } from './perf-hud.js';
const WALK_SPEED = 200;
export class Game {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d', { alpha: false });
this.keys = {};
this.player = { x: 12800, y: 12800, vx: 0, vy: 0 };
this.lastTime = 0;
this.fps = 60;
this.frameTime = 0;
this.worldData = new WorldData(100, 100, 256);
// Streamer manages which chunks stay in memory
this.streamer = new WorldStreamer(this.worldData, { loadRadius: 5 });
// Renderer handles visibility culling and drawing
this.renderer = new WorldRenderer(this.worldData.chunkSize);
this.perfHUD = new PerfHUD();
window.addEventListener('keydown', (e) => { this.keys[e.key.toLowerCase()] = true; });
window.addEventListener('keyup', (e) => { this.keys[e.key.toLowerCase()] = false; });
}
start() {
this.lastTime = performance.now();
requestAnimationFrame((t) => this.loop(t));
}
What changed from the old game.js?
The old file drew everything itself. The new version delegates: WorldStreamer decides which chunks to hold in memory, and WorldRenderer decides which of those to draw. The Game class just coordinates them.
- Save src/game.js. Your browser console will show an error because the loop() method does not exist yet. This confirms the new imports are loading correctly.
- Add the game loop and movement logic below start() by pasting this code:
loop(timestamp) {
const dt = (timestamp - this.lastTime) / 1000;
this.lastTime = timestamp;
this.frameTime = dt * 1000;
this.fps = dt > 0 ? 1 / dt : 60;
this.update(dt);
this.render();
requestAnimationFrame((t) => this.loop(t));
}
update(dt) {
let vx = 0, vy = 0;
if (this.keys['w'] || this.keys['arrowup']) vy = -WALK_SPEED;
if (this.keys['s'] || this.keys['arrowdown']) vy = WALK_SPEED;
if (this.keys['a'] || this.keys['arrowleft']) vx = -WALK_SPEED;
if (this.keys['d'] || this.keys['arrowright']) vx = WALK_SPEED;
if (vx !== 0 && vy !== 0) { vx *= 0.7071; vy *= 0.7071; }
this.player.vx = vx;
this.player.vy = vy;
this.player.x += vx * dt;
this.player.y += vy * dt;
// Clamp player position to world boundaries
const worldWidth = this.worldData.cols * this.worldData.chunkSize;
const worldHeight = this.worldData.rows * this.worldData.chunkSize;
this.player.x = Math.max(0, Math.min(worldWidth, this.player.x));
this.player.y = Math.max(0, Math.min(worldHeight, this.player.y));
// Feed the player's new position to the streamer
this.streamer.update(this.player);
}
What does this code do?
The loop() method is the same game loop from before. The key change is in update(): after moving the player, it now calls this.streamer.update(this.player). This tells the streamer the player's new position so it can load and evict chunks.
The new world-bounds clamping keeps the player from wandering outside the 100x100 grid.
- Add the render() method and the closing class brace below update() by pasting this code:
render() {
const ctx = this.ctx;
const canvas = this.canvas;
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(canvas.width / 2 - this.player.x, canvas.height / 2 - this.player.y);
// Get loaded chunks from the streamer, pass to the renderer
const loadedChunks = this.streamer.getLoadedChunks();
this.renderer.render(ctx, loadedChunks, this.player, canvas.width, canvas.height);
// Draw the player square
ctx.fillStyle = '#44ff44';
ctx.fillRect(this.player.x - 8, this.player.y - 8, 16, 16);
ctx.restore();
// Feed separate stats from streamer and renderer to the HUD
const streamerStats = this.streamer.getStats();
const rendererStats = this.renderer.getStats();
this.perfHUD.render(ctx, {
fps: this.fps,
frameTime: this.frameTime,
chunksProcessed: streamerStats.loadedCount,
drawCalls: rendererStats.drawCallCount,
memoryEstimateKB: streamerStats.loadedCount * 2.1,
});
}
}
What does this code do?
The old render() looped through all 10,000 chunks. The new version asks the streamer for its loaded chunks and hands them to the renderer. The renderer draws only the visible subset.
The PerfHUD now receives streamerStats.loadedCount (chunks in memory) and rendererStats.drawCallCount (actual draw operations) as separate numbers. This lets you see the difference between data in memory and pixels on screen.
- Save src/game.js.
- Switch to your browser at http://localhost:5173. The page should automatically refresh.
Move around with WASD. The PerfHUD should now show roughly 121 chunks processed (the 11x11 grid), FPS near 60, and frame time under 17ms. Compare that to the 10,000 chunks and sub-10 FPS you recorded in Step 2.
Still seeing a blank canvas or an error?
- Open your browser's developer console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows). Look for import errors. The file paths in your imports must match the exact filenames: ./streamer.js and ./renderer.js.
- Make sure you deleted ALL of the old code from src/game.js before pasting the new code. Leftover lines from the old brute-force loop can cause syntax errors.
- Check that your three code blocks in src/game.js are pasted in order with no gaps. The class must have one constructor, start(), loop(), update(), and render().
Why is my streaming world still lagging or showing 10,000 chunks?
Add vehicle mode and stress-test the streaming
Walking speed is a gentle test. Real open-world games handle vehicles moving at 5x walking speed or faster. You need to know if your proximity-only streamer holds up when the player starts moving fast.
- In src/game.js, find this line near the top:
const WALK_SPEED = 200;
- Add a new constant directly below it, so the two lines look like this:
const WALK_SPEED = 200;
const VEHICLE_SPEED = 1000;
- Inside the constructor, find this line:
this.player = { x: 12800, y: 12800, vx: 0, vy: 0 };
- Add this line directly below it:
this.vehicleMode = false;
- Still in the constructor, find the keydown event listener:
window.addEventListener('keydown', (e) => { this.keys[e.key.toLowerCase()] = true; });
- Replace it with this expanded version that toggles vehicle mode on V:
window.addEventListener('keydown', (e) => {
this.keys[e.key.toLowerCase()] = true;
if (e.key.toLowerCase() === 'v') this.vehicleMode = !this.vehicleMode;
});
- In the update() method, find this line:
let vx = 0, vy = 0;
if (this.keys['w'] || this.keys['arrowup']) vy = -WALK_SPEED;
if (this.keys['s'] || this.keys['arrowdown']) vy = WALK_SPEED;
if (this.keys['a'] || this.keys['arrowleft']) vx = -WALK_SPEED;
if (this.keys['d'] || this.keys['arrowright']) vx = WALK_SPEED;
- Replace those lines with this version that uses the active speed:
const speed = this.vehicleMode ? VEHICLE_SPEED : WALK_SPEED;
let vx = 0, vy = 0;
if (this.keys['w'] || this.keys['arrowup']) vy = -speed;
if (this.keys['s'] || this.keys['arrowdown']) vy = speed;
if (this.keys['a'] || this.keys['arrowleft']) vx = -speed;
if (this.keys['d'] || this.keys['arrowright']) vx = speed;
- In the render() method, find this line:
ctx.fillStyle = '#44ff44';
- Replace it with this line so the player turns red in vehicle mode:
ctx.fillStyle = this.vehicleMode ? '#ff4444' : '#44ff44';
- Save src/game.js.
Before you press V, think about this: the streamer loads chunks within a 5-chunk radius of the player's current position. At 5x walking speed, how quickly will the player cross those 5 chunks? Do you think the streamer can keep up?
- Switch to your browser and move around with WASD at walking speed. Confirm the PerfHUD still shows ~60 FPS and ~121 chunks.
- Press V to toggle vehicle mode. Your player square turns red.
- Hold a direction key (W, A, S, or D) and watch the edges of the screen.
You will see chunks popping in at the edges. The player is outrunning the load radius. Chunks that should already be visible are arriving late because the streamer only loads based on where the player IS, not where the player is HEADING.
Why does pop-in happen at vehicle speed?
At walking speed (200 pixels per second), the player takes about 6 seconds to cross the 5-chunk radius. The streamer has plenty of time to load new chunks before they reach the screen edge.
At vehicle speed (1,000 pixels per second), the player crosses the same radius in about 1.3 seconds. The streamer is still loading based on the player's current position, so chunks at the leading edge appear empty until the next frame catches up. This is the exact problem real game engines face with vehicles, fast travel, and speed boosts.
Not seeing any pop-in?
- Make sure you pressed V to enable vehicle mode. The player square should be red, not green.
- Hold a direction key for a sustained sprint, not just a quick tap. The pop-in is most visible during continuous high-speed movement toward the world edges.
Why am I not seeing chunk pop-in at vehicle speed?
✔️ Awesome, I've got everything!
Great. Make sure you saved all files before moving on.
ⓧ I'd like to double check the full code
Your src/game.js should now contain these sections, in order:
- Four imports: WorldData, WorldStreamer, WorldRenderer, PerfHUD.
- Two constants: WALK_SPEED = 200 and VEHICLE_SPEED = 1000.
- Constructor with vehicleMode, streamer, renderer, and the expanded keydown handler with V toggle.
- Methods: start(), loop(), update() with const speed = this.vehicleMode ? VEHICLE_SPEED : WALK_SPEED, and render() with the vehicle color toggle.
You just went from 10,000 chunks per frame to ~121 and recovered a silky 60 FPS. But vehicle mode exposed a real gap: proximity-only loading has no idea where you are heading. In the next step, you will add predictive pre-fetching that uses your velocity vector to load chunks ahead of you before they reach the screen.
Add Predictive Pre-Fetching to Fix the Pop-In
In the last step, vehicle mode revealed a critical gap in your streaming engine. At 1000 pixels per second, the player crosses chunk boundaries faster than the proximity-based loader can fill them. Empty space flashes at the edges before the streamer catches up.
The fix is predictive pre-fetching. Instead of loading only the chunks around the player's current position, the streamer will use the player's velocity vector to predict where they're heading and load chunks there before they arrive. This is the same technique production game engines use for vehicles, fast travel, and high-speed gameplay.
In this step, get ready to:
- Rebuild the WorldStreamer with a velocity-based prediction engine that loads chunks ahead of the player.
- Shape the prefetch zone asymmetrically so it loads more chunks in the direction of movement.
- Verify seamless streaming at both walking and vehicle speeds.
Rebuild the WorldStreamer with predictive pre-fetching
The current streamer only knows where the player is right now. It has no concept of where they're going. The prediction engine fixes this by computing a future position from the player's velocity and pre-loading chunks around that predicted point.
Why prediction instead of a larger radius?
You could simply increase the load radius to cover more ground. But a larger radius loads chunks in every direction equally, wasting memory on chunks behind and to the sides that the player will never reach at speed. Prediction concentrates the loading budget where it matters: directly ahead of the player.
You will replace the entire src/streamer.js file. The new version is built across four code blocks that you paste in sequence.
- In src/streamer.js, select all the existing code and delete it.
- Replace it with the following code to set up the constructor and proximity loading:
export class WorldStreamer {
constructor(worldData, options = {}) {
this.worldData = worldData;
this.loadRadius = options.loadRadius || 5;
// How many seconds ahead to predict player position
this.prefetchLookahead = options.prefetchLookahead || 2.5;
this.loadedChunks = new Map();
this.queue = [];
this.prefetchTargets = new Set();
this.evictionCount = 0;
this.loadedThisFrame = 0;
}
update(player) {
this.loadedThisFrame = 0;
const chunkSize = this.worldData.chunkSize;
const pcx = Math.floor(player.x / chunkSize);
const pcy = Math.floor(player.y / chunkSize);
// Proximity loading: chunks within loadRadius of player
const needed = new Set();
for (let dx = -this.loadRadius; dx <= this.loadRadius; dx++) {
for (let dy = -this.loadRadius; dy <= this.loadRadius; dy++) {
const cx = pcx + dx;
const cy = pcy + dy;
if (cx < 0 || cy < 0 || cx >= this.worldData.cols || cy >= this.worldData.rows) continue;
needed.add(`${cx},${cy}`);
}
}
What changed in the constructor?
Three new properties prepare the streamer for prediction. prefetchLookahead controls how far ahead (in seconds) to predict. queue is the priority queue for pending loads. prefetchTargets tracks which chunks are being loaded predictively vs by proximity.
- Directly below the code you just added, paste the prediction engine:
// Predictive pre-fetching using player velocity
this.prefetchTargets.clear();
if (player.vx !== 0 || player.vy !== 0) {
const predX = player.x + player.vx * this.prefetchLookahead;
const predY = player.y + player.vy * this.prefetchLookahead;
const predCX = Math.floor(predX / chunkSize);
const predCY = Math.floor(predY / chunkSize);
const speed = Math.sqrt(player.vx * player.vx + player.vy * player.vy);
const dirX = speed > 0 ? player.vx / speed : 0;
const dirY = speed > 0 ? player.vy / speed : 0;
// Asymmetric shape: extend further in movement direction
const forwardRadius = Math.ceil(this.loadRadius * 1.4);
const backRadius = Math.ceil(this.loadRadius * 0.6);
for (let dx = -forwardRadius; dx <= forwardRadius; dx++) {
for (let dy = -forwardRadius; dy <= forwardRadius; dy++) {
const cx = predCX + dx;
const cy = predCY + dy;
if (cx < 0 || cy < 0 || cx >= this.worldData.cols || cy >= this.worldData.rows) continue;
const dot = dx * dirX + dy * dirY;
const maxDist = dot >= 0 ? forwardRadius : backRadius;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > maxDist) continue;
const key = `${cx},${cy}`;
needed.add(key);
if (!this.loadedChunks.has(key)) this.prefetchTargets.add(key);
}
}
}
How does the prediction and direction weighting work?
The predicted position is calculated as playerPosition + velocity * lookaheadSeconds. At vehicle speed (1000 px/s) with a 2.5-second lookahead, the streamer looks 2500 pixels ahead. That is nearly 10 chunks into the future.
The direction weighting uses a dot product to classify each candidate chunk as "ahead" or "behind" the player. Chunks ahead get a forwardRadius of 7 chunks. Chunks behind get a backRadius of only 3. This teardrop shape concentrates loading budget where the player is actually heading.
- Continue directly below with the eviction logic and priority queue:
// Evict chunks no longer in the needed set
for (const key of this.loadedChunks.keys()) {
if (!needed.has(key)) {
this.loadedChunks.delete(key);
this.evictionCount++;
}
}
// Build priority queue sorted by distance from player
this.queue = [];
for (const key of needed) {
if (!this.loadedChunks.has(key)) {
const [cx, cy] = key.split(',').map(Number);
const dist = Math.sqrt((cx - pcx) ** 2 + (cy - pcy) ** 2);
this.queue.push({ key, cx, cy, dist });
}
}
this.queue.sort((a, b) => a.dist - b.dist);
for (const item of this.queue) {
const chunk = this.worldData.generateChunk(item.cx, item.cy);
this.loadedChunks.set(item.key, chunk);
this.loadedThisFrame++;
}
}
What does the priority queue do?
Instead of loading needed chunks in an arbitrary order, the queue sorts them by distance from the player. The closest chunks load first, so the player always sees the most important chunks appear before the distant ones. This prevents a frame where far-away prefetch chunks load while nearby chunks are still missing.
- Finally, close the class by adding the stats methods below:
// Return loaded chunks for the renderer
getLoadedChunks() { return this.loadedChunks; }
getStats() {
return {
loadedCount: this.loadedChunks.size,
queueLength: this.queue.length,
prefetchCount: this.prefetchTargets.size,
evictionCount: this.evictionCount,
loadedThisFrame: this.loadedThisFrame,
};
}
}
What's new in getStats?
The stats now include queueLength (how many chunks are waiting to load) and prefetchCount (how many of those are predictive loads). The PerfHUD already reads from getStats(), so these values are available immediately.
- Save src/streamer.js.
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/streamer.js before moving on.
ⓧ I'd like to double check the full code
Since the full file is longer than 30 lines, scroll up and confirm each of these four blocks is present in order inside src/streamer.js:
- The constructor with prefetchLookahead, queue, and prefetchTargets properties, followed by the proximity loop in update().
- The prediction block with predX/predY, forwardRadius/backRadius, and the direction-weighted scan loop.
- The eviction loop, the priority queue build with .sort(), and the chunk loading loop that closes update().
- The getLoadedChunks() and getStats() methods closing the class.
Wire the prediction config and test at vehicle speed
The new streamer accepts a prefetchLookahead option, but src/game.js is not passing it yet. One line edit connects the two.
- In src/game.js, find this line in the constructor:
this.streamer = new WorldStreamer(this.worldData, { loadRadius: 5 });
- Replace it with this line to pass the lookahead config:
this.streamer = new WorldStreamer(this.worldData, { loadRadius: 5, prefetchLookahead: 2.5 });
- Save src/game.js.
Before you check the browser, think about what you expect to see. At walking speed, will the behavior look any different from before? What about at vehicle speed?
- Switch to your browser at http://localhost:5173. Vite hot-reloads automatically.
- Move with WASD at walking speed. The streaming should feel identical to before. The PerfHUD should show ~60 FPS and roughly the same loaded chunk count (~121).
- Press V to toggle vehicle mode. Your player square turns red.
- Hold a direction key and move at full vehicle speed in any direction.
The pop-in is gone. Chunks now load seamlessly ahead of you at vehicle speed with no visible gaps. The PerfHUD shows a slight increase in loaded chunks (the prefetch zone adds to the proximity zone), but FPS remains at 60.
Why does walking speed feel unchanged?
At 200 px/s, the 2.5-second lookahead predicts only about 2 chunks ahead. The proximity radius already covers that area, so the prediction adds almost nothing. The system is self-regulating: it only does extra work when the player moves fast enough to need it.
Seeing blank space or errors in the console?
- Check that your four code blocks in src/streamer.js are pasted in order with no gaps or duplicated lines between them.
- Make sure the prefetchLookahead: 2.5 option in src/game.js is inside the same options object as loadRadius: 5, separated by a comma.
- If the player does not move at all, confirm you did not accidentally delete the getLoadedChunks() method at the bottom of the class.
Need more help? Ask: Why am I seeing blank chunks or console errors after adding predictive pre-fetching to my WorldStreamer?
Your streaming engine now handles both walking and vehicle speeds without a single frame of pop-in. Next up, you will add a Level of Detail system to reduce rendering cost for distant chunks and build a full debug overlay that makes the entire streaming pipeline visible.
Add LOD and the Debug Overlay
Your streaming pipeline now loads and pre-fetches chunks seamlessly at any speed. But the renderer still draws every loaded chunk at full detail, regardless of how far it is from the player. A chunk five tiles away gets the same expensive building outlines as the chunk directly under your feet.
This step adds Level of Detail (LOD) to cut rendering cost for distant chunks. Then you will replace the PerfHUD with a full debug overlay that visualizes the entire streaming pipeline, so you can walk an interviewer through every layer.
In this step, get ready to:
- Add a three-level LOD system that renders nearby chunks in full detail and distant chunks as simple color blocks.
- Build a comprehensive debug overlay with live stats, a baseline-vs-now comparison, and a minimap.
- Wire the overlay into the game loop so you can toggle it with the D key.
Add Level of Detail to the renderer
LOD works by rendering chunks at different quality tiers based on distance. Chunks right next to the player get full building outlines. Chunks at medium range drop the outlines. Chunks at the far edge collapse into a single solid-color rectangle, costing just one draw call.
- In src/renderer.js, find the constructor. Add a lodCounts array to track how many chunks render at each LOD level.
- Find this line in the constructor:
this.renderedCount = 0;
- Add this.lodCounts = [0, 0, 0]; directly above it, so the constructor now looks like this:
constructor(chunkSize) {
this.chunkSize = chunkSize;
this.lodCounts = [0, 0, 0];
this.renderedCount = 0;
this.drawCallCount = 0;
}
- Now replace the entire render() method with this LOD-aware version:
render(ctx, loadedChunks, player, canvasWidth, canvasHeight) {
this.lodCounts = [0, 0, 0];
this.renderedCount = 0;
this.drawCallCount = 0;
const halfW = canvasWidth / 2;
const halfH = canvasHeight / 2;
// Player chunk coordinates for LOD distance calculation
const pcx = Math.floor(player.x / this.chunkSize);
const pcy = Math.floor(player.y / this.chunkSize);
for (const [key, chunk] of loadedChunks) {
const worldX = chunk.cx * this.chunkSize;
const worldY = chunk.cy * this.chunkSize;
const screenX = worldX - player.x + halfW;
const screenY = worldY - player.y + halfH;
if (screenX + this.chunkSize < -this.chunkSize || screenX > canvasWidth + this.chunkSize ||
screenY + this.chunkSize < -this.chunkSize || screenY > canvasHeight + this.chunkSize) continue;
this.renderedCount++;
const dist = Math.sqrt((chunk.cx - pcx) ** 2 + (chunk.cy - pcy) ** 2);
let lod;
if (dist <= 2) lod = 0;
else if (dist <= 4) lod = 1;
else lod = 2;
this.lodCounts[lod]++;
this.renderChunk(ctx, chunk, worldX, worldY, lod);
}
}
What does this code do?
The render loop is mostly the same viewport culling you already had. The new part is the LOD selection at the bottom of the loop.
- pcx and pcy convert the player's pixel position to chunk-grid coordinates so you can measure distance in chunks.
- dist measures how many chunks away each visible chunk is. LOD 0 (0-2 chunks) gets full detail. LOD 1 (3-4 chunks) gets simplified rendering. LOD 2 (5+ chunks) is the cheapest.
- renderChunk() is a new method you will add next that draws each chunk differently based on its LOD level.
- Below the render() method you just pasted, add the renderChunk() method:
renderChunk(ctx, chunk, worldX, worldY, lod) {
// LOD 2: distant chunks are a single color block
if (lod === 2) {
ctx.fillStyle = chunk.avgColor;
ctx.fillRect(worldX, worldY, this.chunkSize, this.chunkSize);
this.drawCallCount++;
return;
}
// LOD 0 and 1: draw background then each building
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(worldX, worldY, this.chunkSize, this.chunkSize);
this.drawCallCount++;
for (const b of chunk.buildings) {
ctx.fillStyle = b.color;
ctx.fillRect(worldX + b.x, worldY + b.y, b.w, b.h);
this.drawCallCount++;
// LOD 0 only: add outlines for maximum detail
if (lod === 0 && b.outlineColor) {
ctx.strokeStyle = b.outlineColor;
ctx.lineWidth = 1;
ctx.strokeRect(worldX + b.x, worldY + b.y, b.w, b.h);
this.drawCallCount++;
}
}
}
How do the three LOD levels differ?
- LOD 2 is the cheapest. It uses chunk.avgColor (the averaged color your WorldData already generates) to fill one rectangle. One draw call per chunk.
- LOD 1 draws each building as a flat colored block. No outlines, so roughly half the draw calls of LOD 0.
- LOD 0 is full detail. Every building gets a fill AND a stroke outline. The most draw calls, but only applied to chunks within 2 tiles of the player.
- Finally, replace the existing getStats() method so it reports the LOD distribution:
getStats() {
// Report LOD breakdown alongside rendering totals
return {
lod0: this.lodCounts[0],
lod1: this.lodCounts[1],
lod2: this.lodCounts[2],
renderedCount: this.renderedCount,
drawCallCount: this.drawCallCount,
};
}
}
- Save src/renderer.js and refresh your browser.
- Move around with WASD. Chunks right next to your player show crisp building outlines. Chunks a few tiles away appear as flat colored blocks without outlines. Chunks at the far edge of your view are single solid-color rectangles.
Seeing a blank screen or errors?
- Open the browser console. If you see this.renderChunk is not a function, make sure your renderChunk() method is inside the WorldRenderer class, not outside the closing brace.
- Check that the getStats() method still sits inside the class and the file ends with a closing } for the class.
Why aren't my LOD levels rendering correctly?
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
Since src/renderer.js is a longer file, scroll up and confirm these pieces are present in order:
- The constructor with this.lodCounts = [0, 0, 0] above this.renderedCount.
- The render() method calculating pcx/pcy and selecting LOD 0, 1, or 2 based on distance.
- The renderChunk() method with the LOD 2 early-return path and the LOD 0 outline check.
- The updated getStats() returning lod0, lod1, lod2 from this.lodCounts.
Create the debug overlay
The PerfHUD tracked four basic metrics. The debug overlay replaces it with a comprehensive panel that exposes every layer of the streaming pipeline: loaded chunks, queued chunks, prefetch targets, eviction counts, LOD distribution, and a side-by-side comparison with the baseline numbers from Step 2.
- Create a new file called src/debug-overlay.js.
- Start the file with the overlay's data model. This defines every line the panel will display:
export class DebugOverlay {
render(ctx, data) {
const { fps, frameTime, streamerStats, rendererStats, player, vehicleMode, chunkSize, worldCols, worldRows } = data;
const panelX = 10, panelY = 10, panelW = 290, lineHeight = 18;
// Every metric line the overlay displays
const lines = [
`FPS: ${fps.toFixed(1)} Frame: ${frameTime.toFixed(1)}ms`,
`Mode: ${vehicleMode ? 'VEHICLE (V)' : 'Walking (V)'}`,
`Player: ${Math.floor(player.x)}, ${Math.floor(player.y)}`,
`Velocity: ${Math.floor(player.vx)}, ${Math.floor(player.vy)}`,
`---`,
`Loaded chunks: ${streamerStats.loadedCount}`,
`Rendered chunks: ${rendererStats.renderedCount}`,
`Draw calls: ${rendererStats.drawCallCount}`,
`Queued: ${streamerStats.queueLength}`,
`Prefetched: ${streamerStats.prefetchCount}`,
`Evictions: ${streamerStats.evictionCount}`,
`Loaded this frame: ${streamerStats.loadedThisFrame}`,
`Est. memory: ${(streamerStats.loadedCount * 2.1).toFixed(1)} KB`,
`---`,
`LOD 0 (full): ${rendererStats.lod0}`,
`LOD 1 (simple): ${rendererStats.lod1}`,
`LOD 2 (block): ${rendererStats.lod2}`,
`---`,
`BASELINE (naive): 10000 chunks ~5 FPS`,
`NOW: ${streamerStats.loadedCount} chunks ${fps.toFixed(0)} FPS`,
`---`,
`[D] toggle overlay [V] vehicle mode`,
];
What's in the lines array?
- The first section shows player state: FPS, frame time, movement mode, position, and velocity.
- The middle section is the streaming pipeline: how many chunks are loaded, rendered, queued, prefetched, and evicted.
- The BASELINE and NOW lines give you an instant before/after comparison for interviews. The baseline numbers are hardcoded from your Step 2 measurements.
- Directly below the lines array, add the panel drawing and text rendering logic that closes the render method:
const panelH = lines.length * lineHeight + 16;
ctx.fillStyle = 'rgba(0, 0, 0, 0.75)';
ctx.fillRect(panelX, panelY, panelW, panelH);
ctx.strokeStyle = '#444';
ctx.lineWidth = 1;
ctx.strokeRect(panelX, panelY, panelW, panelH);
ctx.font = '13px monospace';
ctx.textBaseline = 'top';
// Color-code lines: red for baseline, green for current
for (let i = 0; i < lines.length; i++) {
const text = lines[i];
if (text === '---') {
ctx.strokeStyle = '#333';
ctx.beginPath();
ctx.moveTo(panelX + 8, panelY + 8 + i * lineHeight + lineHeight / 2);
ctx.lineTo(panelX + panelW - 8, panelY + 8 + i * lineHeight + lineHeight / 2);
ctx.stroke();
} else if (text.startsWith('BASELINE')) {
ctx.fillStyle = '#ff6666';
ctx.fillText(text, panelX + 8, panelY + 8 + i * lineHeight);
} else if (text.startsWith('NOW:')) {
ctx.fillStyle = '#66ff88';
ctx.fillText(text, panelX + 8, panelY + 8 + i * lineHeight);
} else {
ctx.fillStyle = '#00ff88';
ctx.fillText(text, panelX + 8, panelY + 8 + i * lineHeight);
}
}
this.renderMinimap(ctx, data, panelX, panelY + panelH + 8);
}
What does the color-coding do?
The drawing loop checks each line's text to decide its color. Lines starting with BASELINE render in red. Lines starting with NOW: render in bright green. The --- entries draw horizontal divider lines instead of text.
- Below the render() method's closing brace, add the renderMinimap() method that draws a bird's-eye view of the world:
renderMinimap(ctx, data, mapX, mapY) {
const { player, chunkSize, worldCols, worldRows } = data;
const mapSize = 160;
const scale = mapSize / Math.max(worldCols, worldRows);
ctx.fillStyle = 'rgba(0, 0, 0, 0.75)';
ctx.fillRect(mapX, mapY, worldCols * scale, worldRows * scale);
ctx.strokeStyle = '#444';
ctx.lineWidth = 1;
ctx.strokeRect(mapX, mapY, worldCols * scale, worldRows * scale);
// Player position as a green dot
const pcx = Math.floor(player.x / chunkSize);
const pcy = Math.floor(player.y / chunkSize);
ctx.fillStyle = '#00ff88';
ctx.fillRect(mapX + pcx * scale - 2, mapY + pcy * scale - 2, 4, 4);
// Predicted position as a blue dot with dashed connector
if (player.vx !== 0 || player.vy !== 0) {
const predX = player.x + player.vx * 2.5;
const predY = player.y + player.vy * 2.5;
const predCX = predX / chunkSize;
const predCY = predY / chunkSize;
ctx.fillStyle = '#4488ff';
ctx.fillRect(mapX + predCX * scale - 2, mapY + predCY * scale - 2, 4, 4);
ctx.strokeStyle = '#4488ff';
ctx.setLineDash([2, 2]);
ctx.beginPath();
ctx.moveTo(mapX + pcx * scale, mapY + pcy * scale);
ctx.lineTo(mapX + predCX * scale, mapY + predCY * scale);
ctx.stroke();
ctx.setLineDash([]);
}
}
}
What does the minimap show?
The minimap draws a scaled-down rectangle of the full 100x100 world. A green dot marks the player's current chunk. When the player is moving, a blue dot marks where the pre-fetcher predicts the player is heading, connected by a dashed line.
- Save src/debug-overlay.js.
✔️ Awesome, I've got everything!
Great. The overlay file is ready. Next you will wire it into the game loop.
ⓧ I'd like to double check the full code
Since src/debug-overlay.js is a longer file, scroll up and confirm these pieces are present in order:
- The class opening and render() method with the lines array containing 21 metric entries.
- The panel drawing loop that color-codes BASELINE red, NOW green, and --- as divider lines.
- The renderMinimap() method drawing a green player dot and a blue predicted-position dot with a dashed connector.
Wire the overlay into the game loop
The overlay file is ready but nothing imports it yet. You need to swap out the PerfHUD for the DebugOverlay in src/game.js and add a D key toggle.
- In src/game.js, find this import line at the top:
import { PerfHUD } from './perf-hud.js';
- Replace it with:
import { DebugOverlay } from './debug-overlay.js';
- In the constructor, find this line:
this.perfHUD = new PerfHUD();
- Replace it with these two lines. The showDebug flag controls whether the overlay is visible:
this.showDebug = true;
this.lastTime = 0;
this.fps = 60;
this.frameTime = 0;
this.worldData = new WorldData(100, 100, 256);
this.streamer = new WorldStreamer(this.worldData, { loadRadius: 5, prefetchLookahead: 2.5 });
this.renderer = new WorldRenderer(this.worldData.chunkSize);
this.debugOverlay = new DebugOverlay();
What changed here?
You added this.showDebug = true so the overlay starts visible, and replaced this.perfHUD with this.debugOverlay. The lines between are unchanged.
- Still in the constructor, find the keydown listener. It currently looks like this:
window.addEventListener('keydown', (e) => {
this.keys[e.key.toLowerCase()] = true;
if (e.key.toLowerCase() === 'v') this.vehicleMode = !this.vehicleMode;
});
- Add the D key toggle on a new line below the V key line, so the listener now looks like this:
window.addEventListener('keydown', (e) => {
this.keys[e.key.toLowerCase()] = true;
if (e.key.toLowerCase() === 'v') this.vehicleMode = !this.vehicleMode;
if (e.key.toLowerCase() === 'd') this.showDebug = !this.showDebug;
});
- Scroll down to the render() method. Find the PerfHUD section after ctx.restore():
ctx.restore();
const streamerStats = this.streamer.getStats();
const rendererStats = this.renderer.getStats();
this.perfHUD.render(ctx, {
fps: this.fps,
frameTime: this.frameTime,
chunksProcessed: streamerStats.loadedCount,
drawCalls: rendererStats.drawCallCount,
memoryEstimateKB: streamerStats.loadedCount * 2.1,
});
- Replace everything after (and including) the old stats variables with this conditional overlay call:
ctx.restore();
// Render debug overlay after ctx.restore so it stays on top
if (this.showDebug) {
this.debugOverlay.render(ctx, {
fps: this.fps,
frameTime: this.frameTime,
streamerStats: this.streamer.getStats(),
rendererStats: this.renderer.getStats(),
player: this.player,
vehicleMode: this.vehicleMode,
chunkSize: this.worldData.chunkSize,
worldCols: this.worldData.cols,
worldRows: this.worldData.rows,
});
}
Why does the overlay render after ctx.restore?
The ctx.save() / ctx.translate() / ctx.restore() block applies a camera offset so the world scrolls relative to the player. The overlay needs to draw in screen-space (fixed in the top-left corner), so it renders after ctx.restore() resets the transform. If it rendered inside the save/restore, it would scroll off-screen with the world.
Before you save and test, think about this. What do you expect the overlay's BASELINE vs NOW comparison to show?
- Save src/game.js and refresh your browser.
A green data panel appears in the top-left corner. The BASELINE line in red reads 10000 chunks ~5 FPS. The NOW line in bright green shows your current loaded chunk count and FPS. Move with WASD and watch every number update live. Press V to toggle vehicle mode and see the prefetch count jump. The minimap below the panel shows a green dot for your position and a blue dot for the predicted position, connected by a dashed line.
- Press D to toggle the overlay off and on.
Overlay not showing up?
- Check the browser console for errors. A DebugOverlay is not a constructor error means the import path is wrong. Confirm the import says './debug-overlay.js'.
- If the overlay flickers when you press D, note that D is also the right-movement key. The showDebug toggle fires on every keydown. This is expected behavior.
- If you see numbers but no minimap, check that renderMinimap() is defined inside the DebugOverlay class and that the this.renderMinimap() call is at the end of the render() method.
Why isn't my debug overlay appearing on screen?
✔️ Awesome, I've got everything!
Great. Double check you have saved all files before moving on.
ⓧ I'd like to double check the full code
Since src/game.js is a longer file, scroll up and confirm these changes are present:
- The import at the top says import { DebugOverlay } from './debug-overlay.js' with no PerfHUD import remaining.
- The constructor has this.showDebug = true and this.debugOverlay = new DebugOverlay() with no this.perfHUD remaining.
- The keydown listener has both the V toggle and the D toggle for showDebug.
- The render() method calls this.debugOverlay.render() inside an if (this.showDebug) block, after ctx.restore().
Your streaming engine now has every layer in place: spatial partitioning, predictive pre-fetching, LOD, and a debug overlay that makes the whole pipeline visible. You have concrete before/after numbers to reference in any system design conversation. Up next is an optional Secret Mission to add LRU cache eviction with a hard memory budget.
Secret mission
Implement LRU Cache Eviction with a Memory Budget
Your streaming engine unloads chunks purely by distance, but a real engine uses an LRU cache with a hard memory cap. Add LRU eviction so the system keeps frequently revisited chunks warm and discards the coldest data when memory pressure rises.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with no cloud services or paid APIs, so there are no ongoing costs.
Resources you used:
- The world-streamer project folder on your Desktop (contains all source files and node_modules dependencies).
- The Vite dev server process (if still running in your terminal).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with the streaming engine.
- Your Vite dev server stays running in the terminal. Revisit the demo at http://localhost:5173 any time.
- The world-streamer folder stays on your Desktop for future edits.
Pause - I'll come back to this later
Shut down the running dev server to free up your terminal. Your project files stay on disk so you can resume later.
- Switch to the terminal where the Vite dev server is running.
- Press Ctrl+C to stop the server.
- When you are ready to resume, open a terminal in the world-streamer folder and run npm run dev to start the server again.
Delete - I don't want to use this again
Remove all project files and stop the dev server. This frees up disk space and fully cleans up the project.
- Switch to the terminal where the Vite dev server is running.
- Press Ctrl+C to stop the server.
- Navigate to your Desktop and delete the project folder by running these commands.
macOS
cd ~/Desktop
rm -rf world-streamer
Windows
cd ~\Desktop
rmdir /s /q world-streamer
- Confirm the folder is gone by running ls (macOS) or dir (Windows) on your Desktop. You should no longer see world-streamer in the listing.
Nice Work!
Nice Work!
You just built a full open-world streaming engine from scratch. A 10,000-chunk procedural city streams seamlessly in your browser with zero loading screens, and you have a live debug overlay to prove exactly how it works.
You've learned how to:
- Build a chunk-based streaming engine with spatial partitioning and predictive pre-fetching that loads only nearby chunks, uses the player's velocity vector to load ahead, and captures concrete before/after metrics (10,000 chunks at sub-10 FPS down to ~121 loaded chunks at 60 FPS).
- Separate the streaming pipeline into a WorldStreamer (data lifecycle) and a WorldRenderer (visibility and drawing) with a three-level LOD system, mirroring how production game engines structure this architecture.
- Build a comprehensive debug overlay that visualizes loaded, queued, prefetched, and evicted chunks, LOD distribution, a minimap with predicted player position, and a baseline-vs-current comparison you can use to whiteboard the entire streaming pipeline in an interview.
- Secret Mission: Implemented LRU cache eviction with a hard memory budget that intelligently discards the least-recently-used chunks when memory pressure rises.
Ready to quiz yourself?