Skip to main content

Module guide

Module guide 

Source
Expand description

§Guide

A tour of the whole API on one page: the game loop, Context, drawing, input, audio, configuration, features, assets and shipping to the browser. The same text ships in every project generated from the jam templates, as its CLAUDE.md.

This module has no items; it exists for its documentation.

ⓘ
use game_toolkit::prelude::*;

§The game loop

Implement the Game trait and hand it to run. Only init / update / render are required:

ⓘ
struct MyGame { /* your state */ }

impl Game for MyGame {
    fn init(ctx: &mut Context) -> Result<Self> { Ok(Self { /* ... */ }) }
    fn update(&mut self, ctx: &mut Context, dt: f32) { /* input + simulation, dt in seconds */ }
    fn render(&mut self, ctx: &mut Context, frame: &mut Frame) { /* draw */ }

    // Optional hooks (default no-ops):
    fn event(&mut self, ctx: &mut Context, e: &GameEvent) {}        // Resized / FocusChanged / CloseRequested / CanvasAttached (web)
    fn raw_window_event(&mut self, ctx: &mut Context, e: &winit::event::WindowEvent) -> bool { false } // return true = consumed (egui)
    fn shutdown(&mut self, ctx: &mut Context) {}
}

There is no fixed_update: set AppConfig::fixed_timestep instead and update is called with a fixed dt (zero, one or several times per frame); leave it None for variable dt. render runs once per frame either way; with a fixed step, ctx.time.alpha (0.0..1.0) is how far the frame is into the next step, for interpolating positions. dt is capped at AppConfig::max_frame_time (250 ms), so a long frame or time in the background slows the game down for one frame instead of fast-forwarding it. With a sillyecs world, leave fixed_timestep at None: the world runs its own fixed phases (see the game-toolkit-ecs docs).

§Context

ctx carries the whole runtime. Fields:

FieldTypeUse
ctx.gfxGraphicsrenderer: textures, meshes, cameras, screenshots, size()
ctx.inputInputkeyboard / mouse / gamepad
ctx.audioOption<Audio>sound - None when no device (runs muted); guard with if let Some(a) = &mut ctx.audio
ctx.assetsAssetsasset bytes (read), path resolution, hot-reload watcher
ctx.timeTimeelapsed, delta (Duration), frame: u64, fps: f32, alpha: f32 (fixed-step interpolation)
ctx.rngRngdeterministic xorshift64* RNG: next_f32(), range_u32(a..b), range_usize(a..b), reseed(seed)
ctx.windowArc<Window>the winit window
ctx.quit()request shutdown
ctx.load_texture(p)Result<TextureId>PNG from the asset root (native + web, hot-reloadable natively)
ctx.load_sound(p)Result<Option<SoundId>>wav/ogg/flac/mp3 from the asset root; Ok(None) when muted

§Virtual resolution (pixel art)

Set virtual_resolution: Some((320, 180)) in AppConfig to draw everything into a small canvas that is scaled onto the window with crisp pixels. scale_mode: ScaleMode::Integer (default; whole multiples, the leftover window is border_color), ScaleMode::Fit (fractional, keeps aspect) or ScaleMode::Stretch; scale_filter: ScaleFilter::Nearest (default) or Linear. With a canvas, ctx.gfx.size(), the cameras, ctx.input.mouse_pos()/mouse_delta() and touch positions are all in canvas pixels, so layout code is unchanged; ctx.gfx.window_size() and ctx.input.mouse_pos_window() give the window’s. All change at runtime: ctx.gfx.set_virtual_resolution(..), ctx.gfx.set_scale_mode(..), ctx.gfx.set_scale_filter(..), ctx.gfx.set_border_color(..). Text is drawn at canvas resolution too. For your own render passes, p.custom(layer, |pass| ..) (see Drawing) runs them at a painter layer, so they can go under sprites and text; ctx.gfx.canvas_view() is the canvas itself, for passes after the painter has flushed. ctx.gfx.flush_pending(frame) (or the end of the frame) scales the canvas onto the window, after which overlays like egui draw at window resolution.

§Pixel fonts

TrueType text (p.text) is anti-aliased; on a pixel-art canvas use bitmap fonts instead. They draw at whole-number scales on the pixel grid, as sprites (so they sort with sprites by layer):

