[Solved] How to use the SDL3 callback app structure in Python?

Hi. How to use the SDL3 callback app structure in Python? Is it possible?

I can write in C:

#define SDL_MAIN_USE_CALLBACKS 1  /* use the callbacks instead of main() */

But how to activate it in Python?

pip install PySDL3

main.py

import sdl3

print(dir(sdl3))

I’ve found SDL_EnterAppMainCallbacks in the output above but I don’t know how to use it. I want to rewrite the official template to Python: SDL/examples/template.c at main · libsdl-org/SDL · GitHub

1 Like

The emccue’s answer on Discord:

Well, you have two options

  1. Reimplement what the callbacks do in python - python already owns the main function so there isn’t much you can do. time to write while True:
  2. Start your program in C and initialize python through its C api

Topic on GitHub

2 Likes

Using Function prototypes, it’s possible to use SDL_EnterAppMainCallbacks in pure Python code.

Example code:

#!/usr/bin/env python3
import ctypes
import dataclasses
import sys
import time

import sdl3


@dataclasses.dataclass
class AppState:
    window: ctypes.POINTER(sdl3.SDL_Window)
    renderer: ctypes.POINTER(sdl3.SDL_Renderer)


def SDL_AppEvent(c_appstate, c_event: ctypes.POINTER(sdl3.SDL_Event)) -> sdl3.SDL_AppResult:  # event: ctypes.POINTER(sdl3.SDL_Event)
    event: sdl3.SDL_Event = c_event.contents
    # appstate: AppState = ctypes.cast(c_appstate, ctypes.POINTER(ctypes.py_object)).contents.value
    if event.type == sdl3.SDL_EVENT_QUIT:
        return sdl3.SDL_APP_SUCCESS
    return sdl3.SDL_APP_CONTINUE


def SDL_AppIterate(c_appstate) -> sdl3.SDL_AppResult:
    appstate: AppState = ctypes.cast(c_appstate, ctypes.POINTER(ctypes.py_object)).contents.value
    if not sdl3.SDL_SetRenderDrawColor(appstate.renderer, int(time.time() * 255 / 2) % 256, 0, 0, sdl3.SDL_ALPHA_OPAQUE):
        print("SDL_SetRenderDrawColorFloat failed")
        return sdl3.SDL_APP_FAILURE
    if not sdl3.SDL_RenderClear(appstate.renderer):
        print("SDL_RenderClear failed")
        return sdl3.SDL_APP_FAILURE
    if not sdl3.SDL_RenderPresent(appstate.renderer):
        print("SDL_RenderPresent failed")
        return sdl3.SDL_APP_FAILURE
    return sdl3.SDL_APP_CONTINUE


def SDL_AppInit(c_appstate, argc, argv) -> sdl3.SDL_AppResult:  # argc: int, argv: ctypes.POINTER[ctypes.c_char_p]
    p_window = ctypes.POINTER(sdl3.SDL_Window)()
    p_renderer = ctypes.POINTER(sdl3.SDL_Renderer)()
    if not sdl3.SDL_CreateWindowAndRenderer(b"Hello world!", 640, 480, 0, ctypes.pointer(p_window), ctypes.pointer(p_renderer)):
        print("sdl3.SDL_CreateWindowAndRenderer failed")
        return sdl3.SDL_APP_FAILURE
    state = AppState(window=p_window.contents, renderer=p_renderer.contents)
    c_appstate[0] = ctypes.cast(ctypes.pointer(ctypes.py_object(state)), ctypes.c_void_p)
    return sdl3.SDL_APP_CONTINUE


def SDL_AppQuit(c_appstate, result) -> None:  # ctypes.POINTER[sdl3.SDL_AppResult]
    appstate: AppState = ctypes.cast(c_appstate, ctypes.POINTER(ctypes.py_object)).contents.value
    sdl3.SDL_DestroyRenderer(appstate.renderer)
    sdl3.SDL_DestroyWindow(appstate.window)


def main():
    c_SDL_AppInit_cfunctype = ctypes.CFUNCTYPE(sdl3.SDL_AppResult, ctypes.POINTER(ctypes.c_void_p), ctypes.c_int, ctypes.POINTER(ctypes.c_char_p))
    c_SDL_AppIterate_cfunctype = ctypes.CFUNCTYPE(sdl3.SDL_AppResult, ctypes.c_void_p)
    c_SDL_AppEvent_cfunctype = ctypes.CFUNCTYPE(sdl3.SDL_AppResult, ctypes.c_void_p, ctypes.POINTER(sdl3.SDL_Event))
    c_SDL_AppQuit_cfunctype = ctypes.CFUNCTYPE(None, ctypes.c_void_p, sdl3.SDL_AppResult)

    c_arg_array_t = ctypes.c_char_p * (len(sys.argv) + 0)
    c_args = c_arg_array_t(*[ctypes.c_char_p(a.encode()) for a in sys.argv])

    result = sdl3.SDL_EnterAppMainCallbacks(ctypes.c_int(len(sys.argv)), c_args,
                                            c_SDL_AppInit_cfunctype(SDL_AppInit),
                                            c_SDL_AppIterate_cfunctype(SDL_AppIterate),
                                            c_SDL_AppEvent_cfunctype(SDL_AppEvent),
                                            c_SDL_AppQuit_cfunctype(SDL_AppQuit))
    return result


if __name__ == "__main__":
    raise SystemExit(main())
2 Likes

There was a bug related to the appstate in my previous example.
The AppState/ctypes.py_object object was created and released in SDL_AppInit.
When running in debug mode, the example failed when accessing the appstate argument in the SDL_AppEvent and SDL_AppIterate callback functions.

#!/usr/bin/env python3
import ctypes
import dataclasses
import sys
import time
import typing

import sdl3


@dataclasses.dataclass
class AppState:
    window: ctypes.POINTER(sdl3.SDL_Window)
    renderer: ctypes.POINTER(sdl3.SDL_Renderer)


state: typing.Optional[AppState] = None


