3D Game Engine: Minecraft Clone Game Engine from Scratch
ChatGPT & Benji Asperheim— Tue Sep 2nd, 2025

3D Game Engine: C Game Engine from Scratch (using a Python controller)

In this article we’ll go over how you can create a C game engine from scratch, with Python-driven logic, that uses a C render/physics core via FFI (Python ctypes), as a kind of Minecraft “clone” prototype that uses Python as a high-level controller or “manager” for the game logic.

We’ll build a C engine (using raylib) that does the fast, repetitive stuff (like opening the window, crunching input every frame, moving the camera, managing the voxel world, and drawing cubes), and it will include a small C API (with “buttons” the outside (Python) world can press).

LEGAL ⚖️: Raylib is licensed under zlib/libpng, which is highly permissive. This means you can use it for commercial games, modify it, and distribute it freely, as long as you don’t misrepresent its origin and you keep the license text with the library’s source.

There are no royalties, no copyleft requirements, and no restrictions on how you license your own game.

Check out the git repo for full examples of this code, and take a look at our other article on the Go implementation of raylib.

Python-Driven Game Logic with a C Renderer

The Python code uses ctypes to press those buttons. That’s FFI—the two programs speaking through a tiny doorway. Python says “put grass here,” “delete that block,” “tick the frame,” and the C side does the heavy lifting at 60 FPS.

Key idea: Python is the director, C is the stunt team.

This is not “a Python wrapper around raylib.” You didn’t glue Python straight onto raylib’s raw functions. You built your own engine in C, and then gave Python a remote control. The Python file is a client/controller, not a renderer. It never pushes individual pixels; it just sends clear commands (“set these blocks”) and lets the C side make it smooth.

Why this feels good in practice

Why this split works

If you want Minecraft-like features later (picking, inventory, crafting), you still follow the same vibe: Python owns the rules, C owns the speed.

Is raylib a “Wrapper” on SDL2?

No. raylib is its own C library (windowing, input, textures, models, audio) built around its graphics layer (rlgl) and platform backends (GLFW/native). There are third-party ports that run raylib on SDL, but upstream raylib does not sit on SDL2.

What is GLFW (in plain English)

GLFW is the stage crew for desktop games. It opens a window, sets up an OpenGL context, and hands you keyboard/mouse events. That’s it. No sprites, no physics, no fancy UI—just the boring-but-crucial plumbing so your renderer can actually show pixels. It’s lightweight, cross-platform (Windows/macOS/Linux), and predictable.

Raylib often uses GLFW under the hood on desktop. Think: raylib is your toolkit for drawing and audio; GLFW is the doorman that gets you into the building and flips the light switches. You rarely talk to GLFW directly when using raylib, but it’s there making sure your window exists and your input arrives on time.

C Engine Game Code

Here’s a practical, end-to-end blueprint for turning it into a tiny C “engine core” that your Python/JS/Go code can drive. You’ll get:

This is deliberately simple and opinionated; you can layer chunking/meshing later.

C 3D Game Engine Screenshot - C Game Engine from Scratch

1) What We’re Building (shape of the thing)

Process layout (simplest version): in-process shared library

If you later want a language-agnostic out-of-process design, you can add a socket or shared-memory command pipe without changing the render core.

2) Public C API (header file example)

Create the engine.h C header file:

// engine.h - tiny C ABI so Python/JS/Go can drive the engine