ⓘ
p.pixel_text([4.0, 4.0], "SCORE 1200", 1, Color::WHITE);   // built-in 5x7 font, 8 px lines
let size = ctx.gfx.builtin_font().measure("SCORE 1200", 1); // [w, h] in whole pixels
let font = ctx.load_bitmap_font("font.png", &GridLayout::ascii(8, 8))?; // glyph sheet
let fnt = ctx.load_bmfont("fonts/tiny.fnt")?;               // BMFont text .fnt + its page
p.bitmap_text(&font, [10.0, 20.0], "Hello\nworld", 2, Color::WHITE);

GridLayout { cell, chars, proportional, spacing }: proportional: true trims each glyph to its inked columns. Draw sheets in white and colour with the tint. bitmap_text_ex / pixel_text_ex take a layer.

§Post-processing

Full-screen effects run after the scene, in two stages: PostStage::Canvas (at the virtual resolution, before the upscale: palette tricks, dithering) and PostStage::Window (after it, at window resolution: scanlines, masks, bloom). Without a virtual resolution both run at window size.

ⓘ
let fx = ShaderEffect::new(&ctx.gfx, "invert", r#"
    @fragment fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
        let c = textureSample(input_tex, nearest_sampler, in.uv);
        return vec4<f32>(1.0 - c.rgb, c.a);
    }
"#);
let id = ctx.gfx.add_post_effect(PostStage::Canvas, fx);
ctx.gfx.set_post_effect_enabled(id, false);

The shader gets input_tex, nearest_sampler, linear_sampler, history_tex (previous output, with .with_history()), the post uniform (size, canvas_size, window_size, canvas_origin, canvas_extent, time, frame, indexed) and VsOut { pos, uv }; it defines fs_main. With indexed colour, indices_tex / index_at(canvas_px) give the palette index behind each canvas pixel (post.indexed == 1u), e.g. to make only some entries glow. .with_source(texture_id) binds a texture of your own as source_tex (post.source == 1u), e.g. an emulated screen uploaded each frame with ctx.gfx.update_texture_rgba; a hand-written PostEffect gets any texture through pass.texture(id). ShaderEffect::with_params(&gfx, label, wgsl, &pod) adds @group(1) @binding(0) var<uniform> params, updated with gfx.post_effect_mut::<ShaderEffect>(id).unwrap().set_params(&pod). Implement PostEffect for multi-pass effects. Feature crt: CrtEffect::new(&ctx.gfx, CrtSettings::arcade()) (also subtle, consumer_tv, pc_monitor) in PostStage::Window, with scanlines locked to canvas rows; it also makes fractional ScaleMode::Fit look even. Its bloom follows brightness, or only chosen palette entries on an indexed canvas: bloom_source: BloomSource::indices(LAVA..LAVA + 8). beam: Some(CrtBeam { min, max, blur }) models the electron beam (lines that widen with brightness, a horizontal blur that melts dithering), monochrome: Some(color) makes a green or amber screen (CrtSettings::monochrome_monitor(color)), corner_radius rounds the tube. .with_source(CrtSource { texture, rect, region }) filters a texture of your own (an emulated screen) into a rectangle instead of the canvas, with scanlines on its lines.

§Indexed colour (feature indexed)

Draw palette indices instead of colours; the end of the frame looks them up in a 256-entry Palette, so swapping, crossfading or cycling palette entries recolours the screen for free.

ⓘ
ctx.gfx.enable_indexed(&Palette::from_hex(&[0x000000, 0x8cc8f0, 0x4a9a3a]));
ctx.gfx.set_palette(1, &night);                 // rows 0..PALETTE_ROWS (16)
ctx.gfx.set_palette_blend(0, 1, t);             // crossfade row 0 -> 1
ctx.gfx.add_palette_cycle(PaletteCycle { start: 16, len: 8, speed: 10.0 }); // steps/s, <0 reverses
let (tex, png_palette) = ctx.load_indexed_texture("tiles.png")?; // indexed PNG, kept as indices
p.rect(pos, size, Color::index(2));             // shapes, text: palette index as colour

Indices ride in the canvas’s red channel, so draw them fully opaque and untinted (circle edges are anti-aliased and blend indices). IndexedImage builds index textures by hand (create_indexed_texture). Your own shaders (a p.custom pass) write index i by returning index_out(i) and read one back with index_in(texel), from INDEX_WGSL (prepend it), with blending off. Post effects see the resolved colours, plus the indices (see Post-processing). Every effect shader also has bayer4(p) / bayer8(p) ordered-dither thresholds (BAYER_WGSL for your own shaders); add a world-anchored offset to p so the pattern doesn’t crawl.

§Drawing: the Painter