def SDL_AppEvent(c_appstate, c_event: ctypes.POINTER(sdl3.SDL_Event)) -> sdl3.SDL_AppResult:
    event: sdl3.SDL_Event = c_event.contents
    if event.type == sdl3.SDL_EVENT_QUIT:
        return sdl3.SDL_APP_SUCCESS
    return sdl3.SDL_APP_CONTINUE


def SDL_AppIterate(c_appstate) -> sdl3.SDL_AppResult:
    if not sdl3.SDL_SetRenderDrawColor(state.renderer, int(time.time() * 255 / 2) % 256, 0, 0, sdl3.SDL_ALPHA_OPAQUE):
        print("SDL_SetRenderDrawColorFloat failed")
        return sdl3.SDL_APP_FAILURE
    if not sdl3.SDL_RenderClear(state.renderer):
        print("SDL_RenderClear failed")
        return sdl3.SDL_APP_FAILURE
    if not sdl3.SDL_RenderPresent(state.renderer):
        print("SDL_RenderPresent failed")
        return sdl3.SDL_APP_FAILURE
    return sdl3.SDL_APP_CONTINUE


def SDL_AppInit(c_appstate, argc, argv) -> sdl3.SDL_AppResult:
    p_window = ctypes.POINTER(sdl3.SDL_Window)()
    p_renderer = ctypes.POINTER(sdl3.SDL_Renderer)()
    if not sdl3.SDL_CreateWindowAndRenderer(b"Hello world!", 640, 480, 0, ctypes.pointer(p_window), ctypes.pointer(p_renderer)):
        print("sdl3.SDL_CreateWindowAndRenderer failed")
        return sdl3.SDL_APP_FAILURE
    global state
    state = AppState(window=p_window.contents, renderer=p_renderer.contents)
    c_appstate[0] = ctypes.cast(ctypes.pointer(ctypes.py_object(state)), ctypes.c_void_p)
    return sdl3.SDL_APP_CONTINUE


def SDL_AppQuit(c_appstate, result) -> None:  # ctypes.POINTER[sdl3.SDL_AppResult]
    appstate: AppState = ctypes.cast(c_appstate, ctypes.POINTER(ctypes.py_object)).contents.value
    sdl3.SDL_DestroyRenderer(appstate.renderer)
    sdl3.SDL_DestroyWindow(appstate.window)


def main():
    c_SDL_AppInit_cfunctype = ctypes.CFUNCTYPE(sdl3.SDL_AppResult, ctypes.POINTER(ctypes.c_void_p), ctypes.c_int, ctypes.POINTER(ctypes.c_char_p))
    c_SDL_AppIterate_cfunctype = ctypes.CFUNCTYPE(sdl3.SDL_AppResult, ctypes.c_void_p)
    c_SDL_AppEvent_cfunctype = ctypes.CFUNCTYPE(sdl3.SDL_AppResult, ctypes.c_void_p, ctypes.POINTER(sdl3.SDL_Event))
    c_SDL_AppQuit_cfunctype = ctypes.CFUNCTYPE(None, ctypes.c_void_p, sdl3.SDL_AppResult)

    c_arg_array_t = ctypes.c_char_p * (len(sys.argv) + 0)
    c_args = c_arg_array_t(*[ctypes.c_char_p(a.encode()) for a in sys.argv])

    result = sdl3.SDL_EnterAppMainCallbacks(ctypes.c_int(len(sys.argv)), c_args,
                                            c_SDL_AppInit_cfunctype(SDL_AppInit),
                                            c_SDL_AppIterate_cfunctype(SDL_AppIterate),
                                            c_SDL_AppEvent_cfunctype(SDL_AppEvent),
                                            c_SDL_AppQuit_cfunctype(SDL_AppQuit))
    return result


if __name__ == "__main__":
    raise SystemExit(main())
2 Likes

Thanks a lot!

Aermoss answered here:

Thank you for pointing out! I implemented ‘SDL_main_impl.h’ in version 0.8.1b0, you can now use callbacks like this:

import ctypes, os

os.environ["SDL_MAIN_USE_CALLBACKS"] = "1"

import sdl3

@sdl3.SDL_AppInit_func
def SDL_AppInit(appstate: sdl3.LP_c_void_p, argc: ctypes.c_int, argv: sdl3.LP_c_char_p) -> sdl3.SDL_AppResult: ...

@sdl3.SDL_AppIterate_func
def SDL_AppIterate(appstate: ctypes.c_void_p) -> sdl3.SDL_AppResult: ...

@sdl3.SDL_AppEvent_func
def SDL_AppEvent(appstate: ctypes.c_void_p, event: sdl3.LP_SDL_Event) -> sdl3.SDL_AppResult: ...

@sdl3.SDL_AppQuit_func
def SDL_AppQuit(appstate: ctypes.c_void_p, result: sdl3.SDL_AppResult) -> None: ...
1 Like

Aermoss published a slightly modified version of madebr’s example here:

import os, ctypes, time

os.environ["SDL_MAIN_USE_CALLBACKS"] = "1"

import sdl3

class AppState(ctypes.Structure):
    _fields_ = [
        ("window", ctypes.POINTER(sdl3.SDL_Window)),
        ("renderer", ctypes.POINTER(sdl3.SDL_Renderer))
    ]

@sdl3.SDL_AppInit_func
def SDL_AppInit(appstate: sdl3.LP_c_void_p, argc: ctypes.c_int, argv: sdl3.LP_c_char_p) -> sdl3.SDL_AppResult:
    state = AppState()

    if not sdl3.SDL_Init(sdl3.SDL_INIT_VIDEO):
        sdl3.SDL_Log("Couldn't initialize SDL: %s", sdl3.SDL_GetError())
        return sdl3.SDL_APP_FAILURE

    if not sdl3.SDL_CreateWindowAndRenderer("Hello, World!".encode(), 640, 480, 0, ctypes.byref(state.window), ctypes.byref(state.renderer)):
        sdl3.SDL_Log("Couldn't create window/renderer: %s", sdl3.SDL_GetError())
        return sdl3.SDL_APP_FAILURE

    appstate[0] = ctypes.cast(ctypes.pointer(state), ctypes.c_void_p)
    return sdl3.SDL_APP_CONTINUE