#pragma once
#ifdef __cplusplus
extern "C" {
#endif

#include <stdint.h>
#include <stdbool.h>

typedef struct Engine Engine;   // opaque

// Create/destroy
Engine* engine_create(int width, int height, const char* title, int target_fps);
void    engine_destroy(Engine* e);

// Camera control (optional; engine also handles WASD/mouse)
void engine_set_camera_pose(Engine* e, float x, float y, float z, float yaw, float pitch);
void engine_get_camera_pose(Engine* e, float* x, float* y, float* z, float* yaw, float* pitch);

// Load a tile atlas and slice it into per-tile textures (for simplicity).
// Example: 512x512 atlas with 64x64 tiles => tile_px=64, cols=8, rows=8
bool engine_load_atlas(Engine* e, const char* png_path, int tile_px, int cols, int rows);

// Define which tile index a block-id uses (same texture on all faces for now).
// You can extend later to (top/side/bottom) if you want.
bool engine_define_block_tile(Engine* e, uint16_t block_id, int tile_index);

// World allocation (simple dense 3D array; start small: 64x64x64)
bool engine_create_world(Engine* e, int sx, int sy, int sz);
void engine_clear_world(Engine* e, uint16_t block_id); // fill entire world with id (0 = empty)

// Set/Read blocks
bool engine_set_block(Engine* e, int x, int y, int z, uint16_t block_id);
uint16_t engine_get_block(Engine* e, int x, int y, int z);

// Main step: processes input, draws a frame, returns false to request quit
bool engine_tick(Engine* e, float dt);

// Convenience: build a flat terrain column (helper)
void engine_fill_box(Engine* e, int x0,int y0,int z0, int x1,int y1,int z1, uint16_t block_id);

#ifdef __cplusplus
}
#endif

3) Engine Implementation (C + raylib)

Create the engine.c file in the same directory (or folder) as the header file:

// engine.c
#include "engine.h"
#include "raylib.h"
#include "raymath.h"
#include <stdlib.h>
#include <string.h>

// color helper for non-textured fallback
static Color tileColorForIndex(int tile) {
    switch (tile % 8) {
        case 0: return (Color){  80, 170,  80, 255 }; // grass green
        case 1: return (Color){ 255, 180,  60, 255 }; // flowers-ish / warm
        case 2: return (Color){ 139, 105,  80, 255 }; // dirt brown
        case 3: return (Color){ 230, 220, 170, 255 }; // sand beige
        case 4: return (Color){ 150, 150, 150, 255 }; // stone gray
        case 5: return (Color){  70, 130, 200, 255 }; // water blue
        case 6: return (Color){ 150, 110,  70, 255 }; // wood brown
        case 7: return (Color){ 245, 250, 255, 255 }; // snow
        default: return (Color){255,  64, 255, 255};   // magenta = debug
    }
}

typedef struct {
    int sx, sy, sz;       // world dims
    uint16_t* v;          // [sx*sy*sz] block ids
} World;

typedef struct {
    Texture2D* tiles;     // per-tile textures (sliced from atlas)
    int tile_count;
    int tile_px, cols, rows;
    Texture2D atlas_tex;  // optional: whole atlas
    Image     atlas_img;  // kept until slicing done
    bool      atlas_loaded;
} Atlas;

typedef struct {
    uint16_t tile_of_block[256]; // block_id -> tile_index (0..tile_count-1), 0xFFFF=undefined
} BlockDefs;

struct Engine {
    // window/render
    int screen_w, screen_h;
    Camera3D cam;
    float yaw, pitch;
    bool  cursor_locked;

    // assets/world
    Atlas atlas;
    BlockDefs defs;
    World world;

    // Inverted (Minecraft) mouse
    bool invert_mouse_x;
    bool invert_mouse_y;

    // movement
    float move_speed, sprint_mult, eye_height;
    float velY, gravity, jump_speed;
};

static inline int idx3D(const World* w, int x,int y,int z) {
    return x + y*w->sx + z*w->sx*w->sy;
}