In render, get a Painter for the frame, then issue draw calls. The painter flushes on drop. All positions are pixels (top-left origin). Colors are impl Into<Color>: a Color (Color::WHITE, Color::hex(0xff8800), Color::rgb8(..), .with_alpha(0.5), .lerp(..)) or an [r, g, b, a] array in 0.0..=1.0. Layers are impl Into<Layer>: a bare integer or a named const HUD: Layer = Layer(3);.

ⓘ
let mut p = frame.painter(&mut ctx.gfx);
p.clear([0.07, 0.08, 0.11, 1.0]);
p.rect(pos, size, color);                 // also rect_outline(pos, size, thickness, color)
p.circle(center, radius, color);          // also circle_outline(center, radius, thickness, color)
p.line(a, b, thickness, color);
p.quad([a, b, c, d], color, layer);       // filled convex quad; also polygon(&points, color, layer)
p.triangles(&verts, &indices, color, layer); // raw filled triangle list
// gradients: quad_colored(points, [c0, c1, c2, c3], layer), polygon_colored, triangles_colored
// blend modes: quad_ex / polygon_ex / triangles_ex(..., layer, BlendMode::Additive)
// textured, perspective-correct (road segments, mode-7 floors). UVs past 0..1 clamp, or
// tile after ctx.gfx.set_texture_wrap(tex, TextureWrap::Repeat) (keep atlases on Clamp):
p.quad_textured(tex, [a, b, c, d], [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], color, layer, BlendMode::Alpha);
p.text(pos, "hi", 24.0, color);           // unlayered: on top of everything
p.text_ex(pos, "hi", 24.0, color, layer); // layered: covered by higher layers (pause overlay)
let [w, h] = p.measure_text("hi", 24.0);  // also ctx.gfx.measure_text, outside render
p.sprite(tex, pos, size);                 // tex: TextureId from ctx.load_texture(...)
p.sprite_region(tex, src_pos, src_size, pos, size); // pixel rect of an atlas
p.mesh(mesh, model, color);               // 3D, depth-tested under all 2D content

Builders for draws with several options; each queues its draw when dropped, at the end of the statement:

ⓘ
p.draw_sprite(tex, pos, size).region(src_pos, src_size).layer(CARS).flip_x_if(facing_left);
p.draw_rect(pos, size).color(Color::BLACK.with_alpha(0.6)).layer(Layer::TOP); // .outline(2.0)
p.draw_text([w / 2.0, 20.0], "PAUSED").size(48.0).align(Align::Center).layer(HUD);

Sprite builder: region (pixels), uv, color, rotation, flip_x / flip_y / flip_x_if(bool), layer, blend. Text builder: size (default 16), color, align (Left / Center / Right, around pos.x), layer (default: unlayered).

Lower-level variants take an explicit layer: sprite_ex(tex, SpriteInstance, layer, BlendMode), rect_ex(pos, size, color, layer, BlendMode) (also rect_outline_ex, line_ex), circle_ex(center, radius, thickness, color, layer). Also visible_rect(). With the vector feature: p.vector(|scene| { /* vello::Scene */ }).

Your own GPU work at a layer: p.custom(layer, move |pass| ..) runs when that layer draws, before its sprites, shapes and text, in the frame’s encoder. A full-screen shader background goes under everything on Layer::BOTTOM. Build the pipeline for ctx.gfx.surface_format(), ctx.gfx.multisample() and ctx.gfx.depth_stencil() and clone what it needs into the closure:

ⓘ
let (pipeline, bind_group) = (self.pipeline.clone(), self.bind_group.clone());
p.custom(Layer::BOTTOM, move |pass| {
    let mut rp = pass.begin_render_pass("background"); // loads, resolves MSAA, attaches depth
    rp.set_pipeline(&pipeline);
    rp.set_bind_group(0, &bind_group, &[]);
    rp.draw(0..3, 0..1);
});

Draw order: lower layers draw under higher ones. Within one layer, call order holds only for draws with the same blend mode and texture, and untextured shapes (rect, line) sort under textured sprites. Filled shapes (quad*, polygon*, triangles*) draw above a layer’s sprites and rects and below its circles, grouped by blend mode and texture like sprites; plain alpha-blended shapes on one layer keep call order. Layered text (text_ex, or draw_text with .layer) draws last within its layer; p.text draws above every layer. To draw a HUD rect over a sprite scene, give it a higher layer with rect_ex instead of relying on call order. Text positions are screen pixels: text doesn’t follow the 2D camera.