@sdl3.SDL_AppEvent_func
def SDL_AppEvent(appstate: ctypes.c_void_p, event: sdl3.LP_SDL_Event) -> sdl3.SDL_AppResult:
    if sdl3.SDL_DEREFERENCE(event).type == sdl3.SDL_EVENT_QUIT:
        return sdl3.SDL_APP_SUCCESS
    
    return sdl3.SDL_APP_CONTINUE

@sdl3.SDL_AppIterate_func
def SDL_AppIterate(appstate: ctypes.c_void_p) -> sdl3.SDL_AppResult:
    state: AppState = sdl3.SDL_DEREFERENCE(ctypes.cast(appstate, ctypes.POINTER(AppState)))
    sdl3.SDL_SetRenderDrawColor(state.renderer, int(time.time() * 255 / 2) % 256, 0, 0, sdl3.SDL_ALPHA_OPAQUE)
    sdl3.SDL_RenderClear(state.renderer)
    sdl3.SDL_RenderPresent(state.renderer)
    return sdl3.SDL_APP_CONTINUE

@sdl3.SDL_AppQuit_func
def SDL_AppQuit(appstate: ctypes.c_void_p, result: sdl3.SDL_AppResult) -> None:
    ... # SDL will clean up the window/renderer for us.
1 Like

Simplified version of the code above:

py install PySDL3

main.py

import os, ctypes, time

os.environ["SDL_MAIN_USE_CALLBACKS"] = "1"

import sdl3

renderer = ctypes.POINTER(sdl3.SDL_Renderer)()
window = ctypes.POINTER(sdl3.SDL_Window)()

@sdl3.SDL_AppInit_func
def SDL_AppInit(appstate, argc, argv):
    if not sdl3.SDL_Init(sdl3.SDL_INIT_VIDEO):
        sdl3.SDL_Log("Couldn't initialize SDL: %s", sdl3.SDL_GetError())
        return sdl3.SDL_APP_FAILURE

    if not sdl3.SDL_CreateWindowAndRenderer("Hello, World!".encode(), 640, 480, 0, window, renderer):
        sdl3.SDL_Log("Couldn't create window/renderer: %s", sdl3.SDL_GetError())
        return sdl3.SDL_APP_FAILURE

    return sdl3.SDL_APP_CONTINUE

@sdl3.SDL_AppEvent_func
def SDL_AppEvent(appstate, event):
    if sdl3.SDL_DEREFERENCE(event).type == sdl3.SDL_EVENT_QUIT:
        return sdl3.SDL_APP_SUCCESS
    
    return sdl3.SDL_APP_CONTINUE

@sdl3.SDL_AppIterate_func
def SDL_AppIterate(appstate):
    sdl3.SDL_SetRenderDrawColor(renderer, int(time.time() * 255 / 2) % 256, 0, 0, sdl3.SDL_ALPHA_OPAQUE)
    sdl3.SDL_RenderClear(renderer)
    sdl3.SDL_RenderPresent(renderer)
    return sdl3.SDL_APP_CONTINUE

@sdl3.SDL_AppQuit_func
def SDL_AppQuit(appstate, result):
    ... # SDL will clean up the window/renderer for us.

Topic with example links in PySDL3: https://github.com/Aermoss/PySDL3/issues/21

My topics with examples for beginners:

Sprite animation (native PNG from SDL 3.3)

It plays a sprite animation from the free asset pack: Tiny Swords by Pixel Frog

This example uses native SDL 3.3 (not SDL3_image):

    texturePath = "./assets/sprites/warrior/idle.png".encode()
    surface = sdl3.SDL_LoadPNG(texturePath)
    if not surface:
        sdl.SDL_Log("PNG load failed: %s: %s".encode() % (texturePath, sdl3.SDL_GetError()))
        return sdl3.SDL_APP_FAILURE

    spriteTexture = sdl3.SDL_CreateTextureFromSurface(renderer, surface)
    sdl3.SDL_DestroySurface(surface)

Source code: sprite-animation-native-png-pysdl3-python.zip (49.5 KB)

pip install PySDL3
py main.py

Console output:

Compiled SDL version: 3.3.2
Linked SDL version: 3.3.2

sprite-animation-pysdl3

C version of the example above:

The following example is the same as above but it uses SDL3_image:

    texturePath = "./assets/sprites/warrior/idle.png".encode()
    spriteTexture = sdl3.IMG_LoadTexture(renderer, texturePath)
    if not spriteTexture:
        sdl3.SDL_Log("Error: %s".encode() % sdl3.SDL_GetError())
        return sdl3.SDL_APP_FAILURE

Source code: sprite-animation-sdl-image-png-pysdl3-python.zip (49.6 KB)

pip install PySDL3
py main.py

Console output:

Compiled SDL version: 3.3.2
Linked SDL version: 3.3.2
SDL3_image version: 3.2.4

sprite-animation-pysdl3

C version of the example above:

Another a very big change was made in SDL 3.3 and SDL3_mixer 3.1. But it works with SDL 3.2.28 and SDL_mixer 3.0.0. This code does not work anymore with PySDL 0.9.9b1: play-sound-by-click-pysdl3-python.zip (352.0 KB)

# pip install PySDL3
# py main.py

import ctypes
import os

os.environ["SDL_MAIN_USE_CALLBACKS"] = "1"
os.environ["SDL_RENDER_DRIVER"] = "opengl"

import sdl3

renderer = ctypes.POINTER(sdl3.SDL_Renderer)()
window = ctypes.POINTER(sdl3.SDL_Window)()
wave = ctypes.POINTER(sdl3.Mix_Chunk)()

