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.
- Python decides what should happen (game rules, inventory, crafting, “place block now”).
- C decides how it happens fast (rendering, physics-ish movement, chunk updates, drawing).
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
- You keep frame-critical work where it belongs (C), so the camera doesn’t stutter.
- You keep “game feel” and rules in Python, so you can change your mind quickly.
- The boundary stays small—coarse commands across the line, not “chatty” per-voxel micromanagement.
Why this split works
- Latency/throughput: the hot path stays in C; Python makes coarse decisions.
- Iteration speed: gameplay rules live in Python—edit and rerun instantly.
- Portability: any language with a C FFI (Go, Node, Rust, etc.) can drive the same engine.
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:
- a minimal C ABI (stable across languages)
- a tiny voxel/block world + sprites drawn in C (raylib)
- a shared library you can call from Python/Node/Go
- compile lines for macOS/Linux/Windows
- a small Python client proving the control loop
This is deliberately simple and opinionated; you can layer chunking/meshing later.
1) What We’re Building (shape of the thing)
Process layout (simplest version): in-process shared library
libmini3d
(C) owns the window, camera, input, textures, and rendering (raylib).- You expose a tiny C ABI (pure
extern "C"
in C) so any language can call it. - High-level code (Python/Node/Go) decides what to place (blocks/sprites), calls
engine_set_block()
etc., and then callsengine_tick(dt)
each frame. All the heavy lifting (draw/texture/input) is done inside C.
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:
- loads a tile atlas and slices it into per-tile textures
- creates a dense 3D world array of block ids
- exposes
engine_set_block
and afill_box
helper - renders with
DrawCubeTexture
- does first-person input/physics in C
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
- Development: The C language was developed in the early 1970s by Dennis Ritchie at Bell Labs. It was initially created to implement the Unix operating system.
- First Compiler: The first C compiler was written in assembly language, but it was soon rewritten in C itself, showcasing the language’s capabilities and portability.
Key Features
- Portability: One of the primary goals of the C compiler was to produce code that could run on different hardware architectures. This portability was a significant advancement over previous languages.
- Efficiency: The C compiler was designed to generate efficient machine code, making it suitable for system programming and applications where performance was critical.
- Low-level Access: C provides low-level access to memory and system resources, allowing developers to write system-level software, such as operating systems and embedded systems.
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
brew
: This refers to Homebrew, a popular package manager for macOS. It simplifies the installation of software on these systems.--prefix raylib
: This option tells Homebrew to return the installation prefix (the directory whereraylib
is installed) for theraylib
package. This typically includes the paths to theinclude
andlib
directories for the library.RP=
: This assigns the output of the command to the variableRP
. After this command executes,RP
will contain the path to theraylib
installation.
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.
- Performance: swap the naive per-cube draw for chunk meshing (greedy meshing or face culling). Keep
engine_set_block()
; internally you mark a chunk dirty and rebuild its mesh in C. - Different textures per face: change
engine_define_block_tile()
to accept{top,side,bottom}
and create a tiny cube mesh with UVs referencing your atlas instead ofDrawCubeTexture
. - Events to Python/JS: add
engine_get_player_pose()
(already there) and/or anengine_poll_event()
that writes into a small struct (key presses, collisions). - Out-of-process: keep the same engine core, add a binary message pipe (shared memory or Unix socket). Your C ABI reduces to
engine_push_command(void*,size)
. You can switch from ctypes to IPC without touching render code.
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.
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
)
- Window + render loop (60 FPS).
- Mouse/keyboard sampling, camera yaw/pitch, gravity/jump.
- World storage (dense array right now), set/get blocks.
- Drawing (grid + colored cubes).
- Basic helpers (fill box, load atlas).
Python (engine’s test_client.py
)
- Creates the engine, loads the atlas.
- Defines which block id maps to which tile index.
- Builds the scene: calls
engine_create_world
,engine_fill_box
,engine_set_block
. - Calls
engine_tick(dt)
every frame to let C run.
”Minecraft-like” Features
Keep in C (engine/core, perf-sensitive):
- Chunks & meshing: replace dense array with chunked storage (e.g., 16x16x256); rebuild meshes per dirty chunk (greedy meshing / face culling). Reason: minimizes draw calls; keeps FFI chatter low.
- Collision & stepping: AABB vs. voxel world, stair/step-up, water/ladder flags. Reason: runs every frame; needs stable, deterministic timing.
- Block picking (ray cast): from camera through the voxel grid → returns hit block + face. Reason: tight math, used every click/hold.
- Edits at scale: batch set-blocks within a chunk; mark chunk dirty. Reason: reduces Python↔C call overhead.
- Save/load world (optional in C): streaming chunks to disk (region files). Reason: world data layout lives next to meshing; fewer copies.
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):
- Inventory & hotbar: item stacks, move/swap, serialization.
- Block placement/destruction rules: what happens on left/right click; cooldowns; which item places which block; what drops when broken.
- Crafting: recipe tables, shaped/unshaped, smelting timers.
- Mob/AI scripting (starter): simple ticks in Python, commanding C to spawn/despawn simple billboard/mesh entities.
- Game modes: creative/survival toggles, damage rules, time-of-day, worldgen parameters.