Atlas sprites with a layer or mirroring: p.draw_sprite(..).region(..) above, or SpriteInstance::at(pos, size).with_pixel_region(src_pos, src_size).flip_x() into sprite_ex (the painter looks the texture size up). flip_x / flip_y work on any UV range.

§Input

ⓘ
ctx.input.key_pressed(Key::Space)   // also key_held / key_released
ctx.input.mouse_pressed(MouseButton::Left) // mouse_held / mouse_released / mouse_pos() / mouse_delta() / scroll()
ctx.input.any_pressed()             // "press any key": any non-modifier key, mouse/pad button or touch
// also any_key_pressed / any_mouse_pressed / keys_pressed() (iterator) / pad.any_button_pressed()
for t in ctx.input.touches_started() { /* t.id(), t.pos() */ } // touches() / touch(id) / touches_ended()
ctx.input.set_touch_emulates_mouse(true) // first finger = mouse + Left button (AppConfig::touch_emulates_mouse)
if let Some(pad) = ctx.input.first_gamepad() {
    let id = pad.id();
    let move_x = pad.axis(Axis::LeftStickX);   // -1.0..=1.0, feed into movement
    if pad.button_held(Button::South) {        // also button_pressed / button_released
        ctx.input.set_rumble(id, 0.6, 200);    // magnitude, duration_ms
    }
}

§Audio (Option)

ⓘ
// In init: load once. `None` = no audio device, the game runs muted.
let boom: Option<SoundId> = ctx.load_sound("boom.wav")?;
let music: Option<SoundId> = ctx.load_sound("music.ogg")?;

// Later: one-shot effect.
if let (Some(audio), Some(id)) = (&mut ctx.audio, boom) {
    let _ = audio.play(id); // -> Result<SoundHandle, AudioError>
}

// Music, e.g. in init: loop at 30% volume. `deferred` queues it until audio may start (web).
if let (Some(audio), Some(id)) = (&mut ctx.audio, music) {
    let handle = audio.play_with(id, PlayOptions::looped().volume(0.3).deferred())?;
    handle.set_volume(0.5); // linear; also set_volume_db, fade_volume(v, Duration)
    handle.pause();         // resume, stop, is_playing, is_paused, is_finished
}

Keep the SoundHandle to control the sound later; dropping it lets the sound play out.

In the browser, audio starts on the player’s first key press / click / touch (a browser rule), or without one if the page already had one (the player clicked through to the game). Chromium says so at once, so audio.is_started() is already true in init; Firefox and Safari take up to ~1.5 s, so audio starts a few frames in - poll is_started() before showing a “click to start” screen. If the host page tells the game the player is back (they may have clicked the page since), call ctx.start_audio_if_allowed() to check again. Before audio starts play fails with AudioError::NotStarted (check audio.is_started()), so don’t ? it in update - log or ignore it, or use PlayOptions::deferred for music.

audio.suspend() / audio.resume() pause and continue everything at once (a pause menu). On the web, if the host page takes the canvas out of the document, the toolkit suspends the audio by itself and sends GameEvent::CanvasAttached(false) / (true) on the first frame after the canvas is back - pause gameplay there if you want to.

§Chiptune music and effects (synth feature)