@sdl3.SDL_AppInit_func
def SDL_AppInit(appstate, argc, argv):
    global wave

    if not sdl3.SDL_Init(sdl3.SDL_INIT_VIDEO | sdl3.SDL_INIT_AUDIO):
        sdl3.SDL_Log("Couldn't initialize SDL: %s".encode() % sdl3.SDL_GetError())
        return sdl3.SDL_APP_FAILURE

    if not sdl3.SDL_CreateWindowAndRenderer("Play a sound by click using PySDL3".encode(), 380, 380, 0, window, renderer):
        sdl3.SDL_Log("Couldn't create window/renderer: %s".encode() % sdl3.SDL_GetError())
        return sdl3.SDL_APP_FAILURE

    sdl3.SDL_SetRenderVSync(renderer, 1) # Turn on vertical sync

    spec = sdl3.SDL_AudioSpec()
    spec.freq = sdl3.MIX_DEFAULT_FREQUENCY
    spec.format = sdl3.MIX_DEFAULT_FORMAT
    spec.channels = sdl3.MIX_DEFAULT_CHANNELS

    # Open the audio device
    if not sdl3.Mix_OpenAudio(0, spec):
        sdl3.SDL_Log("Couldn't open an audio device: %s".encode() % SDL_GetError())
        return sdl3.SDL_APP_FAILURE

    # Load the WAV file
    soundFilePath = "./assets/audio/picked-coin-echo-2.wav".encode();
    wave = sdl3.Mix_LoadWAV(soundFilePath);
    if not wave:
        sdl3.SDL_Log("Couldn't open the file: %s".encode() % soundFilePath)
        sdl3.SDL_Log("Error: %s".encode() % sdl3.SDL_GetError())
        return sdl3.SDL_APP_FAILURE;

    return sdl3.SDL_APP_CONTINUE

@sdl3.SDL_AppEvent_func
def SDL_AppEvent(appstate, event):
    if sdl3.SDL_DEREFERENCE(event).type == sdl3.SDL_EVENT_QUIT:
        return sdl3.SDL_APP_SUCCESS
    elif sdl3.SDL_DEREFERENCE(event).type == sdl3.SDL_EVENT_MOUSE_BUTTON_DOWN:
        # Play the sound effect
        sdl3.Mix_PlayChannel(-1, wave, 0);

    return sdl3.SDL_APP_CONTINUE

@sdl3.SDL_AppIterate_func
def SDL_AppIterate(appstate):
    sdl3.SDL_SetRenderDrawColor(renderer, 210, 220, 210, sdl3.SDL_ALPHA_OPAQUE)
    sdl3.SDL_RenderClear(renderer)
    sdl3.SDL_RenderPresent(renderer)
    return sdl3.SDL_APP_CONTINUE

@sdl3.SDL_AppQuit_func
def SDL_AppQuit(appstate, result):
    global wave
    # SDL will clean up the window/renderer for us
    if wave:
        sdl3.Mix_FreeChunk(wave)
        wave = None

If you try to run it with PySDL 0.9.9b1 you will see this error:

    wave = ctypes.POINTER(sdl3.Mix_Chunk)()
                          ^^^^^^^^^^^^^^
AttributeError: module 'sdl3' has no attribute 'Mix_Chunk'

But this example works with SDL 3.2.28 and SDL3_mixer 3.0.0 in C: GitHub repository. You can test it in the browser (WASM) - click on the canvas to hear a sound. Console output for C app:

Compiled SDL3 version: 3.2.28
Linked SDL3 version:   3.3.4
SDL3_mixer version:    3.0.0

So, I have fixed the Play a sound by click example in Python: play-sound-pysdl-3.3.2-python.zip (352.4 KB)

# pip install PySDL3
# py main.py

import ctypes
import os

os.environ["SDL_MAIN_USE_CALLBACKS"] = "1"
os.environ["SDL_RENDER_DRIVER"] = "opengl"

import sdl3

renderer = ctypes.POINTER(sdl3.SDL_Renderer)()
window = ctypes.POINTER(sdl3.SDL_Window)()

mixer = ctypes.POINTER(sdl3.MIX_Mixer)()
audio = ctypes.POINTER(sdl3.MIX_Audio)()

