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

# the export runs in a copy made by `simvx examples copy ports_hexgl`
simvx examples run ports_hexgl            # interactive
simvx examples run ports_hexgl --test     # headless capture sweep (8 PNGs into screenshots/)
simvx export web main.py \
    -o 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.

300

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.

78

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.

346

nodes/track.py

Procedural anti-grav race track.

352

Source

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