Engine* engine_create(int width, int height, const char* title, int target_fps) {
    Engine* e = (Engine*)calloc(1, sizeof(Engine));
    e->screen_w = width; e->screen_h = height;

    InitWindow(width, height, title ? title : "mini3d");
    SetTargetFPS(target_fps > 0 ? target_fps : 60);

    e->cam.position   = (Vector3){ 0, 2, 4 };
    e->cam.target     = (Vector3){ 0, 1.6f, 0 };
    e->cam.up         = (Vector3){ 0, 1, 0 };
    e->cam.fovy       = 60.0f;
    e->cam.projection = CAMERA_PERSPECTIVE;

    e->yaw = PI;   // look -Z
    e->pitch = -0.15f;

    e->cursor_locked = true;
    DisableCursor();

    e->move_speed = 6.0f;
    e->sprint_mult = 1.8f;
    e->eye_height = 1.7f;
    e->velY = 0.0f; e->gravity = -18.0f; e->jump_speed = 6.5f;

    for (int i=0;i<256;i++) e->defs.tile_of_block[i] = 0xFFFF;

    return e;
}

void engine_destroy(Engine* e) {
    if (!e) return;
    // free world
    free(e->world.v);

    // unload tile textures
    if (e->atlas.tiles) {
        for (int i=0;i<e->atlas.tile_count;i++) {
            if (e->atlas.tiles[i].id) UnloadTexture(e->atlas.tiles[i]);
        }
        free(e->atlas.tiles);
    }
    if (e->atlas.atlas_tex.id) UnloadTexture(e->atlas.atlas_tex);
    if (e->atlas.atlas_loaded) UnloadImage(e->atlas.atlas_img);

    CloseWindow();
    free(e);
}

bool engine_load_atlas(Engine* e, const char* png_path, int tile_px, int cols, int rows) {
    if (!e) return false;
    e->atlas.atlas_img = LoadImage(png_path);
    if (!e->atlas.atlas_img.data) return false;
    e->atlas.atlas_loaded = true;

    e->atlas.atlas_tex = LoadTextureFromImage(e->atlas.atlas_img);
    if (!e->atlas.atlas_tex.id) return false;

    // Safer: only call SetTextureFilter if the symbol exists in this build
    #ifdef FILTER_BILINEAR
        SetTextureFilter(e->atlas.atlas_tex, FILTER_BILINEAR);
    #endif

    e->atlas.tile_px = tile_px; e->atlas.cols = cols; e->atlas.rows = rows;
    e->atlas.tile_count = cols*rows;
    e->atlas.tiles = (Texture2D*)calloc(e->atlas.tile_count, sizeof(Texture2D));

    // Slice atlas into per-tile textures (simplest path with raylib)
    for (int i=0;i<e->atlas.tile_count;i++) {
        int col = i % cols, row = i / cols;
        Rectangle rec = (Rectangle){ (float)(col*tile_px), (float)(row*tile_px), (float)tile_px, (float)tile_px };
        Image sub = ImageFromImage(e->atlas.atlas_img, rec);
        e->atlas.tiles[i] = LoadTextureFromImage(sub);
        UnloadImage(sub);
    }
    return true;
}

bool engine_define_block_tile(Engine* e, uint16_t block_id, int tile_index) {
    if (!e || tile_index < 0 || tile_index >= e->atlas.tile_count) return false;
    e->defs.tile_of_block[block_id] = (uint16_t)tile_index;
    return true;
}

bool engine_create_world(Engine* e, int sx, int sy, int sz) {
    if (!e || sx<=0 || sy<=0 || sz<=0) return false;
    free(e->world.v);
    e->world.sx = sx; e->world.sy = sy; e->world.sz = sz;
    size_t N = (size_t)sx*sy*sz;
    e->world.v = (uint16_t*)calloc(N, sizeof(uint16_t));
    return e->world.v != NULL;
}

void engine_clear_world(Engine* e, uint16_t id) {
    if (!e || !e->world.v) return;
    size_t N = (size_t)e->world.sx*e->world.sy*e->world.sz;
    for (size_t i=0;i<N;i++) e->world.v[i] = id;
}

bool engine_set_block(Engine* e, int x,int y,int z, uint16_t block_id) {
    if (!e || !e->world.v) return false;
    if (x<0||y<0||z<0||x>=e->world.sx||y>=e->world.sy||z>=e->world.sz) return false;
    e->world.v[idx3D(&e->world,x,y,z)] = block_id;
    return true;
}

