HexGL

3D anti-grav racer, Catmull-Rom banked track, lap-attack, AI ghost.

▶ Run in browser

Upstream: https://github.com/BKcore/HexGL

Licence: this port's own code is offered under MIT, not the SimVX Examples Licence the rest of the gallery carries. See ATTRIBUTION.md for the upstream it re-implements, the terms of anything it bundles, and the credit each one requires.

Ports live in the repository only, not in the simvx-examples distribution, because each is a derivative work licensed individually against the game it re-implements. Read it with git clone https://git.simvx.com/simvx/simvx.

Tags: port tier-2

HexGL: SimVX Port

A SimVX port of BKcore/HexGL (anti-grav 3D racer in the WipEout tradition).

Run

# from the repo root
uv run python examples/ports/hexgl/main.py            # interactive
uv run python examples/ports/hexgl/main.py --test     # headless capture sweep (8 PNGs into screenshots/)
uv run python examples/ports/hexgl/harness.py         # smoke harness (no graphics)
uv run simvx export web examples/ports/hexgl/main.py \
    -o /tmp/hexgl.html

Controls

The title screen starts the race on Space, Enter or a click; the race is fully playable with a mouse or a touchscreen.

Action

Keys

Pointer

Thrust

W / Up

hold left button

Steer left / right

A / D / Left / Right

slide the held pointer left / right

Brake

S / Down

right button

Air-brake left

Q / Left-Shift

Air-brake right

E / Right-Shift

Restart race

R

Quit

Esc

Track and ships

The track is procedurally generated from an 8-point Catmull-Rom spline, extruded along the curve into a banked floor + side walls. 6 virtual checkpoints, 3 emissive boost pads. The hull and ghost AI are built from SimVX Mesh.cone + Mesh.sphere primitives. No upstream assets are redistributed: upstream’s track meshes and HUD textures are CC-BY-NC.

Source files

File

Summary

Lines

main.py

HexGL: 3D anti-grav racer, Catmull-Rom banked track, lap-attack, AI ghost.

310

harness.py

SceneRunner-driven smoke harness for HexGL.

55

nodes/__init__.py

HexGL port nodes.

1

nodes/ai_ship.py

Ghost AI opponent: drives the centreline at a fixed pace.

74

nodes/camera.py

Racer chase camera: speed pull-back on a roll-free rig.

70

nodes/hud.py

Speedometer / shield / boost / lap timer HUD.

170

nodes/menu.py

Title screen: the port’s menu-first entry point.

88

nodes/race.py

Race manager: checkpoints, lap tracking, end-of-race state.

140

nodes/ship.py

Player ship: anti-grav with thrust, drift, roll, banked turning.

357

nodes/track.py

Procedural anti-grav race track.

352