@sdl3.SDL_AppInit_func
def SDL_AppInit(appstate, argc, argv):
    global mixer, audio

    sdl3.SDL_SetHint(b"SDL_MIXER_DISABLE_DRFLAC", b"1")
    sdl3.SDL_SetHint(b"SDL_MIXER_DISABLE_DRMP3", b"1")

    if not sdl3.SDL_Init(sdl3.SDL_INIT_VIDEO): # | sdl3.SDL_INIT_AUDIO
        sdl3.SDL_Log("Couldn't initialize SDL: %s".encode() % sdl3.SDL_GetError())
        return sdl3.SDL_APP_FAILURE

    title = "Play a sound by click using PySDL3".encode()
    if not sdl3.SDL_CreateWindowAndRenderer(title, 380, 380, 0, window, renderer):
        sdl3.SDL_Log("Couldn't create window/renderer: %s".encode() % sdl3.SDL_GetError())
        return sdl3.SDL_APP_FAILURE

    sdl3.SDL_SetRenderVSync(renderer, 1) # Turn on vertical sync

    # Get the compiled SDL version
    compiled = sdl3.SDL_VERSION
    compiled_major = sdl3.SDL_VERSIONNUM_MAJOR(compiled)
    compiled_minor = sdl3.SDL_VERSIONNUM_MINOR(compiled)
    compiled_micro = sdl3.SDL_VERSIONNUM_MICRO(compiled)
    print(f"Compiled SDL version: {compiled_major}.{compiled_minor}.{compiled_micro}")

    # Get the linked SDL version
    linked = sdl3.SDL_GetVersion()
    linked_major = sdl3.SDL_VERSIONNUM_MAJOR(linked)
    linked_minor = sdl3.SDL_VERSIONNUM_MINOR(linked)
    linked_micro = sdl3.SDL_VERSIONNUM_MICRO(linked)
    print(f"Linked SDL version:   {linked_major}.{linked_minor}.{linked_micro}")

    v = sdl3.MIX_Version()
    major = sdl3.SDL_VERSIONNUM_MAJOR(v)
    minor = sdl3.SDL_VERSIONNUM_MINOR(v)
    micro = sdl3.SDL_VERSIONNUM_MICRO(v)
    msg = f"SDL3_mixer version:   {major}.{minor}.{micro}".encode("utf-8")
    sdl3.SDL_Log(msg)

    if not sdl3.MIX_Init():
        sdl3.SDL_Log("MIX_Init failed: %s".encode() % sdl3.SDL_GetError())
        return sdl3.SDL_APP_FAILURE

    mixer = sdl3.MIX_CreateMixerDevice(sdl3.SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, None)
    if not mixer:
        err = sdl3.SDL_GetError().decode("utf-8")
        sdl3.SDL_Log(f"Couldn't create mixer: {err}".encode())
        return sdl3.SDL_APP_FAILURE

    mixerspec = sdl3.SDL_AudioSpec()
    sdl3.MIX_GetMixerFormat(mixer, ctypes.byref(mixerspec))
    fmt_name = sdl3.SDL_GetAudioFormatName(mixerspec.format).decode('utf-8')
    msg = f"Mixer is format {fmt_name}, {mixerspec.channels} channels, {mixerspec.freq} frequency".encode()
    sdl3.SDL_Log(msg)

    # --- Print available audio decoders ---
    sdl3.SDL_Log(b"Available MIXER decoders:")

    num_decoders = sdl3.MIX_GetNumAudioDecoders()
    if num_decoders < 0:
        err = sdl3.SDL_GetError().decode("utf-8")
        msg = f" - [error ({err})]".encode("utf-8")
        sdl3.SDL_Log(msg)
    elif num_decoders == 0:
        sdl3.SDL_Log(b" - [none]")
    else:
        for i in range(num_decoders):
            decoder_name = sdl3.MIX_GetAudioDecoder(i).decode("utf-8")
            msg = f" - {decoder_name}".encode("utf-8")
            sdl3.SDL_Log(msg)

    # --- Load audio file ---
    audiofname = b"./assets/audio/picked-coin-echo-2.wav"  # bytes for SDL
    audio = sdl3.MIX_LoadAudio(mixer, audiofname, False)
    if not audio:
        err = sdl3.SDL_GetError().decode("utf-8")
        msg = f"Failed to load '{audiofname.decode()}' ({err})".encode("utf-8")
        sdl3.SDL_Log(msg)
    else:
        audiospec = sdl3.SDL_AudioSpec()
        sdl3.MIX_GetAudioFormat(audio, ctypes.byref(audiospec))

        fmt_name = sdl3.SDL_GetAudioFormatName(audiospec.format).decode("utf-8")
        channels_text = "" if audiospec.channels == 1 else "s"
        msg = f"{audiofname.decode()}: {fmt_name}, {audiospec.channels} channel{channels_text}, {audiospec.freq} freq".encode("utf-8")
        sdl3.SDL_Log(msg)

    return sdl3.SDL_APP_CONTINUE

@sdl3.SDL_AppEvent_func
def SDL_AppEvent(appstate, event):
    if sdl3.SDL_DEREFERENCE(event).type == sdl3.SDL_EVENT_QUIT:
        return sdl3.SDL_APP_SUCCESS
    elif sdl3.SDL_DEREFERENCE(event).type == sdl3.SDL_EVENT_MOUSE_BUTTON_DOWN:
        # Play the sound effect
        if not sdl3.MIX_PlayAudio(mixer, audio):
            sdl3.SDL_Log("Failed to play audio: %s".encode() % sdl3.SDL_GetError())

    return sdl3.SDL_APP_CONTINUE

@sdl3.SDL_AppIterate_func
def SDL_AppIterate(appstate):
    sdl3.SDL_SetRenderDrawColor(renderer, 210, 220, 210, sdl3.SDL_ALPHA_OPAQUE)
    sdl3.SDL_RenderClear(renderer)
    sdl3.SDL_RenderPresent(renderer)
    return sdl3.SDL_APP_CONTINUE

@sdl3.SDL_AppQuit_func
def SDL_AppQuit(appstate, result):
    global audio, mixer, renderer, window

    if audio:
        sdl3.MIX_DestroyAudio(audio)
        audio = None
    if mixer:
        sdl3.MIX_DestroyMixer(mixer)
        mixer = None

    if renderer:
        sdl3.SDL_DestroyRenderer(renderer)
        renderer = None
    if window:
        sdl3.SDL_DestroyWindow(window)
        window = None

    sdl3.MIX_Quit()
    sdl3.SDL_Quit()

Console output:

Compiled SDL version: 3.3.2
Linked SDL version:   3.3.2
SDL3_mixer version:   3.1.0

C version with SDL 3.3.4:

Console output:

Compiled SDL version: 3.3.4
Linked SDL3 version:  3.3.4
SDL3_mixer version:   3.1.0

A few key points:

  • Python is beginner-friendly, making it an excellent choice for kids, students, and anyone new to programming.
  • PySDL3 is easy to install using pip install PySDL3; all required native libraries (such as SDL3, SDL3_ttf, SDL3_mixer, and others) are installed automatically.
  • Migrating from Pygame to PySDL3 is straightforward, as the overall programming model and concepts are very similar.
  • Python code can be translated to C relatively easily (for example, with the help of ChatGPT), enabling builds for native executables (EXE), WebAssembly, and even Android APKs (e.g. via WASM + Cordova).

Collision Detection with Box2D in Python and C

This is a very basic but important example. It demonstrates how to render text using SDL3_ttf and how to detect collisions with Box2D.

Source code

collision-detection-pybox2d-2.3.10-pysdl-3.3.2-python

How to run (Python)

1. Install Python

Download and install Python 3 from the official website:

This project was tested with Python 3.12, but newer Python 3 versions should also work.

Make sure Python is added to your PATH during installation.


2. Install required packages

You can install specific versions (recommended for reproducibility):

pip install Box2D==2.3.10
pip install PySDL3==0.9.9b1

Or install the latest available versions:

