ShimEngine

Auto-generated page. This reference was generated by Claude Opus 5 from clion_game/src/script_bridge.rs as of commit 8dc6e02. The engine is under active development, so that source file is the authoritative reference wherever the two disagree.

ShimEngine is the SDL3-based game engine in clion_game. It embeds the Shim interpreter and registers a set of native functions and globals into the script’s root environment. Everything on this page is available to a script without any import.

The script lifecycle

The engine loads a script file (game.shm by default) and runs it top to bottom once. The script must define a function named loop; the engine calls it once per frame. Loading fails with No loop function found if it is missing.

let x = 0

fn loop() {
    x = x + 60 * delta
    draw_rect(x, 100, 32, 32, r=255, g=64, b=64)
}

Outside of headless mode the engine watches the script file and hot-reloads it when it changes, which recreates the interpreter and discards script state. Use save_data/load_data to persist across reloads.

Note that these functions are specific to ShimEngine, and are not available in the playground sandbox — the sandbox has its own, smaller drawing API (for example it takes a color= tuple where ShimEngine takes separate r, g, b, a channels).

Drawing

Draw calls are queued into a per-frame draw list and submitted by the engine after loop returns. There is no explicit clear, so draw a full-screen rect first if you want a background.

FunctionDescription
draw_rect(x, y, w, h, ...)Queues a rectangle. See the kwargs below.
draw_text(x, y, text, size=1, center=false)Draws text with the built-in bitmap font.
text_size(text, size=1)Returns a (width, height) tuple for the text as it would be drawn.
create_texture(w, h, rgba_bytes, nearest=false)Uploads an RGBA texture and returns a texture handle.
Rect(x1, y1, x2, y2)Builds a UV sub-rectangle for draw_rect’s region=.
window_size()Returns the drawable size as a [width, height] list.

draw_rect

x, y, w, and h are required; everything else is optional:

ArgumentDefaultMeaning
texturenoneA handle from create_texture; the rect is drawn textured.
centerfalseWhen true, x/y are the rect’s centre instead of its top-left corner.
r, g, b, a255 eachColour modulation per channel.
regionwhole textureA Rect naming the UV sub-rectangle of the texture to sample.

Colour channels accept either an integer in 0..=255 or a float in 0.0..=1.0 (floats are scaled and rounded, and both forms are clamped). That means r=255 and r=1.0 are the same colour, but r=1 is nearly black.

fn loop() {
    // Dark background
    draw_rect(0, 0, 640, 480, r=17, g=24, b=39)

    // A translucent red square centred at (320, 240)
    draw_rect(320, 240, 64, 64, center=true, r=1.0, g=0.2, b=0.2, a=0.5)
}

draw_text and text_size

size is a scale factor on the 8px bitmap font, so size=2 draws 16px-tall text. With center=true the text is centred on x/y rather than starting there. text_size returns the (width, height) the same text and size would occupy, which is useful for laying out or boxing text yourself.

fn loop() {
    let label = "Score: 100"
    let dim = text_size(label, size=2)
    draw_rect(8, 8, dim[0] + 8, dim[1] + 8, r=0, g=0, b=0)
    draw_text(12, 12, label, size=2)
}

Textures

create_texture(w, h, rgba_bytes) takes a flat list of exactly w * h * 4 numbers in RGBA order. As with colour channels, integers are treated as 0..=255 and floats as 0.0..=1.0. Passing nearest=true selects nearest-neighbour filtering, which is what you want for pixel art. The returned handle is passed back as draw_rect’s texture= kwarg.

// A 2x2 checkerboard, built once at load time.
let CHECKER = {
    let data = []
    for i in Range(0, 4) {
        let v = if i == 0 or i == 3 { 255 } else { 0 }
        data.append(v); data.append(v); data.append(v); data.append(255)
    }
    create_texture(2, 2, data, nearest=true)
}

fn loop() {
    draw_rect(0, 0, 128, 128, texture=CHECKER)
}

To draw one sprite out of a sheet, pass region=Rect(x1, y1, x2, y2). The coordinates are UVs clamped to 0.0..=1.0, with (0, 0) at the top-left and (x2, y2) the bottom-right corner of the region.

Time