Source

  1"""HexGL: 3D anti-grav racer, Catmull-Rom banked track, lap-attack, AI ghost.
  2
  3# /// simvx
  4# tags = ["port", "tier-2"]
  5# upstream = "https://github.com/BKcore/HexGL"
  6# web = { width = 1280, height = 720, responsive = true }
  7# ///
  8
  9A SimVX port of BKcore's HexGL. Nothing is loaded from disk: the track is a
 10closed Catmull-Rom spline extruded at runtime into a banked floor, side walls
 11and emissive boost pads. On top of it sit track-frame ship physics, a 3-lap
 12time-attack race, an AI ghost to chase, WorldEnvironment post-FX (bloom,
 13motion blur, exponential fog, ACES tonemapping) and a Text2D + on_draw HUD
 14layered over the 3D scene.
 15
 16Controls: W/Up thrust, A/D or the arrow keys steer, S/Down brake, Q/E
 17air-brake, R restart, Esc quit. Mouse and touch play too: hold the left
 18button to thrust and slide left or right to steer, right button to brake.
 19
 20Run:
 21    uv run python examples/ports/hexgl/main.py            # interactive
 22    uv run python examples/ports/hexgl/main.py --test     # headless capture sweep
 23"""
 24
 25from __future__ import annotations
 26
 27import sys
 28from collections.abc import Callable
 29from pathlib import Path
 30
 31_PORT_DIR = Path(__file__).parent
 32if str(_PORT_DIR) not in sys.path:
 33    sys.path.insert(0, str(_PORT_DIR))
 34
 35from nodes.ai_ship import GhostShip  # noqa: E402
 36from nodes.camera import RacerCamera  # noqa: E402
 37from nodes.hud import HexHUD  # noqa: E402
 38from nodes.menu import TitleScreen  # noqa: E402
 39from nodes.race import RaceManager  # noqa: E402
 40from nodes.ship import Ship  # noqa: E402
 41from nodes.track import Track  # noqa: E402
 42
 43from simvx.core import (  # noqa: E402
 44    DirectionalLight3D,
 45    Input,
 46    InputMap,
 47    Key,
 48    MouseButton,
 49    Node,
 50    Vec3,
 51    WorldEnvironment,
 52)
 53from simvx.graphics import App  # noqa: E402
 54
 55WIDTH = 1280
 56HEIGHT = 720
 57
 58
 59def _make_environment_map() -> dict:
 60    """Return the WorldEnvironment.environment_map spec.
 61
 62    If `assets/sky.hdr` exists, load it as an equirect HDR cubemap; otherwise
 63    return a procedural twilight gradient.
 64    """
 65    hdr_path = _PORT_DIR / "assets" / "sky.hdr"
 66    if hdr_path.exists():
 67        return {"path": str(hdr_path)}
 68    return {"colour": (0.04, 0.05, 0.10)}
 69
 70
 71class HexGLRoot(Node):
 72    """Root scene: Track + Ship + GhostShip + RacerCamera + RaceManager + HUD + title."""
 73
 74    def on_ready(self) -> None:
 75        # Input: root.on_ready (web export skips main()). The pointer bindings
 76        # keep the web/touch build playable: touch arrives as MouseButton.LEFT.
 77        InputMap.add_action("thrust", [Key.W, Key.UP, MouseButton.LEFT])
 78        InputMap.add_action("brake", [Key.S, Key.DOWN, MouseButton.RIGHT])
 79        InputMap.add_action("steer_left", [Key.A, Key.LEFT])
 80        InputMap.add_action("steer_right", [Key.D, Key.RIGHT])
 81        InputMap.add_action("airbrake_left", [Key.Q, Key.LEFT_SHIFT])
 82        InputMap.add_action("airbrake_right", [Key.E, Key.RIGHT_SHIFT])
 83        InputMap.add_action("restart", [Key.R])
 84        InputMap.add_action("quit", [Key.ESCAPE])
 85
 86        # Environment with race-feel post-FX. Bloom on emissive thrusters,
 87        # motion blur for speed, mild fog for depth.
 88        env = WorldEnvironment(environment_map=_make_environment_map())
 89        env.bloom_enabled = True
 90        env.bloom_threshold = 0.95
 91        env.bloom_intensity = 0.85
 92        env.tonemap_mode = "aces"
 93        env.tonemap_exposure = 1.05
 94        env.motion_blur_enabled = True
 95        env.motion_blur_intensity = 0.55
 96        env.motion_blur_samples = 12
 97        env.fog_enabled = True
 98        env.fog_mode = "exponential"
 99        env.fog_colour = (0.06, 0.08, 0.13, 1.0)