pip install Box2D PySDL3

3. Run the project

  1. Open a terminal / Command Prompt
  2. Navigate to the project directory
  3. Run:
py main.py

(Optional) Troubleshooting

  • If pip installs packages for a different Python version, try:

    py -m pip install Box2D PySDL3
    
  • On some systems, you may need to use:

    python main.py
    

    instead of py main.py.


Example: Print SDL3 library versions

pip install PySDL3==0.9.9b1

Source code: GitHub repository

py main.py

Console output:

PySDL3 version:       0.9.9b1
Compiled SDL version: 3.3.2
Linked SDL version:   3.3.2
SDL3_image version:   3.2.4
SDL3_ttf version:     3.2.2
SDL3_mixer version:   3.1.0

The decorators can be deleted.

Use:

def SDL_AppInit(...):

def SDL_AppEvent(...):

def SDL_AppIterate(...):

def SDL_AppQuit(...):

Instead of this:

@sdl3.SDL_AppInit_func
def SDL_AppInit(...):

@sdl3.SDL_AppEvent_func
def SDL_AppEvent(...):

@sdl3.SDL_AppIterate_func
def SDL_AppIterate(...):

@sdl3.SDL_AppQuit_func
def SDL_AppQuit(...):

Like this:

main.py

import ctypes
import os

os.environ["SDL_MAIN_USE_CALLBACKS"] = "1"
os.environ["SDL_RENDER_DRIVER"] = "opengl"

import sdl3

renderer = ctypes.POINTER(sdl3.SDL_Renderer)()
window = ctypes.POINTER(sdl3.SDL_Window)()

def SDL_AppInit(appstate, argc, argv):

    # Init
    sdl3.SDL_Init(sdl3.SDL_INIT_VIDEO)

    # Create window
    title = "Example".encode()
    sdl3.SDL_CreateWindowAndRenderer(title, 350, 350, 0, window, renderer)

    # Turn on vertical sync
    sdl3.SDL_SetRenderVSync(renderer, 1)

    return sdl3.SDL_APP_CONTINUE

def SDL_AppEvent(appstate, event):
    ev = sdl3.SDL_DEREFERENCE(event)

    if ev.type == sdl3.SDL_EVENT_QUIT:
        return sdl3.SDL_APP_SUCCESS
    
    return sdl3.SDL_APP_CONTINUE

def SDL_AppIterate(appstate):
    sdl3.SDL_SetRenderDrawColor(renderer, 50, 50, 50, sdl3.SDL_ALPHA_OPAQUE)
    sdl3.SDL_RenderClear(renderer)
    # Draw here
    sdl3.SDL_RenderPresent(renderer)
    return sdl3.SDL_APP_CONTINUE

def SDL_AppQuit(appstate, result):
    # SDL will clean up the window/renderer for us
    pass

Tested with 0.9.9b1:

pip install PySDL3==0.9.9b1
py main.py

Drawing a few rotated rectangles in one file using OpenGL ES 3.0

It is a very simple example for beginners that shows how to draw a few rotated rectangles using OpenGL ES 3.0 in Python and PySDL3. There are more examples in OpenGL here: Simple OpenGL examples for beginners in PySDL3

pip PySDL3 numpy PyOpenGL PyGLM
py main.py

main.py

import ctypes
import math
import os

import glm
import numpy as np
from OpenGL.GL import *
from OpenGL.GL.shaders import *

os.environ["SDL_MAIN_USE_CALLBACKS"] = "1"
os.environ["SDL_RENDER_DRIVER"] = "opengles2"

import sdl3

glContext = None
window = None

uColorLocation = None
uMvpMatrixLocation = None
projViewMatrix = None

vertexShaderSource = """
    #version 300 es
    layout(location = 0) in vec2 aPosition;
    uniform mat4 uMvpMatrix;
    void main()
    {
        gl_Position = uMvpMatrix * vec4(aPosition, 0.0, 1.0);
    }
"""

fragmentShaderSource = """
    #version 300 es
    precision mediump float;
    uniform vec3 uColor;
    out vec4 fragColor;
    void main()
    {
        fragColor = vec4(uColor, 1.0);
    }
"""

def SDL_AppInit(appstate, argc, argv):
    global glContext
    global window
    global projViewMatrix
    global uColorLocation
    global uMvpMatrixLocation

    if not sdl3.SDL_Init(sdl3.SDL_INIT_VIDEO):
        sdl3.SDL_Log("Couldn't initialize SDL: %s".encode() % sdl3.SDL_GetError())
        return sdl3.SDL_APP_FAILURE

    sdl3.SDL_GL_SetAttribute(sdl3.SDL_GL_MULTISAMPLEBUFFERS, 1) # Enable MULTISAMPLE
    sdl3.SDL_GL_SetAttribute(sdl3.SDL_GL_MULTISAMPLESAMPLES, 2) # Can be 2, 4, 8 or 16

    sdl3.SDL_GL_SetAttribute(sdl3.SDL_GL_CONTEXT_PROFILE_MASK, sdl3.SDL_GL_CONTEXT_PROFILE_ES)
    sdl3.SDL_GL_SetAttribute(sdl3.SDL_GL_CONTEXT_MAJOR_VERSION, 3)
    sdl3.SDL_GL_SetAttribute(sdl3.SDL_GL_CONTEXT_MINOR_VERSION, 0)

    windowTitle = "PySDL3, PyGLM, PyOpenGL".encode()
    window = sdl3.SDL_CreateWindow(windowTitle, 350, 350, sdl3.SDL_WINDOW_OPENGL)
    if not window:
        sdl3.SDL_Log("Couldn't create a window: %s".encode() % sdl3.SDL_GetError())
        return sdl3.SDL_APP_FAILURE

    # Create an OpenGL context
    glContext = sdl3.SDL_GL_CreateContext(window)
    if not glContext:
        sdl3.SDL_Log("Couldn't create a glContext: %s".encode() % sdl3.SDL_GetError())
        return sdl3.SDL_APP_FAILURE

    sdl3.SDL_GL_SetSwapInterval(1) # Turn on vertical sync

    glDisable(GL_FRAMEBUFFER_SRGB)

    glClearColor(0.2, 0.2, 0.2, 1)

    vertPositions = np.array([
        -0.5, -0.5, # First triangle
        -0.5, 0.5,
        0.5, -0.5,
        0.5, -0.5, # Second triangle
        -0.5, 0.5,
        0.5, 0.5
    ], dtype=np.float32)
    vertPosBuffer = glGenBuffers(1)
    glBindBuffer(GL_ARRAY_BUFFER, vertPosBuffer)
    glBufferData(GL_ARRAY_BUFFER, len(vertPositions) * 4,
        vertPositions, GL_STATIC_DRAW)

    program = compileProgram(
        compileShader(vertexShaderSource, GL_VERTEX_SHADER),
        compileShader(fragmentShaderSource, GL_FRAGMENT_SHADER))
    glUseProgram(program)

    aPositionLocation = glGetAttribLocation(program, "aPosition")
    glVertexAttribPointer(aPositionLocation, 2, GL_FLOAT, GL_FALSE, 0, ctypes.c_void_p(0))
    glEnableVertexAttribArray(aPositionLocation)

    # Access the uniform variables in the shaders
    uColorLocation = glGetUniformLocation(program, "uColor")
    uMvpMatrixLocation = glGetUniformLocation(program, "uMvpMatrix")

    # Create an orthographic projection matrix and a view matrix
    projMatrix = glm.ortho(0, 300, 300, 0, 1, -1)
    viewMatrix = glm.lookAt(
        glm.vec3(0, 0, 1), # Position
        glm.vec3(0, 0, 0), # Target
        glm.vec3(0, 1, 0)) # Up vector
    # Combine them to one projView matrix
    projViewMatrix = projMatrix * viewMatrix

    return sdl3.SDL_APP_CONTINUE

