SDL3 for Scala Native

Design

The binding is deliberately two-layered, and follows a few consistent conventions. You rarely need to know this to use it, but it explains why the API looks the way it…

The binding is deliberately two-layered, and follows a few consistent conventions. You rarely need to know this to use it, but it explains why the API looks the way it does and is essential if you extend it.

Two layers

The @extern layer is the raw FFI: one object per native library (extern.LibSDL3, extern.LibSDL3Ttf, …) that declares the C functions with Scala Native FFI types and @links the shared library. This is the only place Ptr, CStruct, CString, and UInt appear. You never import it.

The pure-Scala layer is the package object you do import (io.github.edadma.sdl3, io.github.edadma.sdl3_ttf, …). It speaks in Int, Double, Boolean, String, a Color case class, and wrapped handles, and it does the marshalling — allocating C strings in a Zone, converting numbers, packing structs — so callers never touch FFI types.

This split keeps the unsafe surface small and auditable, and lets the ergonomic layer evolve without changing the ABI declarations.

Handle wrappers are zero-cost

SDL hands back opaque pointers — SDL_Window *, SDL_Renderer *, SDL_Texture *, SDL_Surface *. Each is wrapped in an AnyVal value class:

implicit class Renderer(val ptr: sdl.SDL_Renderer) extends AnyVal:
  def clear(c: Color): Unit = ...
  def present(): Unit       = ...

Because they extend AnyVal, the wrapper is erased at runtime — a Renderer is the pointer, with methods. You get renderer.present() instead of SDL_RenderPresent(ptr) at no allocation cost. Each wrapper carries an isNull check for the common “did this fail?” test.

Colours

Color(r, g, b, a) is a plain case class with channels 0–255 (a defaults to 255). Color.fromRGB(0xRRGGBB) unpacks a packed literal, and Color.blend(a, b, t) does a clamped linear interpolation. The renderer and text APIs take Color directly.

Conventions worth knowing

These are the rules the binding holds to internally. They matter if you read the source or add functions.

Structs are passed by pointer

SDL functions that take a struct (SDL_FRect, SDL_Vertex, …) are called with a pointer to a buffer the wrapper fills, never a by-value Scala CStruct. Scala Native’s marshalling of small by-value struct arguments is unreliable, so the binding avoids it entirely. Where a C API insists on a by-value struct, the wrapper passes it in an ABI-equivalent form instead — for example SDL_Color (four bytes) is passed to the SDL_ttf render functions as a packed little-endian uint32, which lands in the same register as the 4-byte struct on the SysV-AMD64 and AArch64 ABIs.

Scratch buffers outlive the call, not the helper

A C call that needs a temporary struct (a rect for SDL_RenderFillRect, an event union for SDL_PollEvent) reads it from a persistent heap buffer owned by the package object, refilled per call. It is never stackalloc‘d in a helper that returns the pointer: stack memory belongs to the frame that allocates it, so such a pointer would dangle the instant the helper returns and SDL would read garbage. Reuse is safe because SDL reads the buffer synchronously within the call and rendering is single-threaded.

Events are a view over a union

pollEvent() fills one reusable SDL_Event buffer and returns an Event view. The field accessors (kind, keyScancode, mouseX, wheelY, …) are only meaningful for the matching kind, because the underlying struct is a union. Read kind first, then the fields for that event type.

Geometry is floating-point

SDL3’s render API takes float coordinates — the SDL2-era 16-bit clamping is gone. The binding exposes Double throughout and narrows to float at the FFI boundary. Filled shapes SDL has no primitive for (circles, thick lines) are built as triangle meshes and drawn through SDL_RenderGeometry; the mesh builders are unit-tested headlessly.

Search

Esc
to navigate to open Esc to close