uint16_t engine_get_block(Engine* e, int x,int y,int z) {
    if (!e || !e->world.v) return 0;
    if (x<0||y<0||z<0||x>=e->world.sx||y>=e->world.sy||z>=e->world.sz) return 0;
    return e->world.v[idx3D(&e->world,x,y,z)];
}

void engine_fill_box(Engine* e, int x0,int y0,int z0, int x1,int y1,int z1, uint16_t id) {
    if (!e || !e->world.v) return;
    if (x0>x1){int t=x0;x0=x1;x1=t;} if (y0>y1){int t=y0;y0=y1;y1=t;} if (z0>z1){int t=z0;z0=z1;z1=t;}
    x0 = x0<0?0:x0; y0=y0<0?0:y0; z0=z0<0?0:z0;
    x1 = x1>=e->world.sx?e->world.sx-1:x1;
    y1 = y1>=e->world.sy?e->world.sy-1:y1;
    z1 = z1>=e->world.sz?e->world.sz-1:z1;
    for (int z=z0; z<=z1; z++)
    for (int y=y0; y<=y1; y++) {
        int base = y*e->world.sx + z*e->world.sx*e->world.sy;
        for (int x=x0; x<=x1; x++) e->world.v[base + x] = id;
    }
}

void engine_set_camera_pose(Engine* e, float x,float y,float z, float yaw,float pitch) {
    if (!e) return;
    e->cam.position = (Vector3){x,y,z};
    e->yaw = yaw; e->pitch = pitch;
}

void engine_get_camera_pose(Engine* e, float* x,float* y,float* z, float* yaw,float* pitch) {
    if (!e) return;
    if (x) *x = e->cam.position.x;
    if (y) *y = e->cam.position.y;
    if (z) *z = e->cam.position.z;
    if (yaw) *yaw = e->yaw;
    if (pitch) *pitch = e->pitch;
}

static void process_input(Engine* e, float dt) {
    // Toggle cursor lock
    if (IsKeyPressed(KEY_TAB)) {
        e->cursor_locked = !e->cursor_locked;
        if (e->cursor_locked) DisableCursor(); else EnableCursor();
    }

    // Mouse look
    if (e->cursor_locked) {
        Vector2 d = GetMouseDelta();
        const float sens = 0.0025f;

        float mx = e->invert_mouse_x ? -d.x : d.x;
        float my = e->invert_mouse_y ? -d.y : d.y;   // invert Y when true (Minecraft-style)

        e->yaw   += mx * sens;    // horizontal: mouse-right -> look-right
        e->pitch += my * sens;    // vertical: sign controlled by invert_mouse_y

        float limit = PI/2.2f;
        if (e->pitch > limit) e->pitch = limit;
        if (e->pitch < -limit) e->pitch = -limit;
    }

    // Build forward/right
    float cp = cosf(e->pitch), sp = sinf(e->pitch);
    float sy = sinf(e->yaw),   cy = cosf(e->yaw);
    Vector3 forward = (Vector3){ cp*sy, sp, -cp*cy };

    Vector3 fg = (Vector3){ forward.x, 0, forward.z };
    float len = Vector3Length(fg);
    if (len > 1e-4f) fg = Vector3Scale(fg, 1.0f/len);
    Vector3 right = Vector3Normalize(Vector3CrossProduct(forward, e->cam.up));

    float speed = e->move_speed;
    if (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) speed *= e->sprint_mult;

    Vector3 move = (Vector3){0,0,0};
    if (IsKeyDown(KEY_W)) move = Vector3Add(move, fg);
    if (IsKeyDown(KEY_S)) move = Vector3Subtract(move, fg);
    if (IsKeyDown(KEY_A)) move = Vector3Subtract(move, right);
    if (IsKeyDown(KEY_D)) move = Vector3Add(move, right);
    float m = Vector3Length(move);
    if (m > 1e-4f) move = Vector3Scale(move, 1.0f/m);

    e->cam.position = Vector3Add(e->cam.position, Vector3Scale(move, speed*dt));

    // gravity + simple ground (y=0)
    e->velY += e->gravity*dt;
    e->cam.position.y += e->velY*dt;
    float minY = 0.0f + e->eye_height;
    bool onGround = false;
    if (e->cam.position.y <= minY) {
        e->cam.position.y = minY; e->velY = 0; onGround = true;
    }
    if (onGround && IsKeyPressed(KEY_SPACE)) e->velY = e->jump_speed;

    e->cam.target = Vector3Add(e->cam.position, forward);
}