def SDL_AppEvent(appstate, event):
    if sdl3.SDL_DEREFERENCE(event).type == sdl3.SDL_EVENT_QUIT:
        return sdl3.SDL_APP_SUCCESS

    return sdl3.SDL_APP_CONTINUE

def drawRectangle(pos, size, angle, color):
    # Create a model matrix, that is, a matrix combining the
    # translation matrix, rotation matrix, and the scale matrix
    modelMatrix = glm.translate(glm.mat4(1), glm.vec3(pos.x, pos.y, 0))
    modelMatrix = glm.rotate(modelMatrix, math.radians(angle), glm.vec3(0, 0, 1))
    modelMatrix = glm.scale(modelMatrix, glm.vec3(size.x, size.y, 1))

    # Combine projView matrix and model matrix into one MVP matrix
    mvpMatrix = projViewMatrix * modelMatrix

    # Send MVP matrix to the vertex shader
    glUniformMatrix4fv(uMvpMatrixLocation, 1, GL_FALSE, glm.value_ptr(mvpMatrix))

    # Send a color to the fragment shader
    glUniform3fv(uColorLocation, 1, glm.value_ptr(color))

    # Draw a rectangle
    glDrawArrays(GL_TRIANGLES, 0, 6)

def SDL_AppIterate(appstate):
    global projViewMatrix
    global uMvpMatrixLocation
    glClear(GL_COLOR_BUFFER_BIT)

    # First rectangle
    position = glm.vec2(200, 70)
    size = glm.vec2(150, 20)
    angle = -20
    color = glm.vec3(1, 0.5, 0.5)
    drawRectangle(position, size, angle, color)

    # Second rectangle
    position = glm.vec2(80, 150)
    size = glm.vec2(100, 30)
    angle = 20
    color = glm.vec3(0.5, 1, 0.5)
    drawRectangle(position, size, angle, color)

    # Third rectangle
    position = glm.vec2(150, 250)
    size = glm.vec2(200, 20)
    angle = 0
    color = glm.vec3(0.5, 0.5, 1)
    drawRectangle(position, size, angle, color)

    sdl3.SDL_GL_SwapWindow(window)
    return sdl3.SDL_APP_CONTINUE

def SDL_AppQuit(appstate, result):
    global glContext
    sdl3.SDL_GL_DestroyContext(glContext)
    # SDL will clean up the window/renderer for us

Way late to this party, but we actually wrote up some documentation on how to start SDL from scripting languages in weird ways, which might be useful to someone else in the future:

https://wiki.libsdl.org/SDL3/NonstandardStartup

Loading a texture using SDL3_image for OpenGL 3.3

Free asset (right arrow): https://www.flaticon.com/free-icons/arrow

pip PySDL3 numpy PyOpenGL PyGLM

texture-pysdl3-image-opengl-python.zip (4.6 KB)

cd texture-pysdl3-image-opengl-python
py main.py

C/C++ version: GitHub - ivan-enzhaev/texture-sdl3-opengl-mingw-c: A clean C template for loading and rendering 2D textures using SDL3_image, OpenGL 3.3 Core, GLAD, and CMake (MinGW/Windows) · GitHub

main.py

import ctypes
import os
import numpy as np
from OpenGL.GL import *
from OpenGL.GL.shaders import *

# Set runtime callback and window driver flags
os.environ["SDL_MAIN_USE_CALLBACKS"] = "1"
os.environ["SDL_RENDER_DRIVER"] = "opengl"

import sdl3

# Global References
window = None
glContext = None
shaderProgram = None
VAO = None
VBO = None
EBO = None
textureID = None

vertexShaderSource = """
#version 330 core
layout (location = 0) in vec3 aPosition;
layout (location = 1) in vec2 aTexCoord;
out vec2 vTexCoord;
void main()
{
    gl_Position = vec4(aPosition, 1.0);
    vTexCoord = vec2(aTexCoord.x, 1.0 - aTexCoord.y);
}
"""

fragmentShaderSource = """
#version 330 core
precision mediump float;
out vec4 FragColor;
in vec2 vTexCoord;
uniform sampler2D ourTexture;
void main()
{
    FragColor = texture(ourTexture, vTexCoord);
}
"""

