MLX Python guide for 42 school projects β pixel buffer rendering, mlx_loop, input handling, common errors, and diagnostics.
This guide was written alongside A-Maze-ing, a school project by tdarscot and tdhenain at 42 Belgium.
This guide exists because the MLX Python binding has almost no documentation and many students hit the same walls. Everything here is based on my original research and experimentation done during the development of A-Maze-ing.
The goal is not to hand you ready-made solutions - it is to save you the days of searching that should not be necessary just to open a window and draw a pixel. This is the guide that would have been useful as I started developing the visualizer for A-Maze-ing: enough to get unstuck, not enough to skip the discovery. The progressive understanding of how MLX works, and the moment where everything falls into place, is yours to experience.
MLX has more to offer than what is covered here. Bitmap fonts, sprites, elaborate UI and other techniques are left for you to find. This guide serves as a bridge, from completely lost to having a solid foundation you can build your own project on.
| Platform | Status |
|---|---|
| Linux AMD64 | π’ Fully supported - tested on Ubuntu 22.04 AMD64 |
| Mac Silicon | π’ Working via Docker --platform linux/amd64 - tested on Ventura 13.4 (Mac Studio M2) |
| Mac Intel | π‘ Should work - not tested |
| Windows | π‘ WSL2 with WSLg may work - not tested |
Step 1 β Install XQuartz
brew install --cask xquartzOr download from xquartz.org. After installation, log out and back in.
Step 2 β Enable network connections in XQuartz
Open XQuartz β Settings β Security β check "Allow connections from network clients". Log out and back in for the setting to take effect.
Step 3 β Open XQuartz and allow local connections
Open XQuartz first, then in your terminal (Mac, outside Docker):
xhost + localhostRun this every time before starting the container.
Step 4 β Start the container with a shell attached
docker run -it --platform linux/amd64 --name mlxguide -e DISPLAY=host.docker.internal:0 -v /tmp/.X11-unix:/tmp/.X11-unix ubuntu:22.04 bashThis starts the container and drops you directly into a shell inside it. All following steps run inside this shell.
Step 1 β Install Python and pip
β±οΈ Can take up to a minute to complete, be patient!
apt-get update && apt-get install -y python3 python3-pipStep 2 β Install git
apt-get install -y gitStep 3 β Clone the repository
git clone https://github.com/tetrotibo/MLX-Python-Guide-to-the-Galaxy.git mlxguide
cd mlxguideStep 4 β Install X11 and Vulkan dependencies
apt-get install -y libx11-dev libxext-dev libxcb-keysyms1 libvulkan1Step 5 β Install the MLX wheel
pip3 install 00_install/mlx-2.2-py3-none-any.whlStep 6 β Verify
python3 01_modules/M01_init.pyA window should open on your Mac. Press ESC or click the X button to close it. If it opens, everything is working.
munmap_chunk(): invalid pointer / Aborted
MLX crashed before connecting to the display. Check that:
- XQuartz is open on your Mac before starting the container
- You ran
xhost + localhostin a Mac terminal before starting the container - The
DISPLAYvariable is set correctly β runecho $DISPLAYinside the container, it should returnhost.docker.internal:0
libmlx.so: No such file or directory
The container is running as arm64 instead of amd64. Make sure your docker run command includes --platform linux/amd64.
Window doesn't appear but no error
Run echo $DISPLAY inside the container. If it returns empty, the environment variable was not passed in. Stop the container and restart it with the full docker run command above.
pip3: command not found
Run Step 1 again β python3-pip may not have installed correctly.
| Folder | Contents |
|---|---|
00_install/ |
MLX wheel |
01_modules/ |
Module files (M01βM06) |
02_common_errors/ |
Error demonstrations (E01βE07) |
03_diagnostics/ |
Diagnostic scripts (D01βD03) |
04_broken_mlx_functions/ |
Broken function demos (B01) |
05_template/ |
Blank starter template |
| File | Topic |
|---|---|
| M01 - init | MLX init, window creation, clean shutdown |
| M02 - image buffer | Image buffer, write_pixel(), write_rect() |
| M03 - tile grid | Tile coordinates, inset tiles, wall bitmasks |
| M04 - draw order | Draw order, compositor pattern, UI split |
| M05 - text | mlx_string_put(), color conversion, draw order |
| M06 - interactive | Input handling, game loop, deferred pattern |
Screenshots
M02 β Image buffer
Six solid-color rectangles drawn with write_rect() β the foundation of every visual in this guide.

M03 β Tile grid
A full tile grid with inset rendering and wall bitmasks. The two blue tiles mark entry and exit.

M04 β Draw order
The compositor pattern in action: background, path, pattern, and UI panel each drawn in the correct order.

M05 β Text and color constants
All C_ color constants rendered as swatches with their hex values and names, drawn with mlx_string_put().

