dune_skimmer/
lib.rs

1//! A compact low-poly hover time trial built on Ferravex's fixed clock and lit renderer.
2mod race;
3
4use ferravex_core::FixedClock;
5use ferravex_render::{
6    demo::Demo,
7    material::{MaterialOptions, ShaderMaterial, ShaderSource},
8    mesh::Mesh3d,
9    prelude::*,
10    shaders::{LitMaterial, LitSettings},
11    ui,
12    visibility::{LodChain, LodState},
13};
14use race::{
15    Course, GATES, HALF_WIDTH, HOVER_HEIGHT, Input, LAPS, Race, heading, path, right, tangent,
16    world,
17};
18use std::f32::consts::{PI, TAU};
19
20const SKY: Color = Color::from_rgba(181, 201, 195, 255);
21const SAND: Color = Color::from_rgba(194, 154, 106, 255);
22const STONE: Color = Color::from_rgba(170, 116, 74, 255);
23const PALE: Color = Color::from_rgba(238, 219, 179, 255);
24const TEAL: Color = Color::from_rgba(56, 159, 151, 255);
25const DARK: Color = Color::from_rgba(35, 55, 55, 255);
26const ORANGE: Color = Color::from_rgba(239, 125, 58, 255);
27
28fn conf() -> Conf {
29    let mut conf = ferravex_render::window_conf("Dune Skimmer / Ferravex");
30    conf.window_width = 1280;
31    conf.window_height = 800;
32    conf
33}
34
35struct Prop {
36    mesh: usize,
37    position: Vec3,
38    rotation: Quat,
39}
40struct Scene {
41    exhaust: ShaderMaterial,
42    ground: Mesh3d,
43    road: Mesh3d,
44    curbs: Mesh3d,
45    props: Vec<Prop>,
46    prop_meshes: Vec<Mesh3d>,
47    rock_lods: Vec<LodChain>,
48    rock_lod_states: std::cell::RefCell<Vec<LodState>>,
49    pillar: Mesh3d,
50    lintel: Mesh3d,
51    cap: Mesh3d,
52    marker: Mesh3d,
53    active_marker: Mesh3d,
54    arrow: Mesh3d,
55    hull: Mesh3d,
56    nose: Mesh3d,
57    cockpit: Mesh3d,
58    rail: Mesh3d,
59    engine: Mesh3d,
60    glow: Mesh3d,
61    stripe: Mesh3d,
62    shadow: Mesh3d,
63    dust: Mesh3d,
64    sun: Mesh3d,
65}
66
67/// Build one indexed flat ribbon; rendering never reconstructs road geometry.
68fn ribbon(inner: f32, outer: f32, height: f32, colors: [Color; 2]) -> Mesh3d {
69    let mut vertices = Vec::with_capacity(192 * 4);
70    let mut indices = Vec::with_capacity(192 * 6);
71    for i in 0..192 {
72        let a = i as f32 / 192.0 * TAU;
73        let b = (i + 1) as f32 / 192.0 * TAU;
74        let color = colors[(i / 3) % 2];
75        let base = vertices.len() as u16;
76        for (t, side) in [(a, inner), (a, outer), (b, outer), (b, inner)] {
77            let mut vertex = Vertex::new2(
78                world(path(t) + right(tangent(t)) * side, height),
79                Vec2::ZERO,
80                color,
81            );
82            vertex.normal = Vec3::Y.extend(0.0);
83            vertices.push(vertex);
84        }
85        indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]);
86    }
87    Mesh3d::from_mesh(Mesh {
88        vertices,
89        indices,
90        texture: None,
91    })
92}
93
94fn merge_meshes(first: Mesh3d, second: Mesh3d) -> Mesh3d {
95    let mut mesh = first.mesh;
96    let offset = mesh.vertices.len() as u16;
97    mesh.vertices.extend(second.mesh.vertices);
98    mesh.indices
99        .extend(second.mesh.indices.into_iter().map(|i| i + offset));
100    Mesh3d::from_mesh(mesh)
101}
102
103impl Scene {
104    fn new() -> Self {
105        let mut props = Vec::new();
106        // Wind-shaped dunes, flat-topped sandstone mesas, and collapsed columns.
107        // Deterministic placement stays clear of the racing surface.
108        let prop_meshes = vec![
109            Mesh3d::frustum(16.0, 7.0, 15.0, STONE, 6),
110            Mesh3d::frustum(11.0, 6.0, 8.0, Color::from_rgba(193, 139, 88, 255), 5),
111            Mesh3d::cone(21.0, 7.0, SAND, 7),
112            Mesh3d::cuboid(vec3(2.8, 7.0, 2.8), STONE),
113            Mesh3d::cuboid(vec3(9.0, 1.8, 2.8), PALE),
114            Mesh3d::frustum(3.0, 1.8, 3.0, Color::from_rgba(158, 107, 69, 255), 5),
115        ];
116        for i in 0..33 {
117            let angle = i as f32 / 33.0 * TAU;
118            let radius = 100.0 + (i % 4) as f32 * 10.0;
119            let mesh = i % 3;
120            let height = [7.1, 3.6, 3.1][mesh];
121            props.push(Prop {
122                mesh,
123                position: vec3(angle.cos() * radius, height, angle.sin() * radius),
124                rotation: Quat::from_rotation_y(angle),
125            });
126        }
127        for i in 0..12 {
128            let angle = (i as f32 + 0.3) / 12.0 * TAU;
129            let p = path(angle) - right(tangent(angle)) * (18.0 + (i % 3) as f32 * 2.0);
130            props.push(Prop {
131                mesh: 3,
132                position: world(p, 3.3),
133                rotation: Quat::from_rotation_y(angle),
134            });
135            if i % 3 == 0 {
136                props.push(Prop {
137                    mesh: 3,
138                    position: world(p + tangent(angle) * 6.0, 3.3),
139                    rotation: Quat::from_rotation_y(angle),
140                });
141                props.push(Prop {
142                    mesh: 4,
143                    position: world(p + tangent(angle) * 3.0, 7.0),
144                    rotation: Quat::from_rotation_y(heading(tangent(angle)) + PI * 0.5),
145                });
146            }
147        }
148        for i in 0..25 {
149            let angle = i as f32 * 2.399_963;
150            let radius = 11.0 + (i % 6) as f32 * 2.5;
151            props.push(Prop {
152                mesh: if i % 4 == 0 { 1 } else { 5 },
153                position: vec3(
154                    angle.cos() * radius,
155                    if i % 4 == 0 { 3.6 } else { 1.3 },
156                    angle.sin() * radius * 0.7,
157                ),
158                rotation: Quat::from_rotation_y(angle),
159            });
160        }
161        Self {
162            exhaust: ShaderMaterial::new(
163                ShaderSource {
164                    vertex: include_str!("../shaders/exhaust.vert").into(),
165                    fragment: include_str!("../shaders/exhaust.frag").into(),
166                },
167                MaterialOptions {
168                    blend: ferravex_render::material::BlendMode::Opaque,
169                    bounds_padding: 0.1,
170                    casts_shadows: false,
171                    receives_shadows: false,
172                },
173            )
174            .expect("exhaust material"),
175            ground: Mesh3d::plane(vec2(440.0, 440.0), SAND, 22),
176            road: ribbon(
177                -HALF_WIDTH,
178                HALF_WIDTH,
179                0.012,
180                [
181                    Color::from_rgba(170, 140, 101, 255),
182                    Color::from_rgba(174, 143, 104, 255),
183                ],
184            ),
185            curbs: merge_meshes(
186                ribbon(-HALF_WIDTH - 0.30, -HALF_WIDTH, 0.055, [PALE, STONE]),
187                ribbon(HALF_WIDTH, HALF_WIDTH + 0.30, 0.055, [PALE, STONE]),
188            ),
189            props,
190            prop_meshes,
191            rock_lods: (0..3)
192                .map(|i| {
193                    let radius = 1.1 + i as f32 * 0.24;
194                    LodChain::new(
195                        vec![
196                            Mesh3d::sphere(radius, STONE, 6, 4).resident().clone(),
197                            Mesh3d::sphere(radius, STONE, 4, 3).resident().clone(),
198                            Mesh3d::sphere(radius, STONE, 3, 2).resident().clone(),
199                        ],
200                        vec![35., 75.],
201                        0.12,
202                    )
203                    .unwrap()
204                })
205                .collect(),
206            rock_lod_states: Default::default(),
207            pillar: Mesh3d::cuboid(vec3(0.75, 7.6, 1.15), STONE),
208            lintel: Mesh3d::cuboid(vec3(HALF_WIDTH * 2.0 + 1.5, 0.7, 1.15), PALE),
209            cap: Mesh3d::cuboid(vec3(1.1, 0.5, 1.5), PALE),
210            marker: Mesh3d::cuboid(vec3(0.8, 1.0, 1.2), ORANGE),
211            active_marker: Mesh3d::cuboid(vec3(0.82, 1.0, 1.22), TEAL),
212            arrow: Mesh3d::cone(0.65, 1.1, TEAL, 3),
213            hull: Mesh3d::cuboid(vec3(1.8, 0.42, 3.2), TEAL),
214            nose: Mesh3d::cone(1.05, 2.3, TEAL, 4),
215            cockpit: Mesh3d::cuboid(vec3(1.05, 0.46, 1.3), DARK),
216            rail: Mesh3d::cuboid(vec3(0.3, 0.35, 3.8), PALE),
217            engine: Mesh3d::cylinder(0.42, 1.5, DARK, 8),
218            glow: Mesh3d::cone(0.29, 1.25, ORANGE, 6),
219            stripe: Mesh3d::cuboid(vec3(0.18, 0.015, 2.6), ORANGE),
220            shadow: Mesh3d::cylinder(1.0, 0.006, Color::from_rgba(114, 100, 77, 255), 8),
221            dust: Mesh3d::cuboid(vec3(0.22, 0.22, 0.22), PALE),
222            sun: Mesh3d::sphere(6.0, Color::from_rgba(255, 226, 166, 255), 12, 8),
223        }
224    }
225    fn draw(&self, course: &Course, race: &Race, position: Vec2, yaw: f32, time: f32) {
226        use ferravex_render::lighting::{PointLight, SunShadow};
227        ferravex_render::set_sun_shadow(Some(SunShadow {
228            center: world(position, 0.),
229            direction: vec3(-0.45, 0.9, -0.4),
230            extent: 38.,
231            depth: 100.,
232            ..Default::default()
233        }))
234        .unwrap();
235        let engine_light = if race.speed() > 1. { 1.4 } else { 0. };
236        ferravex_render::set_point_lights(&[PointLight {
237            position: world(position - race.forward() * 1.8, HOVER_HEIGHT),
238            color: ORANGE,
239            radius: 5.,
240            intensity: engine_light,
241        }])
242        .unwrap();
243
244        self.ground.draw(vec3(0.0, -0.03, 0.0), Quat::IDENTITY);
245        self.road.draw(Vec3::ZERO, Quat::IDENTITY);
246        self.curbs.draw(Vec3::ZERO, Quat::IDENTITY);
247        self.sun.draw(vec3(-95.0, 75.0, -130.0), Quat::IDENTITY);
248        for prop in &self.props {
249            self.prop_meshes[prop.mesh].draw(prop.position, prop.rotation);
250        }
251        {
252            let mut states = self.rock_lod_states.borrow_mut();
253            states.resize(course.rocks.len(), LodState::default());
254            for (index, &(p, _)) in course.rocks.iter().enumerate() {
255                ferravex_render::draw_lod(
256                    &self.rock_lods[index % 3],
257                    Mat4::from_rotation_translation(
258                        Quat::from_rotation_y(index as f32 * 1.7),
259                        world(p, 0.65),
260                    ),
261                    &mut states[index],
262                );
263            }
264        }
265        for (index, gate) in course.gates.iter().enumerate() {
266            let rotation = Quat::from_rotation_y(heading(gate.direction));
267            for side in [-1.0, 1.0] {
268                let p = gate.position + right(gate.direction) * (HALF_WIDTH + 0.4) * side;
269                self.pillar.draw(world(p, 3.7), rotation);
270                self.cap.draw(world(p, 0.4), rotation);
271                if index == race.next_gate {
272                    self.active_marker.draw(world(p, 4.7), rotation);
273                } else {
274                    self.marker.draw(world(p, 4.7), rotation);
275                }
276            }
277            self.lintel.draw(world(gate.position, 7.55), rotation);
278            if index == race.next_gate && !race.finished {
279                self.arrow.draw(
280                    world(gate.position, 4.7 + time.sin() * 0.18),
281                    Quat::from_rotation_z(PI),
282                );
283            }
284        }
285        let forward = vec2(yaw.sin(), yaw.cos());
286        let hover = HOVER_HEIGHT + (time * 5.0).sin() * 0.07;
287        let center = world(position, hover);
288        let rotation = Quat::from_rotation_y(yaw) * Quat::from_rotation_z(race.bank);
289        let draw = |mesh: &Mesh3d, offset: Vec3, local: Quat| {
290            mesh.draw(center + rotation * offset, rotation * local);
291        };
292        if ferravex_render::quality() == ferravex_render::lighting::Quality::Low {
293            for z in [-0.8, 0.8] {
294                self.shadow
295                    .draw(world(position + forward * z, 0.022), Quat::IDENTITY);
296            }
297        }
298        draw(&self.hull, Vec3::ZERO, Quat::IDENTITY);
299        draw(
300            &self.nose,
301            vec3(0.0, -0.03, 1.9),
302            Quat::from_rotation_x(PI * 0.5),
303        );
304        draw(
305            &self.cockpit,
306            vec3(0.0, 0.40, -0.3),
307            Quat::from_rotation_x(-0.08),
308        );
309        draw(&self.stripe, vec3(0.0, 0.22, 0.1), Quat::IDENTITY);
310        for x in [-1.12, 1.12] {
311            draw(&self.rail, vec3(x, -0.14, -0.1), Quat::IDENTITY);
312            draw(
313                &self.engine,
314                vec3(x, -0.05, -1.2),
315                Quat::from_rotation_x(PI * 0.5),
316            );
317            if race.speed() > 1.0 {
318                self.exhaust
319                    .begin([[time, 0., 0., 0.], [0.; 4], [0.; 4], [0.; 4]])
320                    .unwrap();
321                draw(
322                    &self.glow,
323                    vec3(x, -0.05, -2.0 - if race.boosting { 0.45 } else { 0.0 }),
324                    Quat::from_rotation_x(-PI * 0.5),
325                );
326                self.exhaust.end();
327            }
328        }
329        // Bounded analytic particles need no allocation or mutable emitter buffers.
330        if race.speed() > 3.0 && !race.finished {
331            for i in 0..22 {
332                let age = (time * 1.7 + i as f32 / 22.0).fract();
333                let side = (i as f32 * 2.39).sin() * (0.5 + age * 1.6);
334                let p =
335                    position - forward * (2.5 + age * race.speed() * 0.18) + right(forward) * side;
336                self.dust.draw(
337                    world(p, 0.25 + age * 0.8),
338                    Quat::from_rotation_y(i as f32 + time),
339                );
340            }
341        }
342    }
343}
344
345fn label(text: &str, x: f32, y: f32, size: f32, color: Color) {
346    draw_text(
347        text,
348        x + 1.0,
349        y + 2.0,
350        size,
351        Color::new(0.1, 0.14, 0.13, 0.5),
352    );
353    draw_text(text, x, y, size, color);
354}
355fn centered(text: &str, y: f32, size: u16, color: Color) {
356    let width = measure_text(text, None, size, 1.0).width;
357    label(text, (screen_width() - width) * 0.5, y, size as f32, color);
358}
359fn time_label(time: f32) -> String {
360    format!("{:02}:{:05.2}", (time / 60.0) as u32, time % 60.0)
361}
362fn draw_hud(race: &Race, touch_mode: bool) {
363    let w = screen_width();
364    let h = screen_height();
365    let margin = (w * 0.035).clamp(18.0, 44.0);
366    label("DUNE SKIMMER", margin, 39.0, 22.0, PALE);
367    label(
368        &format!("LAP {:02} / 03", (race.laps + 1).min(LAPS)),
369        margin,
370        66.0,
371        18.0,
372        WHITE,
373    );
374    let timer = time_label(race.elapsed);
375    let width = measure_text(&timer, None, 28, 1.0).width;
376    label(
377        &timer,
378        (w - width) * 0.5,
379        if w < 600.0 { 97.0 } else { 42.0 },
380        28.0,
381        WHITE,
382    );
383    let bottom = if touch_mode { h - 140.0 } else { h - 50.0 };
384    label(
385        &format!("{:03}", (race.speed() * 3.6) as u32),
386        margin,
387        bottom,
388        47.0,
389        WHITE,
390    );
391    label("KM/H", margin + 91.0, bottom, 14.0, PALE);
392    let bar_w = 136.0;
393    draw_rectangle(
394        w - margin - bar_w,
395        bottom - 16.0,
396        bar_w,
397        4.0,
398        Color::new(0.1, 0.2, 0.19, 0.45),
399    );
400    draw_rectangle(
401        w - margin - bar_w,
402        bottom - 16.0,
403        bar_w * race.boost,
404        4.0,
405        if race.boosting { ORANGE } else { TEAL },
406    );
407    label("BOOST", w - margin - bar_w, bottom + 8.0, 13.0, PALE);
408    if race.countdown > 0.0 {
409        centered("THREE LAPS THROUGH THE RUINS", h * 0.34, 20, PALE);
410        centered(
411            &format!("{}", race.countdown.ceil() as u32),
412            h * 0.48,
413            74,
414            WHITE,
415        );
416    } else if race.elapsed < 0.8 {
417        centered("GO", h * 0.42, 54, TEAL);
418    }
419    if race.off_track && !race.finished {
420        centered("LOOSE SAND", h - 27.0, 16, PALE);
421    } else if race.elapsed < 10.0 && !touch_mode {
422        centered(
423            "WASD / ARROWS  steer & accelerate     SPACE  boost     R  restart",
424            h - 19.0,
425            16,
426            PALE,
427        );
428    }
429    if race.last_gate_timer > 0.0 && race.countdown == 0.0 && !race.finished {
430        let text = if race.next_gate == 1 {
431            "LAP COMPLETE".to_owned()
432        } else {
433            format!(
434                "GATE {:02} / {GATES}",
435                if race.next_gate == 0 {
436                    GATES
437                } else {
438                    race.next_gate
439                }
440            )
441        };
442        centered(&text, 67.0, 17, PALE);
443    }
444    if race.finished {
445        let panel = Rect::new(w * 0.5 - 180.0, h * 0.5 - 113.0, 360.0, 220.0);
446        draw_rectangle(
447            panel.x,
448            panel.y,
449            panel.w,
450            panel.h,
451            Color::new(0.10, 0.19, 0.18, 0.94),
452        );
453        centered("CIRCUIT COMPLETE", h * 0.5 - 65.0, 22, PALE);
454        centered(&time_label(race.elapsed), h * 0.5 - 6.0, 49, WHITE);
455        centered(
456            &format!("3 laps  /  {} impacts", race.impacts),
457            h * 0.5 + 28.0,
458            18,
459            PALE,
460        );
461        centered("R  /  TAP TO RACE AGAIN", h * 0.5 + 76.0, 16, TEAL);
462    }
463}
464fn touch_controls() -> [Rect; 4] {
465    let h = screen_height();
466    let w = screen_width();
467    let size = (w * 0.14).clamp(48.0, 72.0);
468    [
469        Rect::new(18.0, h - size - 20.0, size, size),
470        Rect::new(size + 32.0, h - size - 20.0, size, size),
471        Rect::new(w - size * 2.0 - 32.0, h - size - 20.0, size, size),
472        Rect::new(w - size - 18.0, h - size - 20.0, size, size),
473    ]
474}
475fn read_input(touch_mode: bool) -> Input {
476    let mut input = Input {
477        throttle: f32::from(is_key_down(KeyCode::W) || is_key_down(KeyCode::Up))
478            - f32::from(is_key_down(KeyCode::S) || is_key_down(KeyCode::Down)),
479        steer: f32::from(is_key_down(KeyCode::D) || is_key_down(KeyCode::Right))
480            - f32::from(is_key_down(KeyCode::A) || is_key_down(KeyCode::Left)),
481        boost: is_key_down(KeyCode::Space),
482    };
483    if touch_mode {
484        let rects = touch_controls();
485        for touch in ferravex_render::input::logical_touches()
486            .iter()
487            .filter(|touch| {
488                touch.phase != TouchPhase::Ended && touch.phase != TouchPhase::Cancelled
489            })
490        {
491            if rects[0].contains(touch.position) {
492                input.steer = -1.0;
493            }
494            if rects[1].contains(touch.position) {
495                input.steer = 1.0;
496            }
497            if rects[2].contains(touch.position) {
498                input.throttle = 1.0;
499            }
500            if rects[3].contains(touch.position) {
501                input.boost = true;
502                input.throttle = 1.0;
503            }
504        }
505    }
506    input
507}
508
509ferravex_render::game!(conf, game);
510async fn game() {
511    let course = Course::new();
512    let scene = Scene::new();
513    let material = LitMaterial::new().expect("Ferravex's portable lit material should compile");
514    let mut race = Race::new();
515    let mut clock = FixedClock::new(120.0, 16);
516    let mut demo = Demo::new();
517    ferravex_render::set_quality(ferravex_render::lighting::Quality::Medium);
518    let mut touch_mode = false;
519    let mut fovy = 51.0_f32;
520    let mut camera_position = world(race.position - race.forward() * 11.0, 5.1);
521    let mut camera_target = world(race.position + race.forward() * 5.0, 1.8);
522    loop {
523        ferravex_render::ui::warm_up_font_sizes(&[13, 14, 16, 17, 18, 20, 22, 28, 47, 49, 54, 74]);
524        ui::warm_up_font();
525        touch_mode |= !ferravex_render::input::logical_touches().is_empty();
526        if is_key_pressed(KeyCode::R)
527            || (race.finished
528                && (is_mouse_button_pressed(MouseButton::Left)
529                    || ferravex_render::input::logical_touches()
530                        .iter()
531                        .any(|t| t.phase == TouchPhase::Started)))
532        {
533            race = Race::new();
534            clock.reset();
535            camera_position = world(race.position - race.forward() * 11.0, 5.1);
536            camera_target = world(race.position + race.forward() * 5.0, 1.8);
537        }
538        let input = read_input(touch_mode);
539        let dt = demo.dt();
540        let frame = clock.advance(dt as f64);
541        for _ in 0..frame.steps {
542            race.step(
543                &course,
544                if demo.autoplay {
545                    race.autoplay(&course)
546                } else {
547                    input
548                },
549                frame.dt,
550            );
551        }
552        let position = race.previous_position.lerp(race.position, frame.alpha);
553        let yaw =
554            race.previous_yaw + race::angle_difference(race.yaw, race.previous_yaw) * frame.alpha;
555        let forward = vec2(yaw.sin(), yaw.cos());
556        let eye = world(
557            position - forward * (10.7 + race.speed() * 0.035),
558            4.8 + race.speed() * 0.015,
559        );
560        let target = world(position + forward * 6.0, 1.6);
561        camera_position = camera_position.lerp(eye, 1.0 - (-dt * 5.5).exp());
562        camera_target = camera_target.lerp(target, 1.0 - (-dt * 8.0).exp());
563        fovy += ((if race.boosting { 54.0 } else { 51.0 }) - fovy) * (dt * 5.0).min(1.0);
564        clear_background(SKY);
565        set_camera(&Camera3D {
566            position: camera_position,
567            target: camera_target,
568            up: Vec3::Y,
569            fovy: fovy.to_radians(),
570            z_near: 0.12,
571            z_far: 370.0,
572            ..Default::default()
573        });
574        material.begin(LitSettings {
575            camera_position,
576            light_direction: vec3(-0.45, 0.9, -0.4),
577            fog_color: SKY,
578            fog_start: 75.0,
579            fog_end: 245.0,
580            ambient: 0.62,
581        });
582        scene.draw(&course, &race, position, yaw, demo.time());
583        material.end();
584        set_default_camera();
585        draw_hud(&race, touch_mode);
586        if touch_mode && !race.finished {
587            for (rect, text) in touch_controls().into_iter().zip(["<", ">", "GO", "BOOST"]) {
588                draw_rectangle(
589                    rect.x,
590                    rect.y,
591                    rect.w,
592                    rect.h,
593                    Color::new(0.1, 0.19, 0.18, 0.50),
594                );
595                let text_width = measure_text(text, None, 18, 1.0).width;
596                label(
597                    text,
598                    rect.x + (rect.w - text_width) * 0.5,
599                    rect.y + rect.h * 0.5 + 6.0,
600                    18.0,
601                    PALE,
602                );
603            }
604        }
605        if demo.finish_frame() {
606            println!(
607                "DUNE SKIMMER laps={} next_gate={} time={:.2} finished={} impacts={} recoveries={} finite={}",
608                race.laps,
609                race.next_gate,
610                race.elapsed,
611                race.finished,
612                race.impacts,
613                race.recovery_count,
614                race.position.is_finite()
615            );
616            break;
617        }
618        next_frame().await;
619    }
620}