A 3D voxel-based game engine built from scratch with C++ and OpenGL, compiled to WebAssembly for browser-based gameplay. Features procedural terrain generation, dynamic lighting, basic physics, and basic mechanics such as breaking and placing blocks.
Click to focus. Use WASD to move, mouse to look around, click to break blocks, right-click to place blocks. Press ESC for options.
This is an ongoing personal learning project to understand GPU APIs like OpenGL by building a Minecraft-style voxel sandbox and shipping something playable.
Terrain generation is refreshingly straightforward: for each chunk, the system samples a 2D simplex noise function across the X-Z plane and uses the result as a height value. Simplex noise produces smooth, continuous random values, perfect for creating natural-looking landscapes without precomputation.
The core terrain generation happens in Map::genHeightMap(), which runs when a chunk is created:
GLfloat heightV = glm::simplex(glm::vec2{
(x + (Constants::CHUNK_SIZE * xCord)) / 64.0f,
(y + (Constants::CHUNK_SIZE * yCord)) / 64.0f
});
heightV += 1; // Shift range from [-1, 1] to [0, 2]
heightV /= 2; // Normalize to [0, 1]
heightV *= 16; // Scale to [0, 16] block heights
The division by 64.0 controls the frequency of the noise. This is the scaling factor. Smaller values (like 32) create more variation and rugged terrain, while larger values (like 128) create gentle rolling hills. The current value of 64 is a middle ground that produces interesting, explorable landscapes.
The algorithm:
glm::simplex() at the world coordinates scaled by frequencyThis means each horizontal position in the world has a deterministic height. Because simplex noise is continuous, adjacent positions produce similar heights, creating smooth terrain rather than random spikes.
Rendering a voxel world naively would bring any GPU to its knees. Imagine trying to draw every face of every block in a world. Even a modest view distance would mean millions of triangles per frame. The solution lies in a combination of smart data structures and aggressive culling techniques that together turn an impossible rendering problem into something that runs smoothly even in a browser.
The world is divided into chunks (cubes of 32x32x32 blocks each). This might seem arbitrary, but it's a sweet spot: large enough that we're not managing thousands of tiny regions, but small enough that we can load and unload them quickly as the player moves around.
The engine maintains a 3x3 chunk grid (9 chunks total) centered on the player. As you walk forward, the chunks behind you unload, and new chunks ahead generate and load in. This creates the illusion of an infinite world while keeping memory usage constant. Each chunk tracks its position (posX, posY) and stores a 3D array of block IDs. Each entry is a simple integer: -1 means "air" (no block).
When the player crosses a chunk boundary, the system detects the change in chunk coordinates and triggers a refresh:
This simple calculation tells us which chunk the player is standing in, and if it's different from last frame, we shift the chunk window and regenerate meshes.
Here's a key insight: if a block is completely surrounded by other blocks, you'll never see any of its faces. Drawing them would be wasted work. Face culling solves this by checking each block's six neighbors before adding faces to the mesh.
The checkBlockNeighbours() method in the Chunk class looks in all six cardinal directions (up, down, left, right, front, back). For each direction, if the adjacent position is either outside the chunk bounds or contains air, that face is exposed and needs to be rendered. Otherwise, we skip it entirely.
This single optimization typically reduces vertex count by 50-80% in a typical terrain. Interior blocks contribute nothing to the final mesh. Only surface blocks matter.
The mesh generation loop looks like this conceptually:
for each block in chunk:
if block is not air:
check six neighbors
for each exposed face:
add 4 vertices (quad)
add 6 indices (two triangles)
Each face gets exactly 4 vertices and 6 indices (forming two triangles in a quad). We maintain running lists of vertices and indices, only appending when a face is actually visible. The final mesh is uploaded to the GPU as a single Vertex Buffer Object (VBO) and Element Buffer Object (EBO), letting OpenGL batch-render the entire chunk in one draw call.
Even with face culling, we'd still be sending geometry for chunks behind the camera or way off to the side. Frustum culling fixes this by testing whether blocks are inside the camera's view frustum (the pyramid-shaped volume that represents everything the camera can actually see).
The Player class extracts six frustum planes from the combined projection and view matrices:
Each plane is represented as , where the plane equation is:
To test if a point is inside the frustum, we check its signed distance to each plane:
If the distance is negative for any plane, the point is outside the frustum and gets culled. Only blocks that pass all six plane tests get added to the render list.
In practice, we test block positions before converting them to triangles. The getPlayerChunk() method in Map iterates through all loaded blocks, tests each one with pointInsideFrustum(), and only includes visible blocks in the final triangle list sent to the raycasting system and rendering pipeline.
When you place or destroy a block, the affected chunk's mesh is marked dirty and regenerated on the next frame. The addBlockToChunk() and removeBlockFromChunk() methods update the internal block array, increment or decrement the block count, and trigger a mesh rebuild via createChunkMesh().
This regeneration is expensive (iterating 32,768 positions and checking neighbors), but it only happens when chunks change, not every frame. For typical gameplay with occasional block edits, this works perfectly. Future optimizations could use greedy meshing (merging adjacent faces into larger quads) to further reduce vertex count, but the current system already achieves smooth 60fps even in the browser.
When you move through the world, a cascade of optimizations keeps the frame rate high:
The result is a voxel engine that can handle thousands of blocks without breaking a sweat, all while running in WebAssembly in your browser. What seems like magic is really just careful bookkeeping: knowing what to draw, and more importantly, what not to draw.
Movement in a voxel world seems simple (just don't let the player walk through blocks). But implementing collision detection that feels natural, prevents glitches, and runs efficiently is surprisingly complex. The naive approach fails in ways that quickly become apparent, so MyCraft uses a multi-stage collision system that balances accuracy with performance.
The simplest collision system would be to represent the player as an axis-aligned bounding box (AABB) and check if it overlaps any block every frame. An AABB is just a 3D rectangle defined by minimum and maximum coordinates on each axis:
Testing if two AABBs overlap is straightforward. They collide if they overlap on all three axes:
Each frame, you'd loop through every block and test if the player's box touches it. If so, undo the movement. Simple, right?
This approach has two critical flaws that make it unusable in practice:
Problem 1: Tunneling
At high speeds or low frame rates, the player can move more than one block in a single frame. Static overlap detection only checks the player's position at discrete moments. If you move fast enough, you can teleport through a thin wall. Imagine moving 3 units per frame but checking collision only at the start and end positions. You'd pass right through a 1-unit-thick wall without ever detecting overlap.
Problem 2: Inefficiency
A voxel world with a 3x3 chunk grid (9 chunks x 32^3 blocks) contains thousands of blocks. Testing the player against every single block every frame means thousands of AABB overlap tests. Most of them are completely unnecessary because the blocks are nowhere near the player. Even if each test is cheap, doing it thousands of times per frame kills performance.
These aren't just theoretical concerns. Without solving tunneling, players clip through floors when falling. Without solving efficiency, frame rates tank in dense areas. We need a smarter approach.
MyCraft's collision system solves both problems through a combination of continuous collision detection (swept AABB) and spatial culling (broad/narrow phases). Instead of checking if boxes overlap right now, we calculate when they'll overlap during movement, and we filter out distant blocks before doing expensive calculations.
Before doing any precise collision math, we need to answer a simple question: which blocks could possibly collide with the player this frame?
The broad phase expands the player's bounding box by their velocity over the frame, creating a "swept volume" that encompasses their entire path of movement:
Any block that doesn't intersect this swept volume can be safely ignored. If the player's entire movement path doesn't touch it, there's no way they'll collide with it.
std::vector<glm::vec3> Player::broadSweep(std::vector<glm::vec3> blockCords, float delta)
This function iterates through all loaded blocks (typically 1,000 to 3,000) and tests each one with six simple comparisons (two per axis). Blocks that pass all three axes get added to a candidate list. This typically filters the set down to 5 to 50 blocks, a 95%+ reduction in narrow-phase calculations.
The key insight is that this test is conservative—it might include blocks that won't actually collide, but it never excludes blocks that will. Better to check a few extra blocks than miss a collision.
Now comes the mathematical heart of the system. For each candidate block from the broad phase, we calculate exactly when the player will collide and from which direction.
The swept AABB algorithm treats collision as a continuous problem. We're not asking "do these boxes overlap?" but rather "at what time during this frame do they first touch?"
float Player::sweeptAABB(std::vector<glm::vec3> blockCords, glm::vec3& normalForces, float delta)
For each axis independently, we calculate the inverse entry and exit distances:
This gives us a time (as a fraction of the frame) when the player enters the block's X-range. We repeat for Y and Z:
The player only collides if they enter all three axes simultaneously. The collision time is the maximum entry time across all axes. This handles complex geometry like corners and edges naturally. If , the collision happens after this frame (ignore it). If , we're already inside (resolve immediately).
The axis with the earliest entry time tells us the collision normal. If Y-entry came first, we hit a horizontal surface (floor or ceiling). This normal is crucial for the next phase.
Once we know when and where the player will collide, we move them up to that point and handle the remaining time with sliding:
void Player::detectCollison(float delta, std::vector<glm::vec3> blockCords)
If the collision time is 0.3, we move 30% of the intended distance, then "slide" for the remaining 70% of the frame. Sliding uses vector projection to remove the velocity component perpendicular to the collision surface:
If you hit a wall while walking diagonally, the component parallel to the wall survives. You slide along it instead of stopping dead. This gives the satisfying "smooth slide" feel that makes movement natural.
void Player::detectCollisonHelper(float delta, std::vector<glm::vec3> blockCords,
glm::vec3 normalForces, float remainingtime)
The system recursively resolves collisions with the new direction. If sliding causes you to hit another block (like sliding down a staircase), it detects and resolves that too. This continues until either there are no more collisions or the remaining time drops below a threshold.
Finally, we need to know if the player is standing on solid ground (for jump validation and gravity logic):
void Player::grounded(std::vector<glm::vec3> blockCords)
This checks if the player's bottom bounding box overlaps any block's top surface. It's currently a brute-force O(n) check against all blocks, noted in the code as a candidate for optimization. It could be replaced with a spatial hash or checking only blocks directly beneath the player.
When you press a movement key, the entire collision pipeline executes in milliseconds:
This system ensures movement feels natural. You can run along walls, slide down slopes, and jump precisely without ever clipping through geometry. The math prevents tunneling even at high speeds (swept AABB catches collisions along the entire path), and the broad phase keeps it performant (checking only nearby blocks). It's what makes MyCraft feel like a real game instead of a buggy tech demo.
The raycasting system is the core interaction mechanism for block selection, destruction, and placement. It's what makes clicking on a distant block feel responsive and precise. At its heart is the Ray class, a lightweight utility that encapsulates all ray-triangle intersection logic and provides the mathematical foundation for turning a 2D mouse click into meaningful 3D world interaction.
The Ray class is simple but powerful. It holds just the essential data needed to represent an infinite line through 3D space:
Class Members:
Class Methods:
The Player class is where the Ray class gets put to work. It orchestrates the raycasting pipeline through a series of methods:
Ray GetMouseRay(GLFWwindow* window,
const glm::mat4& viewMatrix,
const glm::mat4& projectionMatrix);
This method takes your mouse position on screen and converts it into an actual 3D ray pointing into the world.
bool castRayForBlock(Ray ray,
const glm::vec3& blockPosition,
const std::vector<Triangle>& triangles);
When you left-click to destroy a block, this method tests the ray against all triangles in a block and returns true on the first hit. We don't need to know which triangle, just that something was hit.
int castRayForBlockPlace(Ray ray,
const glm::vec3& blockPosition,
std::vector<Triangle> triangles);
For placement (right-click), we need more precision. This method sorts triangles by distance, tests them, and returns the index of the closest front-facing triangle hit. This lets us calculate exactly where the new block should go.
When a mouse button is pressed, the Player calls GetMouseRay(), then loops through visible blocks (each made of 12 triangles), calling the appropriate cast function to either destroy or place.
Imagine looking at your screen: the mouse position is just pixels. But we need to know where in 3D space that pixel points to. This requires transforming coordinates through several spaces, each with its own rules.
We start with mouse coordinates in screen space (just regular pixel positions). First, we normalize these to the range (called Normalized Device Coordinates, or NDC), which is the standard space OpenGL uses:
Now we have a point on the near plane of our camera's view frustum: . But we need the direction vector, not just a point. To get that, we transform this into eye space (relative to the camera) by applying the inverse of the projection matrix. The projection matrix compresses 3D into 2D for the screen; inverting it does the opposite:
We set the depth to and the w-component to (this is a direction, not a position, so the camera doesn't affect it):
We're still in camera space though. To get to world space, we apply the inverse view matrix, which transforms relative-to-camera coordinates into world coordinates:
Finally, we normalize this to get a unit-length direction vector:
Combining this with the camera's position as the origin, we get our ray: where represents the distance along the ray.
Once we have a ray, we need to know if it actually hits anything. Blocks in MyCraft are made of triangles (two per face), and we need a fast algorithm to check if the ray passes through any of them.
Enter the Möller-Trumbore algorithm. It's a clever, numerically stable way to compute ray-triangle intersection. Given a ray and triangle vertices (), we first compute the triangle's edge vectors:
The algorithm works by solving for where the ray intersects the infinite plane containing the triangle. We compute a determinant to check if the ray is nearly parallel to the triangle plane:
If is very small (less than some epsilon), the ray is essentially parallel and we bail out early. No intersection.
Assuming there's an intersection with the plane, we now need to check if it's actually within the triangle's bounds. We use barycentric coordinates: a way of expressing any point in the triangle as a weighted combination of the three vertices. If the weights are all between 0 and 1, we're inside:
The first barycentric coordinate, , must be in :
The second coordinate, , must also be in , and together must not exceed 1:
If all checks pass, we compute the actual distance to the intersection:
If (small positive threshold), we have a valid intersection at position along the ray.
Here's a subtle issue: triangles have two sides. When you're inside a block looking outward, you might accidentally "hit" the back side of a face. To prevent this, and to make placement intuitive (you should only place blocks on faces you're looking at), we validate that the ray is hitting a front-facing surface.
Every triangle has a surface normal (a vector perpendicular to the plane). We compute it from the edge vectors:
If the normal and the ray direction point in roughly the same direction (their dot product is positive), we're hitting the front face:
For block placement, once we've confirmed we hit a valid triangle, we calculate where the new block should go by moving one unit away from the hit triangle along the inverted normal:
(Note: We also convert between OpenGL's coordinate system and the game's block-space coordinates here, since they use different axis conventions.)
When you click, a cascade of transformations and checks unfolds in milliseconds. Your mouse position becomes a ray. That ray is tested against dozens of triangles, each check validating intersection distance, barycentric coordinates, and surface orientation. The closest valid hit is identified, and if left-clicking, a DestroyPacket is queued to remove that block and add it to inventory. If right-clicking, an AddPacket places a new block adjacent to the hit. The next frame, the chunk mesh regenerates, and the change appears in your world. All of this happens because of a tiny math pipeline that turns a pixel coordinate into 3D action.
Dear ImGui handles in-game UI rendering including an inventory system with 20 slots, a selected item display, an options menu, and instruction overlays. It integrates directly into the render loop via GLFW and OpenGL, letting UI and game state update seamlessly each frame.
I learned how chunking and face culling reduce draw calls, how to make collisions feel smooth with swept AABB and sliding, and how to integrate UI and interaction tools into a real-time render loop.