SDL3 graphics bindings for Spinel, an ahead-of-time Ruby compiler.
Uses Spinel's built-in FFI DSL (ffi_lib, ffi_func, ffi_const) — no external gems required.
- Spinel (
spin) - SDL3, SDL3_ttf, and SDL3_image (PNG/JPEG + the dependency-free formats — see
sdl/native/sdl3_image/build.sh), all statically linked from source (seespin.toml's[[build]]/[native]sections) — nobrew installneeded at build or run time, the compiled binaries have no SDL dylib dependency
The first build compiles a small C shim against SDL3. Spin will prompt you to allow it — answer always to permanently trust it, or run spin trust sdl beforehand to skip the prompt entirely.
Create a new project and add sdl as a dependency:
mkdir myapp && cd myapp
spin init
spin add sdl --git https://github.com/jockofcode/sdlPut your program in bin/hello.rb:
require "sdl"
SDL::Screen.open("My Window", width: 800, height: 600) do |window, renderer|
running = true
while running
while (event = SDL::Event.poll)
running = false if event == LibSDL::QUIT
running = false if event == LibSDL::KEYDOWN && SDL::Event.key_sym == LibSDL::K_ESCAPE
end
renderer.draw_color(0, 0, 0)
renderer.clear
renderer.draw_color(255, 80, 0)
renderer.fill_rect(100, 100, 200, 150)
renderer.present
SDL::Screen.delay(16)
end
endBuild and run:
spin run hello # build + run in one stepOr build first and run the binary directly:
spin build
./build/bin/helloThe entry point. Always use the block form — it handles SDL_Init/SDL_Quit and resource cleanup.
SDL::Screen.open("Title", width: 800, height: 600) do |window, renderer|
SDL::Screen.ticks # milliseconds since SDL_Init (SDL_GetTicks)
SDL::Screen.delay(ms) # pause for ms milliseconds (SDL_Delay)
endThe flags: keyword accepts any combination of LibSDL::WINDOW_* constants and defaults to WINDOW_RESIZABLE (windows are shown by default in SDL3 — there's no WINDOW_SHOWN flag anymore).
Wraps an SDL_Window*. Received as the first block argument from Screen.open.
window.width # current width in pixels
window.height # current height in pixels
window.title = str # update the window title bar
window.ptr # raw SDL_Window* for advanced useWraps an SDL_Renderer*. Received as the second block argument from Screen.open.
renderer.draw_color(r, g, b, a = 255) # set current draw color
renderer.clear # fill with current draw color
renderer.present # flip to screen
renderer.draw_point(x, y) # draw a single pixel
renderer.draw_line(x1, y1, x2, y2) # draw a line
renderer.fill_rect(x, y, w, h) # draw a filled rectangle
renderer.draw_rect(x, y, w, h) # draw a rectangle outline
renderer.ptr # raw SDL_Renderer* for advanced usePolls events and exposes fields from the last polled event.
# In your main loop — call until nil to drain the queue each frame
while (event_type = SDL::Event.poll)
case event_type
when LibSDL::QUIT
# window closed
when LibSDL::KEYDOWN, LibSDL::KEYUP
SDL::Event.key_sym # SDLK_* key code (integer)
SDL::Event.key_mod # modifier bitmask
SDL::Event.shift? # true if shift held
SDL::Event.ctrl? # true if ctrl held
SDL::Event.alt? # true if alt held
when LibSDL::MOUSEMOTION
SDL::Event.mouse_x
SDL::Event.mouse_y
when LibSDL::MOUSEBUTTONDOWN, LibSDL::MOUSEBUTTONUP
SDL::Event.mouse_x
SDL::Event.mouse_y
SDL::Event.mouse_button # LibSDL::BUTTON_LEFT / BUTTON_MIDDLE / BUTTON_RIGHT
SDL::Event.mouse_clicks # 1 = single, 2 = double
when LibSDL::MOUSEWHEEL
SDL::Event.wheel_x
SDL::Event.wheel_y
when LibSDL::EVENT_WINDOW_PIXEL_SIZE_CHANGED
# SDL3 gives every window event its own top-level type — no more
# SDL_WINDOWEVENT wrapper + sub-ID. Add more LibSDL::EVENT_WINDOW_*
# constants straight from SDL_events.h as needed.
window.width
window.height
end
endNamed color constants as [r, g, b, a] arrays, ready to splat into draw_color.
renderer.draw_color(*SDL::Color::RED)
renderer.draw_color(*SDL::Color::WHITE)Available colors: BLACK WHITE RED GREEN BLUE YELLOW CYAN MAGENTA ORANGE GRAY
renderer.draw_text(font, "Score: 0", 8, 4, 255, 255, 255, 255)Two ways to load one:
# 1. One of sdl's own bundled fonts (SDL::Fonts), loaded from bytes
# compiled directly into the binary -- no filesystem path involved.
font = SDL::Font.bundled(SDL::Fonts::VT323_NAME, 28)
# SDL::Fonts::PUBLIC_SANS_NAME / ::JETBRAINS_MONO_NAME also available.
# 2. Any font file at a path you supply yourself.
font = SDL::Font.new("/absolute/path/to/font.ttf", 28)
font.close # call once you're done with it (e.g. after Screen.open's block)Prefer SDL::Font.bundled over building a path with __dir__. Spinel's
__dir__ is a compile-time literal of the source file's directory on the
machine that ran spin build — it is not derived from the running
executable's location at runtime. A path like
File.join(__dir__, "..", "assets", "fonts", "whatever.ttf") bakes in that
build machine's absolute path, so it keeps working as long as you run the
binary from the same checkout on the same machine, but breaks silently the
moment you copy the compiled binary anywhere else (another machine, a
dist/ folder, a released artifact) — TTF_OpenFont returns NULL for
the missing path, and renderer.draw_text just quietly draws nothing, with
every other graphic (which has no file dependency) rendering fine. If your
app needs its own custom font rather than one of sdl's bundled ones, embed
it the same way sdl embeds its own (see sdl/bin2c.c and
build_shim.sh for the pattern: convert the .ttf into a compiled-in byte
array in your own [[build]] step, then open it via SDL_IOFromConstMem +
TTF_OpenFontIO through your own small FFI shim) rather than shipping the
font file alongside the binary and hoping the path holds up.
texture = SDL::Texture.load(renderer, "/absolute/path/to/image.png")
# BMP, GIF, PNM, XCF, XPM, PCX, LBM, QOI, TGA, SVG decode with no external
# dependency; PNG and JPEG are vendored in (libpng/libjpeg built from
# source — see sdl/native/sdl3_image/build.sh). AVIF/WEBP/TIFF/JXL are off.
renderer.draw_texture(texture, x, y) # native pixel size
renderer.draw_texture(texture, x, y, w: 128, h: 128) # scaled
renderer.draw_texture(texture, x, y, src: [0, 0, 32, 32]) # crop to a source rect (e.g. one sprite-sheet frame)
renderer.draw_texture(texture, x, y, angle: 45, flip: :horizontal) # rotate/flip about the dst rect's center
renderer.draw_texture(texture, x, y, angle: 45, center: [0, 0]) # ...or an explicit pivot point
renderer.fill_polygon_textured(points, uvs, texture) # texture-mapped convex polygon/quad (points/uvs are
# flat [x0,y0,x1,y1,...]/[u0,v0,u1,v1,...] arrays)
texture.width
texture.height
texture.set_color_mod(255, 128, 64) # tint this texture's pixels (0..255 each)
texture.set_alpha_mod(200) # scale this texture's alpha (0..255)
texture.closeSame __dir__ caveat as SDL::Font.new applies to Texture.load's path — see "Fonts and portability" above.
Poll/query-based — SDL3's gamepad API needs no callbacks. SDL_INIT_GAMEPAD is included in Screen.open's default init flags.
SDL::Gamepad.count # number of connected gamepads
pad = SDL::Gamepad.open(0) # by enumeration index, not instance ID — nil if none
pad.connected?
pad.name
pad.button(LibSDL::GAMEPAD_BUTTON_SOUTH) # true/false
pad.axis(LibSDL::GAMEPAD_AXIS_LEFTX) # -32768..32767
pad.rumble(low: 20000, high: 20000, duration_ms: 150)
pad.closeHandle LibSDL::EVENT_GAMEPAD_ADDED / EVENT_GAMEPAD_REMOVED from SDL::Event.poll to react to hotplug; EVENT_GAMEPAD_BUTTON_DOWN / _UP / EVENT_GAMEPAD_AXIS_MOTION carry SDL::Event.gamepad_which / gamepad_button / gamepad_button_down? / gamepad_axis / gamepad_axis_value.
# Procedural tone, no asset file — a lazily-opened, process-lifetime stream.
SDL::Audio.beep(freq: 440, duration_ms: 200, volume: 80)
SDL::Audio.queued_ms # how much queued beep audio is still waiting to play
# A loaded WAV file, played on its own dedicated stream opened at the WAV's
# own native sample rate/channels/format (so playback is never a manual
# format-matching exercise).
sound = SDL::Sound.new("/absolute/path/to/sound.wav")
sound.play # restarts from the beginning
sound.len # PCM byte count
sound.closeSDL_INIT_AUDIO is included in Screen.open's default init flags; no separate setup call is needed before either of these.
A small multi-channel chip synth — independently-pitched software oscillators (waveform + ADSR envelope), mixed in C and pushed to one shared playback stream every frame. SDL::Audio.beep/SDL::Sound cover a one-shot tone or a pre-recorded file; SDL::Synth is for driving several simultaneous, independently-controllable voices in real time (a tracker/sequencer, a synth-driven soundtrack). Waveform math (8 shapes below, plus two filter bits) is a direct port of pico8tools/lemmings/build_music.rb's waveform_sample (itself a port of zepto8's synth.cpp), not a from-scratch design.
# waveform is one of: TRIANGLE, TILTED_SAW, SAW, SQUARE, PULSE, ORGAN, NOISE, PHASER
SDL::Synth.note_on(channel, freq_hz, SDL::Synth::SQUARE, volume) # volume 0.0..1.0
SDL::Synth.set_flags(channel, buzz, noiz) # PICO-8's waveform-reshaping bits
SDL::Synth.set_envelope(channel, attack_ms, decay_ms, sustain_level, release_ms)
SDL::Synth.note_off(channel) # starts the release stage, doesn't hard-cut
SDL::Synth.pump(ms) # call once per frame — synthesizes+queues real audio
SDL::Synth.queued_ms # how much queued synth audio is still waiting to playchannel is 0...SDL::Synth::CHANNELS (4 by default). pump is safe to call unconditionally every frame, even before any note has played — it's a no-op until the first note_on opens the stream. Note-level effects (slide, vibrato, arpeggio, fades) aren't a C-side feature: resolve them in the caller by recomputing freq_hz/volume per tick and re-issuing note_on, the same way a tracker's playback routine already works.
SDL::Screen.open only ever opens one Window/Renderer pair, but neither class was ever a singleton — a second independent pair is just a second Window.new / Renderer.new:
SDL::Screen.open("Window A", width: 480, height: 320) do |window_a, renderer_a|
window_b = SDL::Window.new("Window B", width: 480, height: 320)
renderer_b = SDL::Renderer.new(window_b)
# ... main loop: dispatch each polled event by SDL::Event.window_id,
# matched against window_a.id / window_b.id (SDL_GetWindowID) ...
renderer_b.close
window_b.close
endSee bin/multi_window_demo.rb for the full dispatch loop.
Both flow through SDL::Event.poll like keyboard/mouse — no extra setup.
when LibSDL::EVENT_FINGER_DOWN, LibSDL::EVENT_FINGER_MOTION, LibSDL::EVENT_FINGER_UP
SDL::Event.touch_x # normalized 0..1
SDL::Event.touch_y # normalized 0..1
SDL::Event.touch_dx # normalized -1..1, motion since last event
SDL::Event.touch_dy
SDL::Event.touch_pressure # normalized 0..1
when LibSDL::EVENT_PEN_DOWN, LibSDL::EVENT_PEN_MOTION, LibSDL::EVENT_PEN_UP,
LibSDL::EVENT_PEN_BUTTON_DOWN, LibSDL::EVENT_PEN_BUTTON_UP, LibSDL::EVENT_PEN_AXIS
SDL::Event.pen_x # window-relative pixels
SDL::Event.pen_y
SDL::Event.pen_down?
SDL::Event.pen_eraser? # true if using the eraser end
SDL::Event.pen_button # button index (EVENT_PEN_BUTTON_* only)
SDL::Event.pen_button_down?
SDL::Event.pen_axis # LibSDL::PEN_AXIS_* (EVENT_PEN_AXIS only)
SDL::Event.pen_axis_valueOptional file logger. Useful when you can't write to stdout during an SDL session.
SDL::Log.open("/tmp/sdl.log") # call before Screen.open
SDL::Log.write("message")
# SDL::Log.close is called automatically by Screen.open's ensure clause| Constant | Value |
|---|---|
QUIT |
0x100 |
KEYDOWN |
0x300 |
KEYUP |
0x301 |
MOUSEMOTION |
0x400 |
MOUSEBUTTONDOWN |
0x401 |
MOUSEBUTTONUP |
0x402 |
MOUSEWHEEL |
0x403 |
EVENT_WINDOW_PIXEL_SIZE_CHANGED |
0x207 |
Printable characters map 1:1 to ASCII. Special keys:
| Constant | Description |
|---|---|
K_UP K_DOWN K_LEFT K_RIGHT |
Arrow keys |
K_RETURN |
Enter |
K_ESCAPE |
Escape |
K_BACKSPACE |
Backspace |
K_TAB |
Tab |
K_SPACE |
Space |
K_F1 – K_F12 |
Function keys |
KMOD_SHIFT, KMOD_CTRL, KMOD_ALT (also KMOD_LSHIFT, KMOD_RSHIFT, etc.)
BUTTON_LEFT, BUTTON_MIDDLE, BUTTON_RIGHT
GAMEPAD_BUTTON_SOUTH EAST WEST NORTH BACK GUIDE START LEFT_STICK RIGHT_STICK LEFT_SHOULDER RIGHT_SHOULDER DPAD_UP DPAD_DOWN DPAD_LEFT DPAD_RIGHT
GAMEPAD_AXIS_LEFTX LEFTY RIGHTX RIGHTY LEFT_TRIGGER RIGHT_TRIGGER
PEN_AXIS_PRESSURE XTILT YTILT DISTANCE ROTATION SLIDER TANGENTIAL_PRESSURE
EVENT_GAMEPAD_ADDED REMOVED BUTTON_DOWN BUTTON_UP AXIS_MOTION, EVENT_FINGER_DOWN UP MOTION CANCELED, EVENT_PEN_DOWN UP BUTTON_DOWN BUTTON_UP MOTION AXIS
SDL3's SDL_Event is a C union and SDL_FRect is a struct — neither can be constructed directly through Spinel's FFI DSL. sdl/shim.c is compiled into the binary alongside the generated code and provides plain-function accessors for event fields and rect-based draw calls. The approach is identical to the mouse_shim.c in the spinel-ncurses library.
shim.c also embeds sdl's bundled fonts (sdl/fonts/*.ttf) as compiled-in byte arrays, generated at build time by bin2c.c and linked in by build_shim.sh. sdl_open_bundled_font wraps them with SDL_IOFromConstMem + TTF_OpenFontIO so SDL::Font.bundled never touches the filesystem at runtime — see "Fonts and portability" above for why that matters.
shim.c also carries: sdl_event_window_id (dispatches on event category — SDL3 has no one shared union member for it), the gamepad/touch/pen event-field accessors, sdl_audio_beep (synthesizes a sine wave directly in C — Spinel's FFI has no bulk-array spec shaped for raw PCM bytes) and the sdl_wav_* family (loads a WAV onto its own dedicated audio stream, opened at the WAV's native format), the sdl_synth_* family (a small fixed bank of oscillator+envelope channels, mixed in C for the same bulk-PCM-through-FFI reason as sdl_audio_beep — see SDL::Synth above), and sdl_surface_width/height. A handful of sdl_test_push_*_event functions at the bottom inject synthetic events via SDL_PushEvent purely so test/*.rb can exercise the touch/pen/gamepad accessors deterministically without real hardware attached, plus sdl_synth_test_active (exposes a channel's envelope-active flag so test/synth.rb can confirm release actually reaches silence) — none of this test-support group is part of the public API.
| Program | Description |
|---|---|
bin/demo.rb |
Bouncing colored rectangle |
bin/snake.rb |
Classic Snake — arrow keys to steer, R to restart, Esc to quit |
bin/mouse_demo.rb |
Mouse position/buttons/clicks |
bin/gamepad_demo.rb |
Live button/axis display + rumble on button press; handles hotplug |
bin/image_demo.rb |
SDL_image texture loading — native/scaled/orbiting/tiled draws |
bin/audio_demo.rb |
Procedural beep tones (1-5 keys) + loaded-WAV playback (space) |
bin/synth_demo.rb |
Multi-channel chip synth — waveform switching (1-8), a 4-channel arpeggio (space), live envelope tweaking (up/down) |
bin/multi_window_demo.rb |
Two independent windows, events routed by SDL::Event.window_id |
bin/touch_pen_demo.rb |
Touch/pen event visualization, with a mouse fallback shape |
Build all examples:
spin buildRun an example:
spin run demoOr after building:
./build/bin/demospin testSnapshot tests print to stdout and diff against .expected files in test/. test/gamepad_events.rb, test/touch_events.rb, and test/pen_events.rb inject synthetic events via the sdl_test_push_*_event shim helpers (see "C shim" above) so they're deterministic without real hardware; test/audio.rb, test/synth.rb, and test/image.rb exercise SDL::Audio/SDL::Sound/SDL::Synth/SDL::Texture against the fixtures in sdl/assets/ (test.wav, test.bmp — both hand-built with no external tooling, see the generator scripts referenced in their header comments); test/multi_window.rb covers opening a second independent Window/Renderer pair.
MIT — see LICENSE.