No audio files needed: write music in tracker notation and play it live on the built-in synth (synthie), sequenced sample-accurately on the audio thread. Each lane is one synth channel and one cell per row: a note (C4, F#3, Bb2), a chord (E2+B2+E3, or E2^07c! as hex semitone offsets: a power chord), . hold, - release, @n switch to the song’s patch n, drums k s h o c x (kick, snare, closed / open hat, crash, clap), k+c together, | a bar line for readability. Patches come from the typed Preset bank: C64Bass, ArpLead, PluckyPulse, PwmLead, MetalPulse, NoiseSnare, SlideBass, TriPad, DriveGuitar, RockLead, MusicBox, TriBass, Brass, EchoLead (Preset::ALL; preset(name) looks one up by display name, e.g. from a config file).

ⓘ
use synth::{ChannelNo, DrumHit, Preset, Song, Tempo};

// In init. Tempo::bpm(bpm, rows_per_beat): 2 = one row per eighth note.
let song = Song::new(Tempo::bpm(128.0, 2))
    .patch(Preset::PwmLead.params())                           // @0
    .patch(Preset::C64Bass.params())                           // @1
    .lane(ChannelNo(0), "A4@0 . C5 . E5 . D5 C5 | E5 . . . - . . .")?
    .lane(ChannelNo(1), "A2@1 A3 A2 A3 A2 A3 A2 A3 | E2 E3 E2 E3 E2 E3 E2 -")?
    .lane(ChannelNo(3), "k+c h s h k k s h | k h s h k s s s")?;
let music: Option<SynthHandle> = ctx.audio.as_mut().and_then(|a| {
    a.play_synth(song, PlayOptions::looped().volume(0.4).deferred()).ok()
});

// Later (keep the handle in your state: dropping the last clone stops the music).
music.fade_volume(0.1, Duration::from_millis(300)); // duck; set_volume, pause, resume, stop
let _ = music.drum(DrumHit::Crash);                  // live events: send(AudioEvent), note_on

Effects and jingles render to PCM once (e.g. in init) and play with play_samples:

ⓘ
use synth::{AudioEvent, MidiNote, SynthParams};

let mut sfx = Synth::new(44_100);
let coin = sfx.coin();                                   // also blip(), thud()
let zap = sfx.render(Preset::MetalPulse.params(), 84, 0.05, 0.2); // note, hold s, release s
let jingle = sfx.render_phrase(&[                         // (seconds, event); notes overlap
    (0.00, AudioEvent::NoteOn(MidiNote(72))),
    (0.08, AudioEvent::NoteOn(MidiNote(76))),
    (0.30, AudioEvent::NoteOff(MidiNote(72))),
    (0.30, AudioEvent::NoteOff(MidiNote(76))),
], SynthParams::default(), 0.4);                          // patch, tail s
let fanfare = sfx.render_song(Song::new(Tempo::bpm(160.0, 4)).lane(ChannelNo(0), "C5 E5 G5 C6 -")?, 0.5);
let _ = audio.play_samples(&jingle, sfx.sample_rate());

§Raw PCM and streams

play_samples / play_samples_with take mono PCM (e.g. from Synth). For audio produced while it plays by your own code (a custom synth, an emulated sound chip), play_stream(source, sample_rate) / play_stream_with call source (any FnMut(&mut [f32]) + Send, or an AudioStream) for mono samples on the audio thread, and return a StreamHandle (set_volume, pause, resume, stop). It works on the web too. The prelude re-exports kira for anything else (handle.with_kira(|h| ...)); don’t add your own kira dependency.

§Randomness

ctx.rng is a deterministic xorshift64* generator: the same AppConfig::random_seed reproduces the same run, which is handy for debugging and replays.

ⓘ
let r = ctx.rng.next_f32();                  // [0.0, 1.0)
let face = ctx.rng.range_u32(1..7);          // 1..=6
let i = ctx.rng.range_usize(0..items.len()); // panics if the range is empty
ctx.rng.reseed(42);                          // restart the stream

Not cryptographically secure - gameplay use only (procedural generation, jitter, AI variation).

§Graphics essentials

ⓘ
let (w, h) = ctx.gfx.size();
let tex = ctx.load_texture("player.png")?;            // -> TextureId (relative to asset_root)
let mesh = ctx.gfx.create_mesh(&vertices, &indices);  // &[MeshVertex], &[u16] -> MeshId
ctx.gfx.camera3d.eye = [2.5, 2.0, 3.5];               // perspective Camera3D (3D path)
ctx.gfx.request_screenshot("shot.png");               // saves the next presented frame (native only)
let rt = ctx.gfx.register_texture(texture, view, Some("rt")); // your own wgpu::Texture + view -> TextureId
// ctx.gfx.camera is the 2D Camera2D.

§AppConfig (in main)

ⓘ
fn main() -> Result<()> {
    #[cfg(not(target_arch = "wasm32"))]
    env_logger::init();
    run::<MyGame>(AppConfig {
        title: "my-jam".into(),
        width: 1280,
        height: 720,
        // The crate's assets/ natively (so `cargo run` works from any directory),
        // "assets" next to index.html on the web:
        asset_root: asset_root!(),
        canvas_id: Some("game".into()), // web: render into index.html's <canvas id="game">
        ..Default::default()
    })
}

All fields: title, width, height, vsync (default true), fixed_timestep: Option<Duration> (default None), max_frame_time: Duration (default 250 ms; caps dt), asset_root (default ./assets), web_preload: Option<Vec<String>> (web: files to fetch instead of assets/manifest.txt), canvas_id: Option<String> (web: existing canvas; None appends one), random_seed: u64 (seeds ctx.rng; default is a fixed nonzero constant, so runs are reproducible until you change it), depth_format: Option<wgpu::TextureFormat> (default None; set Some(wgpu::TextureFormat::Depth32Float) for 3D), msaa_samples (default 1; 4 for anti-aliasing), touch_emulates_mouse (default false), required_limits: Option<wgpu::Limits> / required_features: wgpu::Features (extra device limits and features for your own compute or render passes; merged with the toolkit’s, and init fails naming whatever the adapter lacks), preferred_limits / preferred_features (the same, but each is clamped to what the adapter has and never fails; read the result from ctx.gfx.device().limits()), continue_without_requirements (default false; true starts without unmet required limits and reports them in ctx.gfx.unmet_requirements(), so init can draw a “WebGPU required” screen) and device_hook (edit the device request with the adapter at hand). WebGL2 has no compute: to degrade instead of failing, check ctx.gfx.supports_compute() in init.

§Features

All optional features and what they add to the prelude:

FeatureAddsFor
uiUi, eguiegui debug overlays
asepriteSpriteSheet, AnimationPlayer, Animation, Direction, FrameRect, LoadAseprite (ctx.load_aseprite(p))load .aseprite / exported sheets + play tagged animations
ecsChannelQueue, Vec2 (sillyecs glue)archetype ECS
synthSynth, SynthHandle, synth (Song, Tempo, Preset, …), synthiechiptune music (audio.play_synth) and effects
vectorvellohigh-quality 2D vector graphics (WebGPU only: skipped on WebGL2)
crtCrtEffect, CrtSettings, CrtMaskbuilt-in CRT post-processing filter (see Post-processing)
indexedPalette, PaletteCycle, IndexedImage, Color::indexindexed-colour canvas with palette cycling (see Indexed colour)
computeKernel, Binding, PingPong, PingPongBindGroups, ReadbackRing, StorageTexture, ComputeSubmitterGPU simulations offloaded from update (ComputeSubmitter::try_submit, throttled by in-flight work) or recorded into frame.encoder_mut(); read/write buffer pairs drawable as vertex buffers, non-blocking read-back a few frames late (WebGPU only: check ctx.gfx.supports_compute())

Enable in Cargo.toml: game-toolkit = { ..., features = ["ui", "aseprite"] }.

§Fast dev builds

Unoptimized dependencies make debug builds of a game slow enough to be unplayable (audio decoding, image decoding, thousands of sprites per frame). Optimize just the dependencies, so your own code still compiles fast and debugs well. The jam templates ship this in Cargo.toml:

[profile.dev.package."*"]
opt-level = 3

§Assets

Put art / audio / fonts under assets/. They load relative to AppConfig::asset_root; use the ctx.load_* helpers (or ctx.assets.read(p) for raw bytes) so the same code works on the web. Natively, ctx.assets.watch() + ctx.assets.drain_changes() hot-reload changed files, and ctx.gfx.refresh_textures(paths) re-uploads textures loaded with ctx.load_texture live.

§Shipping to the browser

The same code builds for the web (wasm32, WebGPU with a WebGL2 fallback) via trunk: rustup target add wasm32-unknown-unknown, cargo install trunk --locked, then task web (dev server) or PROFILE=release task web:build in a jam-template project, or trunk serve / trunk build --release directly (zip dist/ and upload it to itch.io as an HTML game).

What’s different on the web:

  • Assets are preloaded. Before init, the toolkit fetches every file listed in assets/manifest.txt; task web / task web:build regenerate it (task assets:manifest). A file missing from the manifest fails to load at runtime. Native debug builds warn (asset ... is not listed in .../manifest.txt) when the game loads a file the manifest doesn’t list, so watch the log after adding assets.
  • No filesystem paths. Load through ctx.load_texture / ctx.load_sound / ctx.load_aseprite / ctx.assets.read, not std::fs or the path-taking ctx.gfx.load_texture (native only).
  • Audio after the first gesture, gamepads after the first button press (browser rules).
  • Not available: hot-reload (watch is a no-op), request_screenshot, ctx.quit() just stops the loop. std::time::Instant::now() panics in wasm - use ctx.time.
  • Logs and panics go to the browser console.

§Upstream examples

Browse the game-toolkit repo examples/ for runnable references: 01_window, 02_sprite, 03_primitives, 04_text, 05_tilemap, 06_egui, 07_3d, 07_audio, 08_hot_reload, 09_aseprite, 10_gamepad, 11_ecs, 12_vector, 13_web (runs natively and in the browser), 16_compute (GPU Game of Life, compute feature), bouncing_ball.