100        env.fog_density = 0.008
101        env.ambient_light_colour = (0.10, 0.12, 0.18, 1.0)
102        env.ambient_light_energy = 0.7
103        self.add_child(env)
104
105        # Sun.
106        sun = DirectionalLight3D(name="Sun", intensity=1.7)
107        sun.colour = (1.0, 0.9, 0.78)
108        sun.position = Vec3(40.0, 60.0, 30.0)
109        sun.look_at(Vec3(0.0, 0.0, 0.0))
110        self.add_child(sun)
111
112        # Track.
113        self.track = Track(width=14.0, length_segments=400, name="Track")
114        self.add_child(self.track)
115
116        # Player ship.
117        self.ship = Ship(track=self.track, name="Ship")
118        self.add_child(self.ship)
119
120        # AI opponent: start about 10 % of the track ahead so the player overtakes.
121        self.ghost = GhostShip(track=self.track, base_speed=58.0, t_offset=0.1, name="Ghost")
122        self.add_child(self.ghost)
123
124        # Chase camera: close enough to see hull detail, but high enough to see road.
125        self.camera = RacerCamera(
126            target=self.ship,
127            distance=8.0,
128            height=2.8,
129            look_ahead=6.0,
130            half_life=0.12,
131            fov=68.0,
132            near=0.1,
133            far=600.0,
134            name="ChaseCam",
135        )
136        self.add_child(self.camera)
137
138        # Race manager. It stays idle until the title screen hands over.
139        self.race = RaceManager(ship=self.ship, track=self.track, max_laps=3, name="Race")
140        self.add_child(self.race)
141
142        # HUD, then the title card on top of it.
143        self.hud = HexHUD(ship=self.ship, race=self.race, name="HUD")
144        self.add_child(self.hud)
145
146        self.title = TitleScreen(name="Title")
147        self.title.started.connect(self.begin_race)
148        self.add_child(self.title)
149
150    def begin_race(self) -> None:
151        """Leave the title screen and arm the start lights."""
152        self.title.active = False
153        self.ship.reset()
154        self.race.start()
155
156    def on_update(self, dt: float) -> None:
157        # The ship only answers to the controls once the lights are out.
158        self.ship.controls_enabled = self.race.racing
159
160        if Input.is_action_just_pressed("quit"):
161            self.app.quit()
162            return
163        if self.race.phase != "idle" and Input.is_action_just_pressed("restart"):
164            self.ship.reset()
165            self.race.start()
166
167
168# -----------------------------------------------------------------------------
169# Headless --test capture
170# -----------------------------------------------------------------------------
171
172
173def _run_headless() -> None:
174    """Capture 8 staged screenshots with run_headless."""
175    from simvx.graphics import save_png
176
177    out_dir = _PORT_DIR / "screenshots"
178    out_dir.mkdir(exist_ok=True)
179
180    app = App(width=WIDTH, height=HEIGHT, title="HexGL (test)", visible=False)
181    root = HexGLRoot()
182
183    Stage = Callable[[HexGLRoot], None]
184
185    # Stage schedule: (capture_frame_idx, description, mutator).
186    stages: list[tuple[int, str, Stage]] = []
187
188    def stage(idx: int, name: str, fn: Stage) -> None:
189        stages.append((idx, name, fn))
190
191    def racing(r: HexGLRoot) -> None:
192        """Leave the title card and drop straight into the green flag."""
193        r.begin_race()
194        r.race.skip_countdown()
195
196    # Frame 30: the title card over the lit track.
197    stage(30, "01_title.png", lambda r: None)
198
199    # Frame 90: thrust on a straight section.
200    def go_straight(r):
201        racing(r)
202        r.ship.teleport(0.05, speed=50.0)
203        r.camera.snap()
204
205    stage(90, "02_straight.png", go_straight)
206
207    # Frame 130: banked turn.
208    def banked(r):
209        racing(r)
210        r.ship.teleport(0.30, lateral=2.0, speed=45.0)
211        r.camera.snap()
212
213    stage(130, "03_banked_turn.png", banked)
214
215    # Frame 170: boost pad effect (mid-pad).
216    def boosting(r):
217        racing(r)
218        r.ship.teleport(0.51, speed=60.0, boost=r.ship.booster_speed)
219        r.camera.snap()
220
221    stage(170, "04_boost.png", boosting)
222
223    # Frame 210: AI overtake, ghost is just ahead, player drawing alongside on the inside.
224    def overtake(r):
225        racing(r)
226        r.ship.teleport(0.42, lateral=-2.5, speed=65.0)  # inside lane
227        r.ghost.teleport(0.425)  # slightly ahead, on the right
228        r.camera.snap()
229
230    stage(210, "05_ai_overtake.png", overtake)
231
232    # Frame 250: lap completed UI, fake the race state.
233    def lap_done(r):
234        racing(r)
235        r.ship.teleport(0.02, speed=55.0)
236        r.race.lap = 2
237        r.race.elapsed = 47.235
238        r.race.best_lap = 47.235
239        r.race.lap_times = [47.235]
240        r.camera.snap()
241
242    stage(250, "06_lap_complete.png", lap_done)
243
244    # Frame 290: final straight, high speed motion blur showcase.
245    def final_straight(r):
246        racing(r)
247        r.ship.teleport(0.92, speed=72.0, boost=r.ship.booster_speed)
248        r.camera.snap()
249
250    stage(290, "07_final_straight.png", final_straight)
251
252    # Frame 330: race finished UI.
253    def finished(r):
254        racing(r)
255        r.ship.teleport(0.0, speed=25.0)
256        r.race.lap = 3
257        r.race.elapsed = 142.18
258        r.race.best_lap = 47.235
259        r.race.lap_times = [47.235, 47.5, 47.4]
260        r.race.dnf = False
261        r.race.phase = "finished"
262        r.camera.snap()
263
264    stage(330, "08_finish.png", finished)
265
266    capture_frames = [s[0] for s in stages]
267    schedule: dict[int, Stage] = {}
268    for capture_idx, _name, mutator in stages:
269        # Run the mutator a few frames *before* the capture so physics catches up.
270        schedule[max(0, capture_idx - 4)] = mutator
271
272    total_frames = max(capture_frames) + 4
273
274    def on_frame(idx, _t):
275        fn = schedule.get(idx)
276        if fn is not None:
277            try:
278                fn(root)
279            except Exception as exc:  # don't crash the sweep
280                print(f"[--test] stage at frame {idx} failed: {exc!r}")
281        return None
282
283    captured = app.run_headless(
284        root,
285        frames=total_frames,
286        capture_frames=capture_frames,
287        on_frame=on_frame,
288    )
289
290    for (_idx, name, _fn), img in zip(stages, captured, strict=False):
291        # The captured frame keeps the blended destination alpha; force it opaque
292        # so the PNG is not written half-transparent.
293        try:
294            img[..., 3] = 255
295        except Exception:
296            pass
297        save_png(img, out_dir / name)
298        print(f"saved {out_dir / name}")
299
300
301def main() -> None:
302    if "--test" in sys.argv:
303        _run_headless()
304        return
305    app = App(width=WIDTH, height=HEIGHT, title="HexGL (SimVX)")
306    app.run(HexGLRoot())
307
308
309if __name__ == "__main__":
310    main()