game-toolkit 0.11.3

A Rust game-jam toolkit for 2D and 3D - umbrella crate; use game_toolkit::prelude::* and go.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458

```rust,ignore
use game_toolkit::prelude::*;
```

## The game loop

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

```rust,ignore
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:

| Field | Type | Use |
|-------|------|-----|
| `ctx.gfx` | `Graphics` | renderer: textures, meshes, cameras, screenshots, `size()` |
| `ctx.input` | `Input` | keyboard / mouse / gamepad |
| `ctx.audio` | `Option<Audio>` | sound - `None` when no device (runs muted); guard with `if let Some(a) = &mut ctx.audio` |
| `ctx.assets` | `Assets` | asset bytes (`read`), path resolution, hot-reload watcher |
| `ctx.time` | `Time` | `elapsed`, `delta` (`Duration`), `frame: u64`, `fps: f32`, `alpha: f32` (fixed-step interpolation) |
| `ctx.rng` | `Rng` | deterministic xorshift64* RNG: `next_f32()`, `range_u32(a..b)`, `range_usize(a..b)`, `reseed(seed)` |
| `ctx.window` | `Arc<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):

```rust,ignore
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.

```rust,ignore
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.

```rust,ignore
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);`.

```rust,ignore
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:

```rust,ignore
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:

```rust,ignore
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

```rust,ignore
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`)

```rust,ignore
// 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).

```rust,ignore
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`:

```rust,ignore
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.

```rust,ignore
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

```rust,ignore
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`)

```rust,ignore
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:

| Feature | Adds | For |
|---------|------|-----|
| `ui` | `Ui`, `egui` | egui debug overlays |
| `aseprite` | `SpriteSheet`, `AnimationPlayer`, `Animation`, `Direction`, `FrameRect`, `LoadAseprite` (`ctx.load_aseprite(p)`) | load `.aseprite` / exported sheets + play tagged animations |
| `ecs` | `ChannelQueue`, `Vec2` (sillyecs glue) | archetype ECS |
| `synth` | `Synth`, `SynthHandle`, `synth` (`Song`, `Tempo`, `Preset`, ...), `synthie` | chiptune music (`audio.play_synth`) and effects |
| `vector` | `vello` | high-quality 2D vector graphics (WebGPU only: skipped on WebGL2) |
| `crt` | `CrtEffect`, `CrtSettings`, `CrtMask` | built-in CRT post-processing filter (see Post-processing) |
| `indexed` | `Palette`, `PaletteCycle`, `IndexedImage`, `Color::index` | indexed-colour canvas with palette cycling (see Indexed colour) |
| `compute` | `Kernel`, `Binding`, `PingPong`, `PingPongBindGroups`, `ReadbackRing`, `StorageTexture`, `ComputeSubmitter` | GPU 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`:

```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](https://trunkrs.dev): `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](https://github.com/sunsided/game-toolkit) `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`.