Graphics and Audio

Sigil provides cross-platform graphics, audio, and windowing for building games and creative applications. It's built on Sokol, a collection of lightweight C libraries.

Modules

  • (sigil app) — Application lifecycle, windowing, input events
  • (sigil graphics) — 2D rendering, textures, transforms
  • (sigil graphics font) — Text rendering with TrueType fonts
  • (sigil graphics image) — Image loading
  • (sigil audio) — Sound effects and music playback

Quick Start

A minimal graphical application:

(import (sigil app)
        (sigil graphics))

(define (init) #f)

(define (frame dt)
  (with-frame
    (clear-screen 0.1 0.1 0.2)
    (set-color 1.0 0.5 0.0)
    (fill-rect 100 100 64 64)))

(define (cleanup) #f)

(app-run init frame cleanup)

Application Lifecycle

app-run

The main entry point for graphical applications:

(app-run init-proc frame-proc cleanup-proc
         [title: "Sigil App"]
         [width: 800]
         [height: 600])
  • init-proc — Called once at startup
  • frame-proc — Called every frame with delta time (seconds)
  • cleanup-proc — Called before exit

Input

;; Keyboard
(key-down? 'space)      ; Currently held
(key-pressed? 'return)  ; Just pressed this frame
(key-released? 'escape) ; Just released this frame

;; Mouse
(mouse-x)               ; X position in pixels
(mouse-y)               ; Y position in pixels
(mouse-down? 'left)     ; Button held
(mouse-pressed? 'right) ; Button just clicked

;; Lifecycle
(quit-requested?)       ; User tried to close window
(request-quit)          ; Trigger application exit

Key symbols: 'a through 'z, '0 through '9, 'space, 'return, 'escape, 'left, 'right, 'up, 'down, 'tab, 'backspace, etc.

Frame Info

(frame-time)    ; Seconds since last frame (delta time)
(time-elapsed)  ; Seconds since app started
(frame-width)   ; Frame buffer width in pixels
(frame-height)  ; Frame buffer height in pixels

Graphics

Frame Structure

All drawing happens between begin-frame and end-frame:

(with-frame
  (clear-screen 0 0 0)
  ;; drawing commands here
  )

The with-frame macro handles begin-frame and end-frame automatically.

Viewport and Coordinates

Set up a virtual coordinate system with automatic letterboxing:

(set-viewport 320 240)  ; Virtual 320x240 resolution
(set-letterbox-color 0 0 0)  ; Black bars

This maintains aspect ratio regardless of window size.

Colors

(set-color 1.0 0.0 0.0)       ; Red (RGB, 0.0-1.0)
(set-color 1.0 1.0 1.0 0.5)   ; White, 50% transparent (RGBA)

Shape Primitives

;; Rectangles
(fill-rect x y width height)
(draw-rect x y width height)

;; Lines and points
(draw-line x1 y1 x2 y2)
(draw-point x y)

;; Triangles
(fill-triangle x1 y1 x2 y2 x3 y3)
(draw-triangle x1 y1 x2 y2 x3 y3)

Textures

Load and draw images:

(import (sigil graphics image))

;; Load image from file
(define img (load-image "sprite.png"))

;; Create GPU texture
(define tex (load-texture img))

;; Draw at position
(draw-texture tex 100 100)

;; Draw scaled
(draw-texture tex 100 100 width: 64 height: 64)

;; Draw sprite sheet region
(draw-texture-region tex x y sx sy sw sh)

Supported formats: PNG, JPEG, BMP, TGA, GIF.

Transforms

Apply transformations to subsequent drawing:

(push-transform)
  (translate 100 100)
  (rotate 0.5)  ; radians
  (scale 2.0 2.0)
  (fill-rect -16 -16 32 32)  ; Drawn transformed
(pop-transform)

;; Rotate around a point
(rotate-at angle center-x center-y)
(scale-at sx sy center-x center-y)

;; Reset to identity
(reset-transform)

Text Rendering

(import (sigil graphics font))

;; Load TrueType font at pixel size
(define font (load-font "DejaVuSans.ttf" 24))

;; Draw text
(draw-text font "Hello, World!" 100 100)

;; Measure text width
(text-width font "Hello")  ; => pixels

;; Draw centered
(draw-text-centered font "Centered" 400 300)

Audio

Setup

Audio is initialized automatically when using app-run. For manual control:

(import (sigil audio))

(audio-setup)
;; ... use audio ...
(audio-shutdown)

Sound Effects

Short sounds loaded entirely into memory:

(define explosion (load-sound "explosion.ogg"))

;; Play sound
(play-sound explosion)
(play-sound explosion volume: 0.5)  ; 50% volume

;; Stop all sounds
(stop-all-sounds)

Music

Streamed from disk for longer audio:

(define bgm (load-music "background.ogg"))

;; Playback control
(play-music bgm)
(play-music bgm loop: #t)  ; Loop forever
(pause-music)
(resume-music)
(stop-music)

;; Check state
(music-playing?)  ; => #t or #f

;; Volume
(set-music-volume 0.8)  ; 0.0 to 1.0

Supported format: OGG Vorbis.

Global Audio Control

(set-master-volume 0.5)  ; Affects all audio
(mute-audio)
(unmute-audio)
(audio-muted?)  ; => #t or #f

Streaming Audio on Every Target

A game that makes its own sound (a synth, a tracker) pushes PCM into a stream with (sigil audio sink). The same source runs natively, where miniaudio drains the stream, and in the browser, where an AudioWorklet does (sigil-audio plus sigil-wasm-audio on the web config).

(import (sigil audio sink))

(define ctx (audio-context-open))          ; resume it from a tap on the web
(define rate (audio-context-sample-rate ctx))
(define music (open-audio-stream channels: 2 buffer-frames: 16384))

;; each frame: top the stream up toward the depth you want kept ahead
(push-audio-samples music pcm n-frames)     ; interleaved float32
(audio-stream-depth music)                  ; frames queued, not yet heard

The depth a stream keeps ahead is how long a frame may stall before the output runs dry. It is also how late anything pushed now is heard: at 48 kHz a 12288-frame depth is 256 ms.

Cues: sounds that must be heard at once

Do not push a tap or a hit into a stream: it would wait behind everything queued. Load it once as a cue and play it by handle:

(define tap (load-audio-cue (render-tap rate) channels: 1))  ; float32 PCM at `rate`

(play-audio-cue tap volume: 0.8 pan: -0.25)
(set-audio-cue-volume! 0.5)   ; the SFX volume (in the worklet, cues already playing too)
(stop-audio-cues!)

In the browser a cue is mixed in the AudioWorklet in the next 128-frame render quantum (2.7 ms at 48 kHz), whatever any stream holds, cross-origin isolated or not; a page with no worklet plays it from a buffer made once, on the cue's first play. (audio-cue-mode) says which, and (audio-cue-lag) reports the frames the last play waited for its quantum. Natively a cue plays through the device callback, like play-sound.

Rendering below the device's rate

A producer can render at a lower rate than the device's and let the stream upsample (a 4-tap cubic, in the audio thread):

(define music (open-audio-stream rate: 24000))
(set-audio-stream-rate! music 22050)   ; from the frames pushed next

A rate change applies from the stream's write position when you call it: frames already queued play at the rate they were rendered at, so a producer can step down without reopening the stream. Depth and room count the stream's own frames.

Knowing what the player heard

(audio-stream-underruns s) counts output frames the stream could not fill. Not all of them are audible: a stream with nothing to say that runs dry is silent either way. (audio-stream-underruns-sounding s) counts the frames that cut sound off (the stream ran dry within 128 frames of a sample above -80 dB), and (audio-stream-underruns-idle s) the rest. Judge a fix by the sounding count.

The rate ladder

On a weak device a synth can cost more per frame than a 60 fps frame has. (sigil audio ladder) measures what the producer costs and steps its render rate down a rung when the steady cost is over budget:

(import (sigil audio ladder))

(define ladder (make-rate-ladder rate
                                 rungs: '(32000 24000 22050)
                                 budget-ms: 4.0
                                 key: "ring 16384"))   ; the buffering it measures under

;; each pull of the producer, timed
(rate-ladder-measure ladder n (lambda () (render! buf n)))
;; each cue: the next 150 ms of pulls share a frame with the tap
(rate-ladder-quiet! ladder)
;; once a frame
(let ((new-rate (rate-ladder-step! ladder)))
  (when new-rate
    (reopen-synth-at! new-rate)
    (set-audio-stream-rate! music new-rate)))

(rate-ladder-report ladder)   ; => "48000/24000 BY LADDER@20.2S"

The median of 60 measured pulls decides, never a single pull. Pulls within 150 ms of a cue and the 30 after an open or a step are not measured. The rate only goes down, and never below floor: (default: the lowest rung); rate-ladder-pin! and rate-ladder-unpin! are the only ways to set it higher. To keep a device's rung across runs, pass remember: a procedure that stores (rate-ladder-remembered ladder) and call rate-ladder-restore! with it at the next boot. A stored rate is ignored under a different key:, because the rung a device lands on depends on the buffering around the measurement: one phone settled a rung higher with a 320 ms ring than with a 256 ms ring.

Example: Simple Game

(import (sigil app)
        (sigil graphics)
        (sigil graphics font)
        (sigil audio))

(define player-x 400)
(define player-y 300)
(define speed 200)
(define font #f)
(define jump-sound #f)

(define (init)
  (set-viewport 800 600)
  (set! font (load-font "game-font.ttf" 16))
  (set! jump-sound (load-sound "jump.ogg")))

(define (frame dt)
  ;; Input
  (when (key-down? 'left)
    (set! player-x (- player-x (* speed dt))))
  (when (key-down? 'right)
    (set! player-x (+ player-x (* speed dt))))
  (when (key-pressed? 'space)
    (play-sound jump-sound))
  (when (key-pressed? 'escape)
    (request-quit))

  ;; Render
  (with-frame
    (clear-screen 0.2 0.3 0.4)

    ;; Player
    (set-color 1.0 0.8 0.0)
    (fill-rect (- player-x 16) (- player-y 16) 32 32)

    ;; UI
    (set-color 1.0 1.0 1.0)
    (draw-text font "Arrow keys to move, Space to jump" 10 10)))

(define (cleanup) #f)

(app-run init frame cleanup
         title: "My Game"
         width: 800
         height: 600)

Building a Game

Add the graphics and audio packages to your dependencies:

;; package.sgl
(package
  name: "my-game"
  version: "0.1.0"

  dependencies:
  (list
    (from-workspace name: "sigil-app")
    (from-workspace name: "sigil-graphics")
    (from-workspace name: "sigil-audio"))

  entry: '(my-game main))

Build and run:

sigil run

Create a standalone executable:

sigil build --config release

Cross-Platform Notes

The graphics and audio libraries work on:

  • Linux (OpenGL)
  • macOS (Metal)
  • Windows (D3D11)
  • WebAssembly (WebGL)

The same code runs on all platforms — Sokol handles the graphics backend abstraction.