static void draw_world(Engine* e) {
    BeginMode3D(e->cam);
    DrawGrid(32, 1.0f);

    if (!e->world.v || !e->atlas.tiles) {
        EndMode3D();
        return;
    }

    // VERY NAIVE: draw every nonzero block.
    // Start small (e.g. 64^3). Optimize later with chunking/meshing.
    // VERY NAIVE: draw every nonzero block.
    for (int z=0; z<e->world.sz; z++)
    for (int y=0; y<e->world.sy; y++)
    for (int x=0; x<e->world.sx; x++) {
        uint16_t id = e->world.v[idx3D(&e->world,x,y,z)];
        if (!id) continue;
        uint16_t tile = e->defs.tile_of_block[id];
        if (tile == 0xFFFF || tile >= e->atlas.tile_count) continue;
        // If we had per-tile textures and DrawCubeTexture is available you can swap back later.
        // For broad compatibility use colored cubes for now:
        Vector3 pos = (Vector3){ (float)x, (float)y, (float)z };
        Color c = tileColorForIndex((int)tile);
        DrawCube(pos, 1.0f, 1.0f, 1.0f, c);
        DrawCubeWires(pos, 1.0f, 1.0f, 1.0f, Fade(BLACK, 0.2f));
    }

    EndMode3D();
}

bool engine_tick(Engine* e, float dt) {
    if (!e) return false;
    if (WindowShouldClose()) return false;

    process_input(e, dt);

    BeginDrawing();
    ClearBackground(RAYWHITE);
    draw_world(e);
    DrawText("WASD move | SPACE jump | SHIFT sprint | TAB cursor", 10, 10, 14, DARKGRAY);
    DrawFPS(10, 30);
    EndDrawing();

    return true;
}

That’s a complete, minimal engine core:

For scale you’ll eventually want chunking + meshing (not drawing every cube), but keep the API the same and change internals later.


4) Build the Engine Using the C Language Compiler

The original C language compiler, often referred to as the C Compiler, has a rich history and played a crucial role in the development of programming languages and systems. Here’s an overview of its key aspects:

Overview of the Original C Language Compiler

Key Features

The C language compiler not only facilitated the growth of the C language itself but also laid the groundwork for modern programming practices and compiler design. If you have specific aspects of the C compiler you’d like to explore further, feel free to ask!

C Compiler for macOS (using homebrew for macOS)

On macOS, use Homebrew’s brew command to install raylib if you haven’t already:

brew install raylib

You can then use brew --prefix to “point” C to the relevent libraries when compiling:

RP=$(brew --prefix raylib)
cc -dynamiclib -o libmini3d.dylib engine.c \
  -I"$RP/include" -L"$RP/lib" -lraylib \
  -framework Cocoa -framework OpenGL -framework IOKit
cc hello_cube.c failed with fatal error: 'raylib.h' file not found

These are the exact commands that find the problem and the files:

# Where Homebrew installed raylib
brew --prefix raylib

# Does the pkgconfig file exist?
ls "$(brew --prefix raylib)"/lib/pkgconfig/raylib.pc

# Does the header exist?
ls "$(brew --prefix raylib)"/include/raylib.h