delta is a float global holding the seconds elapsed since the previous frame. The engine updates it before each loop call, so scale per-frame motion by it to stay frame-rate independent.

let angle = 0.0

fn loop() {
    angle = angle + 1.5 * delta
}

Keyboard input

The key global names every key: key.A, key.Space, key.Left, and so on. Each key exposes five attributes:

AttributeMeaning
.pressedTrue on every frame the key is held.
.releasedTrue on every frame the key is not held.
.just_pressedTrue only on the frame the key went down.
.just_releasedTrue only on the frame the key came up.
.just_pressed_with_repeatLike .just_pressed, but also fires on key-repeat after a 0.4s delay, then every 0.08s. Good for menus and text entry.

Reading an unknown name is an error (Unknown key: ...). The available names are:

  • Letters AZ
  • Digits Key0Key9, and keypad Kp0Kp9
  • Function keys F1F12
  • Enter, Escape, Backspace, Tab, Space, Delete, Insert
  • Minus, Equal, LeftBracket, RightBracket, Backslash, Semicolon, Apostrophe, GraveAccent, Comma, Period, Slash
  • Left, Right, Up, Down, Home, End, PageUp, PageDown
  • CapsLock, NumLock, ScrollLock, PrintScreen, Pause
  • KpDivide, KpMultiply, KpMinus, KpPlus, KpEnter
  • Modifiers LeftCtrl, LeftShift, LeftAlt, LeftSuper, and the matching Right... variants
fn loop() {
    if key.Escape.just_pressed {
        // open the pause menu
    }
    if key.Right.pressed {
        x = x + 120 * delta
    }
    if key.Down.just_pressed_with_repeat {
        selected = selected + 1
    }
}

Mouse input

FunctionDescription
mouse_pos()Returns the cursor position as an [x, y] list.
mouse_pressed(button)True while button is held.
mouse_just_pressed(button)True on the frame button went down.
mouse_just_released(button)True on the frame button came up.
show_cursor() / hide_cursor()Shows or hides the system cursor.

The button constants are MOUSE_LEFT (1), MOUSE_MIDDLE (2), MOUSE_RIGHT (3), MOUSE_X1 (4), and MOUSE_X2 (5). Any other value is an error.

fn loop() {
    let m = mouse_pos()
    if mouse_just_pressed(MOUSE_LEFT) {
        fire_at(m[0], m[1])
    }
}

Window

FunctionDescription
window_size()The drawable size as [width, height].
set_window_title(title)Sets the OS window title.
mouse_focus()True while the window is under the mouse.
input_focus()True while the window has keyboard focus.

Randomness

The engine seeds an xorshift64 generator from the system clock on first use.

CallResult
rand()Float in [0.0, 1.0).
rand(hi)Float in [0.0, hi).
rand(lo, hi)Float in [lo, hi).
randi()Non-negative integer.
randi(hi)Integer in [0, hi); hi must be non-negative.
randi(lo, hi)Integer in [lo, hi); lo must be <= hi.

Passing more than two arguments to either is an error.

Audio

Audio is queued as commands each frame and handled by the mixer. Playback calls return a voice handle you can keep to control that one sound later.

Playing tones

play_sine(freq, duration, amp=0.5, attack=0.005, decay=0.0, sustain=1.0,
          release=0.005, delay=0.0, pan=0.0, bus=0)

play_square(freq, duration, duty=0.5, amp=0.5, attack=0.005, decay=0.0,
            sustain=1.0, release=0.005, delay=0.0, pan=0.0, bus=0)

freq is in Hz and duration in seconds; both are required. attack, decay, sustain, and release are a standard ADSR envelope (sustain is a level clamped to 0.0..=1.0, the rest are seconds). delay postpones the start by that many seconds. pan is clamped to -1.0 (full left) through 1.0 (full right). duty on play_square is the pulse width, clamped to 0.0..=1.0.

fn beep() {
    play_sine(440.0, 0.15, amp=0.3)
}

Samples

create_sample(samples, sample_rate) registers a buffer of mono float samples and returns a sample handle. sample_rate must be positive. The handle’s .play() method starts a voice:

sample.play(amp=1.0, speed=1.0, fade_in=0.005, fade_out=0.005,
            delay=0.0, pan=0.0, bus=0)