M06 β Interactive
The full interactive loop: arrow key movement, SPACE to regenerate, and a live UI panel with keybinds.

| File | Error |
|---|---|
| E01 - bounds | write_rect() overflow - silent corruption vs crash |
| E02 - no loop | Missing mlx_loop() - window closes immediately |
| E03 - mask key press | Wrong mask on key hook - keys never fire |
| E04 - x button | Missing close handler - X button has no effect |
| E05 - sync loop | mlx_loop() is single-threaded and synchronous |
| E06 - text overwrite | mlx_string_put() erased by mlx_put_image_to_window() |
| E07 - str color | mlx_string_put() expects 0xBBGGRR not 0xRRGGBB |
| File | Topic |
|---|---|
| D01 - memory leak | mlx_new_image() without mlx_destroy_image() |
| D02 - frame rate | Measuring effective FPS via mlx_loop_hook() |
| D03 - key repeat | X11/XQuartz key autorepeat behavior |
| File | Topic |
|---|---|
| B01 - pixel put 01 | mlx_pixel_put() called before mlx_loop() - black |
| B01 - pixel put 02 | mlx_pixel_put() called from key handler - black |
| B01 - pixel put 03 | mlx_pixel_put() with explicit flush - black |
Tile indices are always converted to pixel positions via:
def tile_px(tile_col): return (tile_col + BORDER) * TILE_SIZE
def tile_py(tile_row): return (tile_row + BORDER) * TILE_SIZEdraw_tile() fills only the interior of a tile, leaving WALL_SIZE
pixels on each edge as a gap. Without this, two adjacent same-color
tiles bleed into a solid block with no visible separation at open
passages.
All modules use the 4-walls-per-cell approach: each cell owns and
draws all 4 of its wall strips, trimmed at both ends by WALL_SIZE
to avoid corner overlap. This leaves a WALL_SIZE x WALL_SIZE
junction gap at every corner - almost invisible at WALL_SIZE = 1,
visible at WALL_SIZE = 2+.
A cleaner alternative (west + north walls only) is documented in M03 but not used in the guide.
MLX has no real layers - just one flat pixel buffer. The last
write_rect() call to touch a pixel wins. The correct draw stack:
1. Background fill + floor tiles
2. Pattern tiles
3. Path tiles
4. Entry / exit tiles - must come after path
5. Walls - conventional, not required with inset tiles
6. UI background
7. UI content
All constants use 0xRRGGBB with a C_ prefix. write_rect()
handles the BGR conversion internally.
| Constant | Role |
|---|---|
| C_BG | Full-window background fill |
| C_FLOOR | Default tile interior |
| C_WALL | Wall strip color |
| C_ENTRY | Entry tile |
| C_EXIT | Exit tile |
| C_PATH | Solution path tile |
| C_PATTERN | Decorative highlight tile |
| C_UI_BG | UI panel background |
| C_UI_ACTIVE | UI panel highlighted element |
| C_UI_INACTIVE | UI panel default element |
Used in M03 onwards to encode which walls are present on a cell:
bit 3 (0b1000) = West
bit 2 (0b0100) = South
bit 1 (0b0010) = East
bit 0 (0b0001) = North
Example: 0b1010 = West + East walls (horizontal corridor).
Extracting a bit: (bitmask >> bit_position) & 1
| Event | X11/XQuartz number | mlx_hook mask |
|---|---|---|
| EVENT_KEY_PRESS | 2 | 1 |
| EVENT_KEY_RELEASE | 3 | 2 |
| EVENT_WINDOW_CLOSE | 33 | 0 |
Key handlers run synchronously inside mlx_loop() - there is no
parallelism. While a handler executes, the loop is paused and no
redraws happen. The deferred pattern lets you render visual feedback
before work runs on the next frame:
# In key_press_handler:
pending_action = True
render_scene() # feedback visible NOW, before work runs
# In game_loop - one frame later:
if pending_action:
pending_action = False
do_work()
render_scene()The freeze still happens - the pattern ensures feedback appears before it. See M06 and E05 for full demonstrations.
amaazouz β for his curiosity about MLX and A-Maze-ing, which pushed me to document what I'd learned.
dloic β for his completely different approach to wall rendering that made me think harder about my own, and for being just as frustrated by the lack of MLX documentation - which is part of why this guide exists.
tdhenain β for his partnership throughout A-Maze-ing.
cgazen and kprist β for discovering the close window fix that saved everyone.
gwfranco β for his terminal-based take on A-Maze-ing.
gdupret β for his last-minute tips that always arrive exactly when they are needed.
mhummels, syalcin and aouassar β for their feedback on A-Maze-ing.
Also thanks to mhummels, gdupret, gwfranco, cgazen, kprist, and dloic for sharing their A-Maze-ing β seeing different approaches to the same project was invaluable.