# If pkg-config fails, inspect what it returns:
pkg-config --cflags --libs raylib

If pkg-config says “no package found” but the .pc exists, your PKG_CONFIG_PATH is the missing link.

Compile C Code in Linux

cc -fPIC -shared -o libmini3d.so engine.c $(pkg-config --cflags --libs raylib)

NOTE: If pkg-config can’t find raylib, set PKG_CONFIG_PATH, or pass -I/-L manually.

C Language Compiler for Windows

Include the mini3d.dll library when you compile C in Windows:

gcc -shared -o mini3d.dll engine.c \
  $(pkg-config --cflags --libs raylib) \
  -Wl,--out-implib,libmini3d.a

5) Python client (drives the engine)

Create the following test_client.py:

import ctypes as C, time, os, sys

# load the shared lib (adjust name per-OS)
if sys.platform == "darwin": # macOS
    lib = C.CDLL(os.path.abspath("./libmini3d.dylib"))
elif sys.platform.startswith("linux"):
    lib = C.CDLL(os.path.abspath("./libmini3d.so"))
elif sys.platform.startswith("win"):
    lib = C.CDLL(os.path.abspath("./mini3d.dll"))
else:
    raise RuntimeError("unsupported OS")

# declare arg/return types
lib.engine_create.restype = C.c_void_p
lib.engine_create.argtypes = [C.c_int, C.c_int, C.c_char_p, C.c_int]
lib.engine_destroy.argtypes = [C.c_void_p]
lib.engine_load_atlas.argtypes = [C.c_void_p, C.c_char_p, C.c_int, C.c_int, C.c_int]
lib.engine_load_atlas.restype  = C.c_bool
lib.engine_define_block_tile.argtypes = [C.c_void_p, C.c_uint16, C.c_int]
lib.engine_define_block_tile.restype  = C.c_bool
lib.engine_create_world.argtypes = [C.c_void_p, C.c_int, C.c_int, C.c_int]
lib.engine_create_world.restype  = C.c_bool
lib.engine_set_block.argtypes = [C.c_void_p, C.c_int, C.c_int, C.c_int, C.c_uint16]
lib.engine_set_block.restype  = C.c_bool
lib.engine_fill_box.argtypes = [C.c_void_p, C.c_int,C.c_int,C.c_int, C.c_int,C.c_int,C.c_int, C.c_uint16]
lib.engine_tick.argtypes = [C.c_void_p, C.c_float]
lib.engine_tick.restype  = C.c_bool

# create engine
e = lib.engine_create(1280, 720, b"Mini3D - Python drives C", 60)

# load atlas (the one you generated earlier)
ok = lib.engine_load_atlas(e, b"terrain_sheet_simple.png", 64, 8, 8)
if not ok: raise RuntimeError("failed to load atlas")

# define tiles: 1..n map to first few atlas tiles (grass=0, flowers=1, dirt=2, sand=3, stone=4, water=5, wood=6, snow=7)
for bid, tidx in [(1,0),(2,1),(3,2),(4,3),(5,4),(6,5),(7,6),(8,7)]:
    lib.engine_define_block_tile(e, bid, tidx)

# world: 64x32x64
lib.engine_create_world(e, 64, 32, 64)

# build a simple scene: flat ground + a little pillar
lib.engine_fill_box(e, 0,0,0, 63,0,63, 1)  # grass floor
lib.engine_fill_box(e, 10,1,10, 10,5,10, 5) # stone pillar
lib.engine_fill_box(e, 20,1,20, 22,3,22, 3) # dirt lump
lib.engine_fill_box(e, 30,1,15, 30,8,15, 7) # snow post

# run loop: let C do input & rendering; Python just calls tick
prev = time.perf_counter()
while True:
    now = time.perf_counter()
    dt = now - prev
    prev = now
    if not lib.engine_tick(e, C.c_float(dt)):
        break

lib.engine_destroy(e)

