HexGL

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

▶ Run in browser

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

Tags: port tier-2

HexGL: SimVX Port

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

Run

# Interactive (Vulkan window)
uv run python ported_games/hexgl/simvx_port/main.py

# Headless capture sweep: writes 8 PNGs into screenshots/
uv run python ported_games/hexgl/simvx_port/main.py --test

# Smoke harness (no graphics)
uv run python ported_games/hexgl/simvx_port/harness.py

# Web export (single HTML)
uv run simvx export web ported_games/hexgl/simvx_port/main.py \
    -o ported_games/hexgl/simvx_port/web/index.html

Controls

Action

Keys

Thrust

W / Up

Brake

S / Down

Steer left / right

A / D / Left / Right

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

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