@sdl3.SDL_AppInit_func
def SDL_AppInit(appstate, argc, argv):
    global window, glContext, shaderProgram, VAO, VBO, EBO, textureID

    if not sdl3.SDL_Init(sdl3.SDL_INIT_VIDEO):
        return sdl3.SDL_APP_FAILURE

    # Match Desktop Core Profile 3.3 attributes
    sdl3.SDL_GL_SetAttribute(sdl3.SDL_GL_CONTEXT_PROFILE_MASK, sdl3.SDL_GL_CONTEXT_PROFILE_CORE)
    sdl3.SDL_GL_SetAttribute(sdl3.SDL_GL_CONTEXT_MAJOR_VERSION, 3)
    sdl3.SDL_GL_SetAttribute(sdl3.SDL_GL_CONTEXT_MINOR_VERSION, 3)

    window = sdl3.SDL_CreateWindow("SDL3_image OpenGL".encode(), 380, 380, sdl3.SDL_WINDOW_OPENGL)
    if not window:
        return sdl3.SDL_APP_FAILURE

    glContext = sdl3.SDL_GL_CreateContext(window)
    if not glContext:
        return sdl3.SDL_APP_FAILURE

    sdl3.SDL_GL_SetSwapInterval(1)

    glEnable(GL_BLEND)
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)

    # Setup Geometry
    vertices = np.array([
         0.5,  0.5, 0.0, 1.0, 1.0,
         0.5, -0.5, 0.0, 1.0, 0.0,
        -0.5, -0.5, 0.0, 0.0, 0.0,
        -0.5,  0.5, 0.0, 0.0, 1.0
    ], dtype=np.float32)

    indices = np.array([0, 1, 3, 1, 2, 3], dtype=np.uint32)

    VAO = glGenVertexArrays(1)
    VBO = glGenBuffers(1)
    EBO = glGenBuffers(1)

    glBindVertexArray(VAO)
    
    glBindBuffer(GL_ARRAY_BUFFER, VBO)
    glBufferData(GL_ARRAY_BUFFER, vertices.nbytes, vertices, GL_STATIC_DRAW)
    
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO)
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.nbytes, indices, GL_STATIC_DRAW)

    float_size = 4
    stride = 5 * float_size

    # Position Attribute (Location 0)
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(0))
    glEnableVertexAttribArray(0)
    
    # TexCoord Attribute (Location 1)
    glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(3 * float_size))
    glEnableVertexAttribArray(1)
    
    glBindVertexArray(0)

    # Shader Compilation via PyOpenGL helper
    shaderProgram = compileProgram(
        compileShader(vertexShaderSource, GL_VERTEX_SHADER),
        compileShader(fragmentShaderSource, GL_FRAGMENT_SHADER)
    )

    # Load Image file via integrated SDL3_image function
    texturePath = "assets/images/right-arrow.png".encode()
    surface = sdl3.IMG_Load(texturePath)
    if not surface:
        sdl3.SDL_Log("Image loading failed: %s".encode() % sdl3.SDL_GetError())
        return sdl3.SDL_APP_FAILURE

    # Convert surface to standard ABGR8888 to feed straight to GL_RGBA
    optimizedSurface = sdl3.SDL_ConvertSurface(surface, sdl3.SDL_PIXELFORMAT_ABGR8888)
    sdl3.SDL_DestroySurface(surface)

    if not optimizedSurface:
        sdl3.SDL_Log("Surface conversion failed: %s".encode() % sdl3.SDL_GetError())
        return sdl3.SDL_APP_FAILURE

    # Safely unpack the contents field depending on wrapper's pointer configuration
    surf_contents = optimizedSurface.contents if hasattr(optimizedSurface, 'contents') else optimizedSurface

    # Convert pixels to an OpenGL Texture Object
    textureID = glGenTextures(1)
    glBindTexture(GL_TEXTURE_2D, textureID)

    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)

    # Casting the pixels attribute directly to a ctypes void pointer allows 
    # PyOpenGL to read the surface buffer successfully.
    glTexImage2D(
        GL_TEXTURE_2D,
        0,
        GL_RGBA,
        surf_contents.w,
        surf_contents.h,
        0,
        GL_RGBA,
        GL_UNSIGNED_BYTE,
        ctypes.c_void_p(surf_contents.pixels)
    )
    glGenerateMipmap(GL_TEXTURE_2D)

    # Clean up memory handle
    sdl3.SDL_DestroySurface(optimizedSurface)

    return sdl3.SDL_APP_CONTINUE

@sdl3.SDL_AppEvent_func
def SDL_AppEvent(appstate, event):
    if sdl3.SDL_DEREFERENCE(event).type == sdl3.SDL_EVENT_QUIT:
        return sdl3.SDL_APP_SUCCESS
    return sdl3.SDL_APP_CONTINUE

@sdl3.SDL_AppIterate_func
def SDL_AppIterate(appstate):
    glClearColor(0.3, 0.3, 0.3, 1.0)
    glClear(GL_COLOR_BUFFER_BIT)

    glUseProgram(shaderProgram)
    glBindTexture(GL_TEXTURE_2D, textureID)
    glBindVertexArray(VAO)
    
    # Render indices via glDrawElements
    glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, ctypes.c_void_p(0))
    glBindVertexArray(0)

    sdl3.SDL_GL_SwapWindow(window)
    return sdl3.SDL_APP_CONTINUE

@sdl3.SDL_AppQuit_func
def SDL_AppQuit(appstate, result):
    global VAO, VBO, EBO, shaderProgram, glContext, window
    if VAO is not None: glDeleteVertexArrays(1, [VAO])
    if VBO is not None: glDeleteBuffers(1, [VBO])
    if EBO is not None: glDeleteBuffers(1, [EBO])
    if shaderProgram is not None: glDeleteProgram(shaderProgram)
    
    if glContext:
        sdl3.SDL_GL_DestroyContext(glContext)
    if window:
        sdl3.SDL_DestroyWindow(window)