NOTE: The Python script with throw an error if you don’t have a terrain_sheet_simple.png sprite sheet in the same location.

Make sure Python can load the shared lib (macOS: same dir is fine; Linux: LD_LIBRARY_PATH if not; macOS alt: DYLD_FALLBACK_LIBRARY_PATH).

You should get the same raylib window, but now Python decides what blocks to place; C renders, handles input/physics, and keeps a 60 FPS loop. NodeJS, TypeScript, or Go could work similarily: just call the same C ABI. For Node, use ffi-napi (quick) or a small N-API addon (faster). For Go, use cgo to call the functions directly.

Use Python Pillow to Create Sprite Sheet

If you don’t already have a sprite sheet for the blocks you can use Python’s Pillow library to create it.

C Game Engine Sprite Sheet Made with Python Pillow

Use the pip command to install Pillow if you haven’t already:

pip install pillow

Now create the terrain_sheet_simple.png image by running the following make_terrain_sheet.py Python script:

from PIL import Image, ImageDraw, ImageFilter
import random
import json
import argparse
import os

TILE = 64
COLS, ROWS = 8, 8
W, H = TILE * COLS, TILE * ROWS

BASE_KINDS = [
    "grass", "flowers", "dirt", "sand",
    "stone", "water", "wood", "snow",
]

def make_grass(d):
    d.rectangle([0,0,TILE,TILE], fill=(95,170,95,255))
    for _ in range(70):
        x = random.randint(0, TILE-1)
        y = random.randint(0, TILE-1)
        d.point((x,y), fill=(70+random.randint(-5,5),135+random.randint(-20,20),70,200))
    # subtle darker bottom
    for y in range(int(TILE*0.7), TILE):
        for x in range(0, TILE, 6):
            if random.random() < 0.08:
                d.point((x,y), fill=(60,120,60,24))

def make_dirt(d):
    d.rectangle([0,0,TILE,TILE], fill=(139,105,80,255))
    for _ in range(40):
        r = random.randint(1,3)
        x,y = random.randint(0,TILE-1), random.randint(0,TILE-1)
        d.ellipse((x-r,y-r,x+r,y+r), fill=(90+random.randint(-10,10),70,55,170))

def make_sand(d):
    d.rectangle([0,0,TILE,TILE], fill=(230,220,170,255))
    for _ in range(50):
        x,y = random.randint(0,TILE-1), random.randint(0,TILE-1)
        d.point((x,y), fill=(210+random.randint(-10,10),190,140,200))

def make_stone(d):
    d.rectangle([0,0,TILE,TILE], fill=(150,150,150,255))
    for _ in range(18):
        r = random.randint(3,6)
        x,y = random.randint(4,TILE-5), random.randint(4,TILE-5)
        d.ellipse((x-r,y-r,x+r,y+r), fill=(120+random.randint(-10,10),120,120,240))

def make_water(d):
    d.rectangle([0,0,TILE,TILE], fill=(70,130,200,255))
    # soft horizontal bands
    for y in range(2, TILE, 8):
        d.line([(0,y),(TILE,y)], fill=(200,220,255,120), width=2)
    # small foam dots
    for _ in range(12):
        x = random.randint(0,TILE-1); y = random.randint(0,TILE-1)
        d.point((x,y), fill=(240,250,255,120))

def make_wood(d):
    d.rectangle([0,0,TILE,TILE], fill=(160,110,70,255))
    for y in range(0, TILE, 10):
        d.line([(0,y),(TILE,y)], fill=(110+random.randint(-10,10),80,50,255), width=2)
    # knots
    for _ in range(6):
        x = random.randint(6,TILE-6); y = random.randint(6,TILE-6)
        r = random.randint(1,3)
        d.ellipse((x-r,y-r,x+r,y+r), fill=(120,90,60,200))