speed is a playback-rate multiplier (so it changes pitch too); fade_in and fade_out are short click-avoiding ramps in seconds. .play() takes no positional arguments and returns a voice handle.

Voice handles

MemberDescription
.finishedTrue once the voice has stopped playing.
.stop()Stops the voice.
.set_gain(amp, ramp=0.01)Ramps the voice’s gain to amp over ramp seconds.
.set_pan(pan, ramp=0.01)Ramps the pan to pan (clamped to -1.0..=1.0).
.pause(fade=0.01)Fades out and freezes playback position.
.resume(fade=0.01)Fades back in from where it was paused.

Targeting a voice that has already finished is a harmless no-op.

let music = play_sine(220.0, 30.0, amp=0.2, bus=1)

fn loop() {
    if key.M.just_pressed {
        music.pause()
    }
    if music.finished {
        music = play_sine(220.0, 30.0, amp=0.2, bus=1)
    }
}

Buses

Every voice is routed to one of 32 mixer buses, addressed 0 through 31 (bus=0 is the default). A bus value outside that range, or a non-integer, is an error.

FunctionDescription
set_bus_gain(bus, gain, ramp=0.01)Ramps a bus’s gain over ramp seconds.
set_bus_lowpass(bus, cutoff, q=0.707, ramp=0.01)Applies a low-pass filter with the given cutoff (Hz) and resonance.
clear_bus_effects(bus)Removes the bus’s effects.
pause_bus(bus, fade=0.01)Fades out and freezes every voice on the bus.
resume_bus(bus, fade=0.01)Resumes the bus.
pause_audio(fade=0.01)Pauses the whole mix.
resume_audio(fade=0.01)Resumes the whole mix.
reset_audio(fade=0.05)Stops everything and resets the audio system.

The muffled-underwater effect is a low-pass on a bus:

let MUSIC_BUS = 1

fn duck_music() {
    set_bus_lowpass(MUSIC_BUS, 500.0, ramp=0.2)
    set_bus_gain(MUSIC_BUS, 0.4, ramp=0.2)
}

fn restore_music() {
    clear_bus_effects(MUSIC_BUS)
    set_bus_gain(MUSIC_BUS, 1.0, ramp=0.2)
}

Save data

save_data and load_data serialise Shim values to JSON on disk. Both are desktop only — on the web build they raise save_data is not supported on web.

save_data(data, path)
load_data(type, path, default=..., default_fn=...)

save_data writes any serialisable value: None, ints, floats, bools, strings, tuples, lists, dicts, and structs (whose fields are saved by name). Values that have no JSON form — functions, native handles — are an error.

load_data needs the struct type as its first argument, not just the path. The type argument supplies the captured environment used to reconstruct nested struct values, so the exact definition in scope is the one that gets rebuilt.

If the file is missing you can supply a fallback: default= for a ready-made value, or default_fn= for a function called to build one. Passing both is an error, and passing neither means a missing file is an error.

struct Game {
    level = 1
    score = 0
}

let state = load_data(Game, "game.sav", default_fn=Game)

fn loop() {
    if key.S.just_pressed {
        save_data(state, "game.sav")
    }
    if key.L.just_pressed {
        state = load_data(Game, "game.sav", default_fn=Game)
    }
}

Performance counters

The perf global reports the previous frame’s timings in seconds:

AttributeMeaning
perf.scriptTime spent running the script.
perf.gcTime spent in garbage collection.
perf.renderTime spent rendering.
perf.vsyncTime spent waiting for vsync.
perf.totalThe sum of the four above.
perf.mem_high_pointHigh-water mark of interpreter memory use, in bytes (an integer).
fn loop() {
    draw_text(4, 4, "frame: \(perf.total * 1000.0)ms")
}

Debug UI

Three functions wrap Dear ImGui for quick debug panels. They mirror the underlying immediate-mode API, so each ig_begin needs a matching ig_end.

FunctionDescription
ig_begin(title)Starts a window; returns true when the window is visible.
ig_text(text)Draws a line of text in the current window.
ig_end()Ends the current window.

Neither title nor text may contain a null byte.

fn loop() {
    if ig_begin("Debug") {
        ig_text("delta: \(delta)")
    }
    ig_end()
}