def make_snow(d):
    d.rectangle([0,0,TILE,TILE], fill=(240,245,255,255))
    for _ in range(26):
        r = random.randint(1,2)
        x,y = random.randint(0,TILE-1), random.randint(0,TILE-1)
        d.ellipse((x-r,y-r,x+r,y+r), fill=(255,255,255,220))

def make_flowers(d):
    d.rectangle([0,0,TILE,TILE], fill=(100,170,100,255))
    colors = [(235,100,140),(255,210,120),(180,140,255),(255,180,220)]
    for _ in range(12):
        x,y = random.randint(5,TILE-6), random.randint(5,TILE-6)
        c = random.choice(colors)
        d.ellipse((x-2,y-2,x+2,y+2), fill=c+(255,))

KIND_TO_FUNC = {
    "grass": make_grass,
    "dirt": make_dirt,
    "sand": make_sand,
    "stone": make_stone,
    "water": make_water,
    "wood": make_wood,
    "snow": make_snow,
    "flowers": make_flowers,
}

def make_tile(kind):
    img = Image.new("RGBA", (TILE, TILE), (0,0,0,0))
    d = ImageDraw.Draw(img)
    if kind in KIND_TO_FUNC:
        KIND_TO_FUNC[kind](d)
    else:
        d.rectangle([0,0,TILE,TILE], fill=(180,180,180,255))
    # tiny painterly blur to soften edges (not too much)
    if random.random() < 0.25:
        img = img.filter(ImageFilter.GaussianBlur(radius=0.6))
    return img

def build_types_random(seed=None):
    rng = random.Random(seed)
    # Create a list of 64 tiles by sampling base kinds with small chance of variants.
    kinds = []
    for i in range(COLS*ROWS):
        # bias toward terrain basics but sometimes add flowers/snow etc.
        if rng.random() < 0.12:
            kinds.append(rng.choice(["flowers","snow"]))
        else:
            kinds.append(rng.choice(BASE_KINDS))
    # shuffle a bit to avoid vertical stacks
    rng.shuffle(kinds)
    return kinds

def make_sheet(out_png, out_json, seed=None):
    tile_types = build_types_random(seed)
    sheet = Image.new("RGBA", (W, H))
    index = {}
    for idx, kind in enumerate(tile_types):
        col = idx % COLS
        row = idx // COLS
        tile = make_tile(kind)
        sheet.paste(tile, (col*TILE, row*TILE), tile)
        index.setdefault(kind, []).append([col, row])
    sheet.save(out_png)
    with open(out_json, "w", encoding="utf-8") as f:
        json.dump({"tile_size":TILE, "cols":COLS, "rows":ROWS, "mapping":index, "seed":seed}, f, indent=2)
    print(f"Saved {out_png} and {out_json} (seed={seed})")

def main():
    p = argparse.ArgumentParser()
    p.add_argument("--seed", type=int, default=None, help="Random seed (optional)")
    p.add_argument("--out", default="terrain_sheet_simple.png", help="Output PNG filename")
    p.add_argument("--json", default="terrain_sheet_simple.json", help="Output JSON index filename")
    args = p.parse_args()
    make_sheet(args.out, args.json, args.seed)

if __name__ == "__main__":
    main()

If the C program compiled correctly, and you have the sprite sheet, just run the Python script to start the game:

python3 test_client.py

Conclusion

C owns the engine core. It opens the window, runs the frame loop, reads input, moves the camera/physics, stores the voxel world, and draws everything. However, Python is the game brain. It decides what blocks/entities/items should exist and when; it calls small C functions to apply those changes.

What Each Part Does (now)

C (engine’s libmini3d)

Python (engine’s test_client.py)

”Minecraft-like” Features

Keep in C (engine/core, perf-sensitive):

C exposes events (mouse clicks, scroll, selected slot index); Python updates inventory state and tells C what to render in a tiny HUD API. Python reads pick events from C (block id + face) and calls back engine_set_block / engine_fill_box etc.

Put in Python (gameplay rules & UI/state):